mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(links): relax link_source CHECK to kebab-case provenance + migration v114 Open link_source from a closed allowlist to a kebab-case format gate (^[a-z][a-z0-9]*(-[a-z0-9]+)*$, char_length<=64) so external derivers stamp their own provenance (e.g. citation-graph) without a per-deriver migration. Migration v114: Postgres NOT VALID + VALIDATE (lock-friendly, transaction:false); PGLite plain DROP+ADD. Updates the schema.sql + engine provenance contract comments. (#1941) * feat(links): expose link provenance on link ops + link-add/link-rm/link-sources add_link/remove_link now accept --link-source/--link-type; add_link guards the reconciliation-managed built-ins (markdown/frontmatter/mentions/ wikilink-resolved) and defaults omitted provenance to 'manual' (was the misleading engine default 'markdown'). New cliHints.aliases mechanism with a startup collision guard registers link-add/link-rm; printOpHelp shows the invoked alias name. New list_link_sources read op + listLinkSources engine method (both engines, {sourceId?,sourceIds?}, deterministic order) powers `gbrain link-sources`, added to the minion read allowlist. (#1941) * test(links): kebab provenance, op guard, link-sources, aliases + parity Covers the v114 regex/length boundaries, upgrade-path constraint swap on existing data, the managed-built-in op guard + manual default, remove_link type/source filters, list_link_sources scoping (scalar + federated) and PG/PGLite parity, and alias resolution/collision/help. Fixes the prior 'inferred'-rejection assertion (now valid kebab) in the mentions test. (#1941) * chore: bump version and changelog (v0.42.31.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update KEY_FILES for v0.42.31.0 link provenance surface KEY_FILES.md current-state updates for #1941: link_source now an open kebab-case provenance (migration v114), the add_link/remove_link guard + defaults, list_link_sources + listLinkSources, and cliHints.aliases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(minions): supervisor queue-singleton keying + pidfile cleanup (follow-up #1849) Two correctness bugs in the v0.42.29.0 supervisor-singleton work, caught by adversarial review: - supervisorLockId mixed a config-derived DB identity into the key, but the lock row already lives inside the target database. Two supervisors on the same physical DB via different-but-equivalent URLs (pooler vs direct port, host alias, trailing params) computed different ids and BOTH acquired the "singleton" lock. Key on the queue alone; the database half of the mutex is physical. Removes the now-dead currentDbIdentity() from worker-registry. - The pidfile-cleanup process.on('exit') listener was installed AFTER the DB-lock acquire, so the LOCK_HELD early-exit stranded the pidfile this process had just created. Install the listener first. Regression test pins the listener-before-lock ordering; updates the lockId test to the queue-only invariant; KEY_FILES updated to current state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f8d4ce6fc4
commit
f401d7407e
@@ -44,11 +44,13 @@ describe('BRAIN_TOOL_ALLOWLIST', () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('contains the v0.15 read-only 10 + put_page + v0.29 salience pair', () => {
|
||||
test('contains the v0.15 read-only 10 + put_page + v0.29 salience pair + v114 list_link_sources', () => {
|
||||
// v0.29 added get_recent_salience + find_anomalies (read-only).
|
||||
// get_recent_transcripts is deliberately excluded — subagent calls always
|
||||
// have ctx.remote=true, and the v0.29 trust gate rejects remote callers.
|
||||
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(13);
|
||||
// v114 (#1941) added list_link_sources (read-only provenance discovery);
|
||||
// the edge-WRITE ops add_link/remove_link stay out (separate trust call).
|
||||
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(14);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('query')).toBe(true);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('search')).toBe(true);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('get_page')).toBe(true);
|
||||
@@ -56,6 +58,9 @@ describe('BRAIN_TOOL_ALLOWLIST', () => {
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('put_page')).toBe(true);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_salience')).toBe(true);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('find_anomalies')).toBe(true);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('list_link_sources')).toBe(true);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('add_link')).toBe(false);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('remove_link')).toBe(false);
|
||||
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_transcripts')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -339,6 +339,31 @@ describeBoth('Engine parity — Postgres vs PGLite', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('v114 (#1941) listLinkSources parity: same ordered provenance counts on both engines', async () => {
|
||||
const mk = async (eng: BrainEngine) => {
|
||||
for (const s of ['lsp-a', 'lsp-b', 'lsp-c']) {
|
||||
await eng.putPage(s, { type: 'note', title: s, compiled_truth: 'b', timeline: '' });
|
||||
}
|
||||
// citation-graph:2, manual:1 — exercises count DESC + the kebab regex.
|
||||
await eng.addLink('lsp-a', 'lsp-b', '', 'cites', 'citation-graph');
|
||||
await eng.addLink('lsp-a', 'lsp-c', '', 'cites', 'citation-graph');
|
||||
await eng.addLink('lsp-b', 'lsp-c', '', 'rel', 'manual');
|
||||
};
|
||||
await mk(pgEngine);
|
||||
await mk(pgliteEngine);
|
||||
|
||||
const pg = await pgEngine.listLinkSources({ sourceId: 'default' });
|
||||
const pglite = await pgliteEngine.listLinkSources({ sourceId: 'default' });
|
||||
|
||||
const norm = (rows: { link_source: string | null; count: number }[]) =>
|
||||
rows.filter(r => r.link_source === 'citation-graph' || r.link_source === 'manual');
|
||||
expect(norm(pg)).toEqual(norm(pglite));
|
||||
// citation-graph (2) must order before manual (1) on both engines.
|
||||
const cgIdx = pg.findIndex(r => r.link_source === 'citation-graph');
|
||||
const mIdx = pg.findIndex(r => r.link_source === 'manual');
|
||||
expect(cgIdx).toBeLessThan(mIdx);
|
||||
});
|
||||
|
||||
test('v0.41.19.0 resolveSlugsByPaths parity: same Map on both engines', async () => {
|
||||
const seedSql = `
|
||||
INSERT INTO pages (source_id, slug, source_path, type, title, compiled_truth, timeline, frontmatter)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* v114 (#1941): link_source opened from a closed allowlist to a kebab-case
|
||||
* regex + length cap, provenance exposed on the link ops with a managed-
|
||||
* built-in guard, a new `list_link_sources` read op, and `link-add`/`link-rm`
|
||||
* CLI aliases.
|
||||
*
|
||||
* Coverage:
|
||||
* - Migration v114 shape (two engine branches, transaction:false, idempotent)
|
||||
* - DB CHECK boundary: kebab accepted (incl. all 5 built-ins), garbage rejected
|
||||
* - Upgrade-path: existing built-in row survives the constraint swap (re-run)
|
||||
* - add_link op: provenance threaded, default 'manual', managed-built-in guard
|
||||
* - remove_link op: link_type / link_source / both filters
|
||||
* - list_link_sources op: counts, deterministic order, scalar + federated scope
|
||||
* - CLI aliases resolve; printOpHelp shows the invoked alias name; no collisions
|
||||
*
|
||||
* Hermetic via PGLite. No DATABASE_URL needed.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { MIGRATIONS, LATEST_VERSION } from '../src/core/migrate.ts';
|
||||
import {
|
||||
operations,
|
||||
operationsByName,
|
||||
MANAGED_LINK_SOURCES,
|
||||
} from '../src/core/operations.ts';
|
||||
import type { OperationContext } from '../src/core/operations.ts';
|
||||
import { cliAliases, printOpHelp } from '../src/cli.ts';
|
||||
|
||||
const V = 114;
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
engine,
|
||||
remote: false,
|
||||
config: {},
|
||||
logger: console,
|
||||
dryRun: false,
|
||||
...overrides,
|
||||
} as unknown as OperationContext;
|
||||
}
|
||||
|
||||
// Two pages every link test reuses.
|
||||
async function seedPages() {
|
||||
await engine.putPage('lsrc-a', { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} });
|
||||
await engine.putPage('lsrc-b', { type: 'note', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} });
|
||||
}
|
||||
|
||||
describe('migration v114 — links_link_source_check_kebab_regex', () => {
|
||||
test('registered with expected version + name', () => {
|
||||
const m = MIGRATIONS.find(m => m.version === V);
|
||||
expect(m).toBeDefined();
|
||||
expect(m!.name).toBe('links_link_source_check_kebab_regex');
|
||||
});
|
||||
|
||||
test('LATEST_VERSION >= 114', () => {
|
||||
expect(LATEST_VERSION).toBeGreaterThanOrEqual(V);
|
||||
});
|
||||
|
||||
test('both engine branches present; transaction:false; idempotent', () => {
|
||||
const m = MIGRATIONS.find(m => m.version === V)!;
|
||||
expect(m.transaction).toBe(false);
|
||||
expect(m.idempotent).toBe(true);
|
||||
expect(m.sqlFor?.postgres).toBeTruthy();
|
||||
expect(m.sqlFor?.pglite).toBeTruthy();
|
||||
});
|
||||
|
||||
test('postgres branch uses NOT VALID + VALIDATE (lock-friendly)', () => {
|
||||
const m = MIGRATIONS.find(m => m.version === V)!;
|
||||
const pg = m.sqlFor!.postgres!;
|
||||
expect(pg).toMatch(/DROP CONSTRAINT IF EXISTS links_link_source_check/i);
|
||||
expect(pg).toContain('NOT VALID');
|
||||
expect(pg).toMatch(/VALIDATE CONSTRAINT links_link_source_check/i);
|
||||
expect(pg).toContain("'^[a-z][a-z0-9]*(-[a-z0-9]+)*$'");
|
||||
expect(pg).toContain('char_length(link_source) <= 64');
|
||||
});
|
||||
|
||||
test('pglite branch is plain DROP+ADD with the same regex', () => {
|
||||
const m = MIGRATIONS.find(m => m.version === V)!;
|
||||
const pl = m.sqlFor!.pglite!;
|
||||
expect(pl).toMatch(/DROP CONSTRAINT IF EXISTS links_link_source_check/i);
|
||||
expect(pl).not.toContain('NOT VALID');
|
||||
expect(pl).toContain("'^[a-z][a-z0-9]*(-[a-z0-9]+)*$'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('DB CHECK — kebab format gate', () => {
|
||||
beforeAll(seedPages);
|
||||
|
||||
const ACCEPTED = [
|
||||
'citation-graph', 'derived', 'a',
|
||||
// OV7 — all 5 reconciliation-managed built-ins must stay DB-valid
|
||||
'markdown', 'frontmatter', 'manual', 'mentions', 'wikilink-resolved',
|
||||
];
|
||||
const REJECTED: Array<[string, string]> = [
|
||||
['UPPER', 'uppercase'],
|
||||
['has_underscore', 'underscore'],
|
||||
['has space', 'space'],
|
||||
['2bad', 'leading digit'],
|
||||
['-lead', 'leading dash'],
|
||||
['trail-', 'trailing dash'],
|
||||
['a--b', 'double dash'],
|
||||
['', 'empty'],
|
||||
['a'.repeat(65), '65 chars (over cap)'],
|
||||
];
|
||||
|
||||
for (const v of ACCEPTED) {
|
||||
test(`accepts '${v}'`, async () => {
|
||||
// engine.addLink writes link_source straight through (no op guard) — this
|
||||
// is the DB CHECK under test, not the op layer.
|
||||
await expect(
|
||||
engine.addLink('lsrc-a', 'lsrc-b', '', `t-${v}`, v),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
for (const [v, why] of REJECTED) {
|
||||
test(`rejects '${v}' (${why})`, async () => {
|
||||
await expect(
|
||||
engine.addLink('lsrc-a', 'lsrc-b', '', `t-rej-${why}`, v),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('upgrade-path — existing rows survive the constraint swap', () => {
|
||||
test('built-in row present, re-running v114 succeeds, new kebab tag inserts', async () => {
|
||||
await seedPages();
|
||||
// A row tagged with a pre-existing built-in value (would have been valid
|
||||
// under the old allowlist too).
|
||||
await engine.addLink('lsrc-a', 'lsrc-b', '', 't-upgrade', 'markdown');
|
||||
const m = MIGRATIONS.find(m => m.version === V)!;
|
||||
// Re-apply the migration against the table that now holds data: ADD/VALIDATE
|
||||
// must not fail on the existing row.
|
||||
await expect(engine.runMigration(V, m.sqlFor!.pglite!)).resolves.toBeUndefined();
|
||||
// And a brand-new external provenance still inserts afterward.
|
||||
await expect(
|
||||
engine.addLink('lsrc-a', 'lsrc-b', '', 't-upgrade', 'citation-graph'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_link op — provenance + managed-built-in guard (OV4A)', () => {
|
||||
beforeAll(seedPages);
|
||||
|
||||
test('threads custom provenance through to the row', async () => {
|
||||
const op = operationsByName['add_link'];
|
||||
await op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'cites', link_source: 'citation-graph' });
|
||||
const links = await engine.getLinks('lsrc-a');
|
||||
expect(links.some(l => (l as any).to_slug === 'lsrc-b' && (l as any).link_source === 'citation-graph' && (l as any).link_type === 'cites')).toBe(true);
|
||||
});
|
||||
|
||||
test("omitted link_source defaults to 'manual' (not 'markdown')", async () => {
|
||||
const op = operationsByName['add_link'];
|
||||
await op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'default-prov' });
|
||||
const links = await engine.getLinks('lsrc-a');
|
||||
const row = links.find(l => (l as any).link_type === 'default-prov');
|
||||
expect((row as any)?.link_source).toBe('manual');
|
||||
});
|
||||
|
||||
for (const managed of MANAGED_LINK_SOURCES) {
|
||||
test(`rejects managed built-in '${managed}'`, async () => {
|
||||
const op = operationsByName['add_link'];
|
||||
await expect(
|
||||
op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'g', link_source: managed }),
|
||||
).rejects.toThrow(/reconciliation-managed/);
|
||||
});
|
||||
}
|
||||
|
||||
test("'manual' is allowed (not in the managed set)", async () => {
|
||||
const op = operationsByName['add_link'];
|
||||
await expect(
|
||||
op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'man', link_source: 'manual' }),
|
||||
).resolves.toMatchObject({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove_link op — type/source filters', () => {
|
||||
async function seedTwoProvenances() {
|
||||
await engine.putPage('rm-a', { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} });
|
||||
await engine.putPage('rm-b', { type: 'note', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} });
|
||||
await engine.addLink('rm-a', 'rm-b', '', 'cites', 'manual');
|
||||
await engine.addLink('rm-a', 'rm-b', '', 'cites', 'citation-graph');
|
||||
}
|
||||
|
||||
test('--link-source filter deletes only that provenance', async () => {
|
||||
await seedTwoProvenances();
|
||||
const op = operationsByName['remove_link'];
|
||||
await op.handler(makeCtx(), { from: 'rm-a', to: 'rm-b', link_source: 'citation-graph' });
|
||||
const links = await engine.getLinks('rm-a');
|
||||
const sources = links.filter(l => (l as any).to_slug === 'rm-b').map(l => (l as any).link_source);
|
||||
expect(sources).toContain('manual');
|
||||
expect(sources).not.toContain('citation-graph');
|
||||
});
|
||||
|
||||
test('--link-type-only filter deletes every provenance of that type', async () => {
|
||||
await seedTwoProvenances();
|
||||
const op = operationsByName['remove_link'];
|
||||
await op.handler(makeCtx(), { from: 'rm-a', to: 'rm-b', link_type: 'cites' });
|
||||
const links = await engine.getLinks('rm-a');
|
||||
expect(links.filter(l => (l as any).to_slug === 'rm-b' && (l as any).link_type === 'cites').length).toBe(0);
|
||||
});
|
||||
|
||||
test('both filters delete only the type+source match', async () => {
|
||||
await seedTwoProvenances();
|
||||
const op = operationsByName['remove_link'];
|
||||
await op.handler(makeCtx(), { from: 'rm-a', to: 'rm-b', link_type: 'cites', link_source: 'manual' });
|
||||
const links = await engine.getLinks('rm-a');
|
||||
const sources = links.filter(l => (l as any).to_slug === 'rm-b').map(l => (l as any).link_source);
|
||||
expect(sources).toContain('citation-graph');
|
||||
expect(sources).not.toContain('manual');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_link_sources op (B4 + OV8 + OV9)', () => {
|
||||
test('op declaration: read scope, link-sources CLI name, not localOnly', () => {
|
||||
const op = operationsByName['list_link_sources'];
|
||||
expect(op).toBeDefined();
|
||||
expect(op.scope).toBe('read');
|
||||
expect(op.localOnly).not.toBe(true);
|
||||
expect(op.cliHints?.name).toBe('link-sources');
|
||||
});
|
||||
|
||||
test('returns distinct provenances with counts, ordered count DESC then name ASC', async () => {
|
||||
await engine.putPage('ls-a', { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} });
|
||||
await engine.putPage('ls-b', { type: 'note', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} });
|
||||
await engine.putPage('ls-c', { type: 'note', title: 'C', compiled_truth: 'c', timeline: '', frontmatter: {} });
|
||||
// zeta:1, alpha:2 — alpha sorts first by count; within ties, name ASC.
|
||||
await engine.addLink('ls-a', 'ls-b', '', 'r1', 'alpha-src');
|
||||
await engine.addLink('ls-a', 'ls-c', '', 'r2', 'alpha-src');
|
||||
await engine.addLink('ls-b', 'ls-c', '', 'r3', 'zeta-src');
|
||||
|
||||
const op = operationsByName['list_link_sources'];
|
||||
const rows = (await op.handler(makeCtx(), {})) as Array<{ link_source: string | null; count: number }>;
|
||||
const map = new Map(rows.map(r => [r.link_source, r.count]));
|
||||
expect(map.get('alpha-src')).toBe(2);
|
||||
expect(map.get('zeta-src')).toBe(1);
|
||||
// Deterministic order: counts non-increasing.
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
expect(rows[i - 1].count >= rows[i].count).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('scalar ctx.sourceId scopes; a non-matching source returns nothing', async () => {
|
||||
const op = operationsByName['list_link_sources'];
|
||||
// Pages above were written to the 'default' source.
|
||||
const inDefault = (await op.handler(makeCtx({ sourceId: 'default' } as any), {})) as unknown[];
|
||||
expect(inDefault.length).toBeGreaterThan(0);
|
||||
const inOther = (await op.handler(makeCtx({ sourceId: 'no-such-source' } as any), {})) as unknown[];
|
||||
expect(inOther.length).toBe(0);
|
||||
});
|
||||
|
||||
test('federated allowedSources ({sourceIds}) scopes (OV8)', async () => {
|
||||
const op = operationsByName['list_link_sources'];
|
||||
const inDefault = (await op.handler(makeCtx({ auth: { allowedSources: ['default'] } } as any), {})) as unknown[];
|
||||
expect(inDefault.length).toBeGreaterThan(0);
|
||||
const inOther = (await op.handler(makeCtx({ auth: { allowedSources: ['no-such-source'] } } as any), {})) as unknown[];
|
||||
expect(inOther.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI aliases + help (OV10 + OV11)', () => {
|
||||
test('link-add resolves to add_link, link-rm to remove_link', () => {
|
||||
expect(cliAliases.get('link-add')?.name).toBe('add_link');
|
||||
expect(cliAliases.get('link-rm')?.name).toBe('remove_link');
|
||||
});
|
||||
|
||||
test('no alias collides with any primary CLI name or another alias', () => {
|
||||
const primaries = new Set(operations.map(o => o.cliHints?.name).filter(Boolean) as string[]);
|
||||
const seen = new Set<string>();
|
||||
for (const op of operations) {
|
||||
for (const alias of op.cliHints?.aliases ?? []) {
|
||||
expect(primaries.has(alias)).toBe(false);
|
||||
expect(seen.has(alias)).toBe(false);
|
||||
seen.add(alias);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('printOpHelp shows the invoked alias name, not the primary (OV10)', () => {
|
||||
const op = operationsByName['add_link'];
|
||||
const lines: string[] = [];
|
||||
const orig = console.log;
|
||||
console.log = (...a: unknown[]) => { lines.push(a.join(' ')); };
|
||||
try {
|
||||
printOpHelp(op, 'link-add');
|
||||
} finally {
|
||||
console.log = orig;
|
||||
}
|
||||
const out = lines.join('\n');
|
||||
expect(out).toContain('Usage: gbrain link-add');
|
||||
expect(out).not.toContain('Usage: gbrain link ');
|
||||
});
|
||||
});
|
||||
@@ -97,19 +97,21 @@ describe('fresh-init brain (post-migration v95) accepts link_source=mentions', (
|
||||
expect(rows.some(r => r.link_source === 'mentions')).toBe(true);
|
||||
});
|
||||
|
||||
test('CHECK still rejects an unknown source value (widening did not nullify the gate)', async () => {
|
||||
test('CHECK still rejects a malformed source value (gate not nullified)', async () => {
|
||||
const slugA = `bad-source-a-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const slugB = `bad-source-b-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await engine.putPage(slugA, { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} });
|
||||
await engine.putPage(slugB, { type: 'person', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} });
|
||||
// 'inferred' is NOT in allow-list ∪ {'mentions'} — must reject.
|
||||
// v114 (#1941): the closed allowlist became a kebab-case regex, so a once-
|
||||
// illegal-but-kebab value like 'inferred' is now ACCEPTED. The gate still
|
||||
// bites on MALFORMED values — 'Inferred_X' has an uppercase + underscore.
|
||||
await expect(
|
||||
engine.addLinksBatch([
|
||||
{
|
||||
from_slug: slugA,
|
||||
to_slug: slugB,
|
||||
link_type: 'mentions',
|
||||
link_source: 'inferred' as any,
|
||||
link_source: 'Inferred_X' as any,
|
||||
context: 'should reject',
|
||||
},
|
||||
]),
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* - refresh-failure past the threshold fails SAFE (exits non-zero) (F1A)
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from 'bun:test';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { tryAcquireDbLock } from '../src/core/db-lock.ts';
|
||||
import { MinionSupervisor, ExitCodes, supervisorLockId, classifySupervisorSingleton } from '../src/core/minions/supervisor.ts';
|
||||
@@ -32,12 +35,16 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe('#1849 supervisorLockId', () => {
|
||||
test('keys on DB identity AND queue', () => {
|
||||
expect(supervisorLockId('default', 'postgres://x')).toBe('gbrain-supervisor:postgres://x:default');
|
||||
expect(supervisorLockId('shell', 'postgres://x')).toBe('gbrain-supervisor:postgres://x:shell');
|
||||
// Different DB identity → different lock even for the same queue.
|
||||
expect(supervisorLockId('default', 'postgres://a'))
|
||||
.not.toBe(supervisorLockId('default', 'postgres://b'));
|
||||
test('keys on queue ONLY (DB scoping is physical — the lock row lives in the DB)', () => {
|
||||
expect(supervisorLockId('default')).toBe('gbrain-supervisor:default');
|
||||
expect(supervisorLockId('shell')).toBe('gbrain-supervisor:shell');
|
||||
// Different queues → different locks.
|
||||
expect(supervisorLockId('default')).not.toBe(supervisorLockId('shell'));
|
||||
// Regression (the bug this fixes): the id must NOT depend on how the same
|
||||
// physical DB was addressed. Two supervisors on one DB via different URLs
|
||||
// must compute the SAME id so they collide on the one shared locks table.
|
||||
// The function takes no DB-identity arg precisely so it can't diverge.
|
||||
expect(supervisorLockId.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +82,7 @@ describe('#1849 classifySupervisorSingleton (doctor)', () => {
|
||||
|
||||
describe('#1849 DB lock is the real singleton', () => {
|
||||
test('second acquire of the same (db, queue) lock is refused', async () => {
|
||||
const id = supervisorLockId('default', 'pglite:test');
|
||||
const id = supervisorLockId('default');
|
||||
const first = await tryAcquireDbLock(engine, id, 5);
|
||||
expect(first).not.toBeNull();
|
||||
// A second supervisor (different pidfile, same db+queue) gets null → exit 2.
|
||||
@@ -89,8 +96,8 @@ describe('#1849 DB lock is the real singleton', () => {
|
||||
});
|
||||
|
||||
test('different queues on the same DB do not collide', async () => {
|
||||
const a = await tryAcquireDbLock(engine, supervisorLockId('default', 'pglite:test'), 5);
|
||||
const b = await tryAcquireDbLock(engine, supervisorLockId('shell', 'pglite:test'), 5);
|
||||
const a = await tryAcquireDbLock(engine, supervisorLockId('default'), 5);
|
||||
const b = await tryAcquireDbLock(engine, supervisorLockId('shell'), 5);
|
||||
expect(a).not.toBeNull();
|
||||
expect(b).not.toBeNull();
|
||||
await a!.release();
|
||||
@@ -98,6 +105,48 @@ describe('#1849 DB lock is the real singleton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1849 LOCK_HELD path does not strand the pidfile', () => {
|
||||
test('the pidfile-cleanup exit listener is installed BEFORE the DB-lock acquire', async () => {
|
||||
// Supervisor A already holds the queue lock.
|
||||
const holderA = await tryAcquireDbLock(engine, supervisorLockId('default'), 5);
|
||||
expect(holderA).not.toBeNull();
|
||||
|
||||
const pidFile = join(tmpdir(), `gbrain-sup-stranded-${process.pid}-${Math.random().toString(36).slice(2)}.pid`);
|
||||
const sup = new MinionSupervisor(engine, { cliPath: '/bin/sh', healthInterval: 0, json: true, pidFile });
|
||||
|
||||
// Capture the 'exit' listener start() registers (if any) and stop execution
|
||||
// at the first process.exit (the LOCK_HELD path) the way the real exit would.
|
||||
let exitListener: ((...a: unknown[]) => void) | null = null;
|
||||
const onSpy = spyOn(process, 'on').mockImplementation(((event: string, cb: (...a: unknown[]) => void) => {
|
||||
if (event === 'exit') exitListener = cb;
|
||||
return process;
|
||||
}) as never);
|
||||
const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}) as never);
|
||||
|
||||
try {
|
||||
try { await sup.start(); } catch { /* exit stub throws at LOCK_HELD */ }
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(ExitCodes.LOCK_HELD);
|
||||
// The bug: the exit listener was registered AFTER the DB-lock exit, so
|
||||
// start() threw before reaching it and the pidfile this process created
|
||||
// is stranded. The fix installs it first → it's captured here.
|
||||
expect(exitListener).not.toBeNull();
|
||||
// And it actually cleans up the pidfile we created (contents match our pid).
|
||||
expect(existsSync(pidFile)).toBe(true);
|
||||
exitListener!();
|
||||
expect(existsSync(pidFile)).toBe(false);
|
||||
} finally {
|
||||
onSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
if (existsSync(pidFile)) unlinkSync(pidFile);
|
||||
await holderA!.release();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('#1849 refresh-failure fails safe (F1A)', () => {
|
||||
test('exits LOCK_LOST after the failure threshold; tolerates a single blip', async () => {
|
||||
const sup = new MinionSupervisor(engine, { cliPath: '/bin/sh', healthInterval: 0, json: true });
|
||||
|
||||
Reference in New Issue
Block a user