Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 8480c69cc5 fix(takes): supersede targets active rows, reserved list subcommand, holder allow-list on serve --http, derive-phase privacy excludes
Four verified backlog fixes in the takes lifecycle / gateway component:

- #2078: `takes supersede` looked up its target with active:false, so
  superseding any ACTIVE take failed "Row #N not found". The lookup now
  targets active rows; an already-superseded row gets a clear error.
- #2079: `takes list` is now a reserved subcommand — bare `takes list`
  lists all active takes brain-wide (CLI parity with the takes_list op),
  `takes list <slug>` scopes to a page. Previously "list" was parsed as
  a page slug and printed "No takes on list."
- #2529: serve --http ignored permissions.takes_holders for legacy
  bearer tokens — GBrainOAuthProvider.verifyAccessToken never read the
  grant, so the transport's ['world'] fallback always won and
  `gbrain auth set-takes-holders` was a silent no-op over OAuth HTTP.
  The legacy branch now threads takesHoldersAllowList (fail-closed
  ['world'] default), mirroring src/mcp/http-transport.ts.
- #2780: derive phases (chronicle events, facts, atoms, takes) now skip
  pages under GBRAIN_SEARCH_EXCLUDE prefixes via the shared
  resolveDeriveExcludes/isHardExcludedSlug helpers — previously they
  re-materialized excluded-prefix content as searchable rows/pages
  outside the excluded prefix. Deliberately scoped to the env-configured
  privacy prefixes; the DEFAULT_HARD_EXCLUDES noise prefixes (test/,
  attachments/, .raw/) stay derivable (pinned by existing suites).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:01:03 -07:00
12 changed files with 386 additions and 11 deletions
+20 -11
View File
@@ -128,11 +128,9 @@ function writeBody(path: string, body: string): void {
// --- Subcommands ---
async function cmdList(engine: BrainEngine, args: string[]): Promise<void> {
const slug = args[0];
if (!slug) {
console.error('Usage: gbrain takes <slug> [--json]');
process.exit(1);
}
// #2079: `gbrain takes list` (no slug) lists all active takes brain-wide —
// CLI parity with the takes_list op. A leading flag also means "no slug".
const slug = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
const json = flagPresent(args, '--json');
const holder = flagValue(args, '--who');
const kind = flagValue(args, '--kind') as string | undefined;
@@ -153,16 +151,17 @@ async function cmdList(engine: BrainEngine, args: string[]): Promise<void> {
}
if (takes.length === 0) {
console.log(`No takes on ${slug}.`);
console.log(slug ? `No takes on ${slug}.` : 'No takes in brain.');
return;
}
console.log(`# Takes on ${slug}\n`);
console.log(slug ? `# Takes on ${slug}\n` : '# All takes\n');
for (const t of takes) {
const tag = t.active ? '' : ' [superseded]';
const w = Number(t.weight).toFixed(2);
const since = t.since_date ?? '';
const src = t.source ? `${t.source}` : '';
console.log(`#${t.row_num} [${t.kind}${t.holder} • w=${w}${since ? `${since}` : ''}]${tag}\n ${t.claim}${src}\n`);
const ref = slug ? `#${t.row_num}` : `${t.page_slug}#${t.row_num}`;
console.log(`${ref} [${t.kind}${t.holder} • w=${w}${since ? `${since}` : ''}]${tag}\n ${t.claim}${src}\n`);
}
}
@@ -290,11 +289,17 @@ async function cmdSupersede(engine: BrainEngine, args: string[], sourceId?: stri
await withPageLock(slug, async () => {
const pageId = await getPageId(engine, slug, sourceId);
// Read existing row to inherit kind/holder unless overridden
const existing = await engine.listTakes({ page_id: pageId, active: false, limit: 500 });
// Read existing row to inherit kind/holder unless overridden.
// #2078: the supersede target is an ACTIVE take — active:false could
// only ever find already-superseded rows, so every real supersede failed.
const existing = await engine.listTakes({ page_id: pageId, active: true, limit: 500 });
const target = existing.find(t => t.row_num === rowNum);
if (!target) {
console.error(`Row #${rowNum} not found on ${slug}.`);
const superseded = await engine.listTakes({ page_id: pageId, active: false, limit: 500 });
const stale = superseded.find(t => t.row_num === rowNum);
console.error(stale
? `Row #${rowNum} on ${slug} is already superseded.`
: `Row #${rowNum} not found on ${slug}.`);
process.exit(1);
}
const kind = ensureKind(flagValue(args, '--kind') ?? target.kind);
@@ -554,6 +559,7 @@ export async function runTakes(engine: BrainEngine, args: string[]): Promise<voi
Subcommands:
takes <slug> [--json] [--who h] [--kind k] [--sort weight|since_date|created_at] [--expired]
List takes for a page
takes list [<slug>] [same flags] List all active takes (or one page's)
takes search "<query>" [--limit N] [--json]
Keyword search across all takes
takes add <slug> --claim "..." --kind <fact|take|bet|hunch> --who <holder>
@@ -583,6 +589,9 @@ Common flags:
const rest = args.slice(1);
switch (sub) {
// #2079: reserved word — never a page slug. Bare `takes list` lists all
// active takes; `takes list <slug>` behaves like `takes <slug>`.
case 'list': return cmdList(engine, rest);
case 'search': return cmdSearch(engine, rest);
case 'add': return cmdAdd(engine, rest, await resolveTakesSourceId(engine));
case 'update': return cmdUpdate(engine, rest, await resolveTakesSourceId(engine));
+7
View File
@@ -10,6 +10,7 @@
// configured it returns zero events (auto-emit is a no-op, never an error).
import type { BrainEngine } from '../engine.ts';
import { computeContentHash } from '../ingestion/types.ts';
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
export interface ChronicleEventProposal {
when: string; // ISO datetime or YYYY-MM-DD
@@ -112,6 +113,12 @@ export async function runChronicleExtract(
): Promise<ChronicleExtractResult> {
const sourceId = opts.sourceId ?? 'default';
const tz = opts.tz ?? 'UTC';
// #2780 (privacy): never derive events from excluded-prefix pages — the
// event page lands under life/events/ (outside the exclusion) and quotes
// the excluded content in searchable form.
if (isHardExcludedSlug(opts.slug, resolveDeriveExcludes())) {
return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'excluded_prefix' };
}
const page = await engine.getPage(opts.slug, { sourceId });
if (!page) return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'page_not_found' };
+5
View File
@@ -56,6 +56,7 @@ import { writeReceipt } from '../extract/receipt-writer.ts';
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
import { createHash } from 'crypto';
import { slugifySegment } from '../sync.ts';
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
const DEFAULT_BUDGET_USD = 0.3;
@@ -461,7 +462,11 @@ export async function runPhaseExtractAtoms(
seenHashes.add(t.contentHash);
work.push({ kind: 'transcript', ...t });
}
// #2780 (privacy): never derive atoms from excluded-prefix pages — the
// atom lands under atoms/ (outside the exclusion) and becomes searchable.
const hardExcludes = resolveDeriveExcludes();
for (const p of pages) {
if (isHardExcludedSlug(p.slug, hardExcludes)) continue;
if (seenHashes.has(p.contentHash)) { duplicatesSkipped++; continue; }
seenHashes.add(p.contentHash);
work.push({ kind: 'page', ...p });
+5
View File
@@ -49,6 +49,7 @@ import {
} from './phantom-redirect.ts';
import { embed, isAvailable } from '../ai/gateway.ts';
import { isAborted } from '../abort-check.ts';
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
interface ExistingPageFact {
fact: string;
@@ -247,11 +248,15 @@ export async function runExtractFacts(
}
// ── Reconcile each page ───────────────────────────────────────
// #2780 (privacy): never derive from excluded-prefix pages — the facts
// rows would be searchable outside the exclusion.
const hardExcludes = resolveDeriveExcludes();
for (const slug of slugs) {
// #1972: bail at the top of the per-page loop on abort. Each page is an
// independent delete-then-insert commit, so breaking leaves a consistent
// partial state; the receipt/rollup below still runs with partial counts.
if (isAborted(opts.signal)) break;
if (isHardExcludedSlug(slug, hardExcludes)) continue;
result.pagesScanned += 1;
const page = await engine.getPage(slug, { sourceId });
+7
View File
@@ -24,6 +24,7 @@ import { join, relative, sep } from 'node:path';
import type { BrainEngine, TakeBatchInput } from '../engine.ts';
import { parseTakesFence, type ParsedTake } from '../takes-fence.ts';
import { walkMarkdownFiles } from '../../commands/extract.ts';
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
export interface ExtractTakesOpts {
/** Brain repo root. Required for source='fs'. */
@@ -127,10 +128,13 @@ export async function extractTakesFromFs(
const files = walkMarkdownFiles(opts.repoPath);
const buffer: TakeBatchInput[] = [];
// #2780 (privacy): never derive takes from excluded-prefix pages.
const hardExcludes = resolveDeriveExcludes();
for (const { path, relPath } of files) {
const slug = relPath.replace(/\.md$/, '').split(sep).join('/');
if (slugFilter && !slugFilter.has(slug)) continue;
if (isHardExcludedSlug(slug, hardExcludes)) continue;
result.pagesScanned++;
let body: string;
@@ -198,8 +202,11 @@ export async function extractTakesFromDb(
? opts.slugs.map(slug => ({ slug, source_id: 'default' }))
: await engine.listAllPageRefs();
const buffer: TakeBatchInput[] = [];
// #2780 (privacy): never derive takes from excluded-prefix pages.
const hardExcludes = resolveDeriveExcludes();
for (const { slug, source_id } of refs) {
if (isHardExcludedSlug(slug, hardExcludes)) continue;
result.pagesScanned++;
const page = await engine.getPage(slug, { sourceId: source_id });
if (!page) continue;
+10
View File
@@ -721,6 +721,15 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
? (permissions as Record<string, unknown>).source_id
: undefined;
const { sourceId, allowedSources } = parseLegacyTokenScope(sourceGrant);
// #2529: honor permissions.takes_holders on the OAuth transport,
// mirroring src/mcp/http-transport.ts. Fail-closed default ['world']
// — a token with no grant sees public claims only.
const takesHoldersRaw = permissions && typeof permissions === 'object'
? (permissions as Record<string, unknown>).takes_holders
: undefined;
const takesHoldersAllowList = Array.isArray(takesHoldersRaw)
? takesHoldersRaw.filter((h): h is string => typeof h === 'string')
: ['world'];
return {
token,
clientId: name,
@@ -732,6 +741,7 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
// allowedSources for federated reads, matching legacy HTTP transport.
sourceId,
allowedSources,
takesHoldersAllowList,
} as CoreAuthInfo as SdkAuthInfo;
}
+9
View File
@@ -305,6 +305,15 @@ export interface AuthInfo {
* case (back-compat).
*/
allowedSources?: string[];
/**
* #2529: per-token takes-holder allow-list from
* `access_tokens.permissions.takes_holders`, resolved at
* token-verification time so `serve --http` bearer tokens honor
* `gbrain auth set-takes-holders`. Legacy tokens without a grant get
* `['world']` (fail-closed); OAuth clients leave it undefined and the
* transport defaults to `['world']`.
*/
takesHoldersAllowList?: string[];
}
export interface OperationContext {
+27
View File
@@ -132,3 +132,30 @@ export function resolveHardExcludes(
}
return Array.from(union);
}
/**
* #2780 (privacy): true when a slug sits under any hard-excluded prefix
* (defaults GBRAIN_SEARCH_EXCLUDE). Derive phases (chronicle events,
* facts, atoms, takes) MUST skip such source pages — otherwise excluded
* content gets re-materialized as searchable rows/pages OUTSIDE the
* excluded prefix, silently defeating the exclusion.
*/
export function isHardExcludedSlug(
slug: string,
prefixes: string[] = resolveHardExcludes(),
): boolean {
return prefixes.some(p => slug.startsWith(p));
}
/**
* #2780: prefixes derive phases must never read from. Deliberately the
* env-configured (privacy) excludes ONLY — the DEFAULT_HARD_EXCLUDES
* noise prefixes (test/, attachments/, .raw/) stay derivable: they are a
* search-ranking concern, not a privacy boundary, and extraction from
* test/ pages is pinned by existing suites.
*/
export function resolveDeriveExcludes(
envValue: string | undefined = process.env.GBRAIN_SEARCH_EXCLUDE,
): string[] {
return parseHardExcludesEnv(envValue);
}
+96
View File
@@ -0,0 +1,96 @@
// #2780 (privacy) — derive phases (chronicle events, facts, takes) must
// skip pages under hard-excluded prefixes (defaults GBRAIN_SEARCH_EXCLUDE).
// Pre-fix, excluded-prefix content was re-materialized as searchable
// rows/pages outside the excluded prefix, silently defeating the exclusion.
//
// Hermetic: mock engines, env via withEnv. The atoms-phase gate is pinned
// in test/extract-atoms-page-discovery.test.ts (PGLite fixture lives there).
import { describe, expect, test } from 'bun:test';
import { isHardExcludedSlug, resolveDeriveExcludes } from '../src/core/search/source-boost.ts';
import { runExtractFacts } from '../src/core/cycle/extract-facts.ts';
import { extractTakesFromDb } from '../src/core/cycle/extract-takes.ts';
import { runChronicleExtract } from '../src/core/chronicle/extract-events.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { withEnv } from './helpers/with-env.ts';
describe('isHardExcludedSlug', () => {
test('matches env prefixes and defaults, prefix-anchored', () => {
expect(isHardExcludedSlug('private/journal', ['private/'])).toBe(true);
expect(isHardExcludedSlug('people/private', ['private/'])).toBe(false);
// Defaults apply when no explicit list is passed.
expect(isHardExcludedSlug('attachments/x')).toBe(true);
expect(isHardExcludedSlug('people/alice-example')).toBe(false);
});
test('reads GBRAIN_SEARCH_EXCLUDE by default', async () => {
await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'journal/' }, () => {
expect(isHardExcludedSlug('journal/2026-01-01')).toBe(true);
});
await withEnv({ GBRAIN_SEARCH_EXCLUDE: undefined }, () => {
expect(isHardExcludedSlug('journal/2026-01-01')).toBe(false);
});
});
});
describe('resolveDeriveExcludes', () => {
test('env privacy prefixes only — NOT the search-noise defaults', async () => {
await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/,journal/' }, () => {
expect(resolveDeriveExcludes()).toEqual(['private/', 'journal/']);
});
// test/ etc. stay derivable (pinned by the extract-takes holder suites).
await withEnv({ GBRAIN_SEARCH_EXCLUDE: undefined }, () => {
expect(resolveDeriveExcludes()).toEqual([]);
});
});
});
describe('extract_facts skips excluded-prefix pages (#2780)', () => {
test('excluded slug never reaches getPage', async () => {
const fetched: string[] = [];
const engine = {
executeRaw: async (sql: string) => {
if (sql.includes('row_num IS NULL')) return [{ n: '0' }];
return [];
},
getPage: async (slug: string) => { fetched.push(slug); return null; },
} as unknown as BrainEngine;
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
runExtractFacts(engine, { slugs: ['private/journal', 'people/alice-example'], dryRun: true }),
);
expect(fetched).toEqual(['people/alice-example']);
expect(result.pagesScanned).toBe(1);
});
});
describe('extract_takes skips excluded-prefix pages (#2780)', () => {
test('db path: excluded slug never reaches getPage', async () => {
const fetched: string[] = [];
const engine = {
getPage: async (slug: string) => { fetched.push(slug); return null; },
} as unknown as BrainEngine;
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
extractTakesFromDb(engine, { slugs: ['private/journal', 'people/alice-example'], dryRun: true }),
);
expect(fetched).toEqual(['people/alice-example']);
expect(result.pagesScanned).toBe(1);
});
});
describe('chronicle extract skips excluded-prefix pages (#2780)', () => {
test('excluded slug returns skipped/excluded_prefix without reading the page', async () => {
let getPageCalled = false;
const engine = {
getPage: async () => { getPageCalled = true; return null; },
} as unknown as BrainEngine;
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
runChronicleExtract(engine, { slug: 'private/journal' }),
);
expect(result.status).toBe('skipped');
expect(result.reason).toBe('excluded_prefix');
expect(getPageCalled).toBe(false);
});
});
+20
View File
@@ -19,6 +19,7 @@ import {
discoverExtractablePages,
} from '../src/core/cycle/extract-atoms.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import type { ChatOpts, ChatResult } from '../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
@@ -254,6 +255,25 @@ describe('v0.41.2.1: runPhaseExtractAtoms — dual-source merge + idempotency',
expect(result.details?.pages_processed).toBe(1);
});
test('#2780: excluded-prefix pages are never atomized (privacy)', async () => {
const chat = stubChatUnique();
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
runPhaseExtractAtoms(engine, {
_transcripts: [],
_pages: [
{ slug: 'private/journal', content: 'secret content', contentHash: 'privhash123456789a' },
{ slug: 'meeting/pub', content: 'public content', contentHash: 'pubhash1234567890b' },
],
_chat: chat,
}));
expect(result.details?.pages_processed).toBe(1);
const rows = await engine.executeRaw<{ frontmatter: Record<string, unknown> }>(
`SELECT frontmatter FROM pages WHERE type = 'atom'`,
);
expect(rows.length).toBe(1);
expect(rows[0].frontmatter.source_slug).toBe('meeting/pub');
});
test('atom frontmatter: page-origin uses source_slug, transcript-origin uses source_path', async () => {
// Use stubChatUnique so the two work-items write to distinct slugs;
// a constant title would upsert into one slug and mask one origin.
+39
View File
@@ -363,6 +363,45 @@ describe('verifyAccessToken', () => {
expect(authInfo.sourceId).toBe('default');
expect(authInfo.allowedSources).toEqual(['default', 'src-a', 'src-b']);
});
test('legacy access_tokens fallback honors permissions.takes_holders (#2529)', async () => {
await sql`
ALTER TABLE access_tokens
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
`;
const legacyToken = generateToken('gbrain_');
const hash = hashToken(legacyToken);
await sql`
INSERT INTO access_tokens (id, name, token_hash, permissions)
VALUES (
${crypto.randomUUID()},
${'legacy-takes-agent'},
${hash},
${JSON.stringify({ takes_holders: ['self', 'world'] })}::jsonb
)
`;
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toEqual(['self', 'world']);
});
test('legacy access_tokens without a takes_holders grant default to world (#2529)', async () => {
await sql`
ALTER TABLE access_tokens
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
`;
const legacyToken = generateToken('gbrain_');
const hash = hashToken(legacyToken);
await sql`
INSERT INTO access_tokens (id, name, token_hash, permissions)
VALUES (${crypto.randomUUID()}, ${'legacy-nogrant-agent'}, ${hash}, ${'{}'}::jsonb)
`;
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
expect(authInfo.takesHoldersAllowList).toEqual(['world']);
});
});
// ---------------------------------------------------------------------------
+141
View File
@@ -0,0 +1,141 @@
// #2078 + #2079 — `gbrain takes` CLI regressions:
// #2078: cmdSupersede looked up its target with active:false, so
// superseding any ACTIVE take failed "Row #N not found".
// #2079: `gbrain takes list` parsed "list" as a page slug and printed
// "No takes on list." — now a reserved subcommand.
//
// Hermetic: mock engine, no DB. Env-mutating (GBRAIN_HOME) via withEnv.
import { afterEach, describe, expect, test, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runTakes } from '../src/commands/takes.ts';
import type { BrainEngine, Take, TakesListOpts } from '../src/core/engine.ts';
import { withEnv } from './helpers/with-env.ts';
const tmpRoots: string[] = [];
afterEach(() => {
for (const root of tmpRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function fakeTake(overrides: Partial<Take> = {}): Take {
return {
id: 1, page_id: 11, page_slug: 'ideas/widget-co', row_num: 1,
claim: 'widget-co will reach PMF', kind: 'bet', holder: 'self',
weight: 0.6, since_date: null, until_date: null, source: null,
superseded_by: null, active: true, resolved_at: null,
resolved_outcome: null, resolved_quality: null, resolved_value: null,
resolved_unit: null, resolved_source: null, resolved_by: null,
} as Take;
}
function makeEngine() {
const listCalls: TakesListOpts[] = [];
const supersedeCalls: unknown[][] = [];
const engine = {
getConfig: async () => null,
executeRaw: async (sql: string, params: unknown[] = []) => {
if (sql.includes('FROM sources WHERE id = $1')) return [{ id: params[0] as string }];
if (sql.includes('FROM sources')) return [];
if (sql.includes('FROM pages WHERE slug = $1 AND source_id = $2')) return [{ id: 11 }];
if (sql.includes('FROM pages WHERE slug = $1 LIMIT 1')) return [{ id: 11 }];
return [];
},
listTakes: async (opts: TakesListOpts = {}) => {
listCalls.push(opts);
// Only the ACTIVE row #1 exists (the #2078 scenario).
return opts.active === true ? [fakeTake()] : [];
},
supersedeTake: async (...args: unknown[]) => {
supersedeCalls.push(args);
return { oldRow: 1, newRow: 2 };
},
} as unknown as BrainEngine;
return { engine, listCalls, supersedeCalls };
}
describe('takes supersede targets ACTIVE rows (#2078)', () => {
test('superseding an active take succeeds instead of "Row #N not found"', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup-'));
const home = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup-home-'));
tmpRoots.push(brainDir, home);
const { engine, listCalls, supersedeCalls } = makeEngine();
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
try {
await withEnv({ GBRAIN_HOME: home, GBRAIN_SOURCE: undefined }, async () => {
await runTakes(engine, [
'supersede', 'ideas/widget-co', '--row', '1',
'--claim', 'widget-co pivoted', '--dir', brainDir,
]);
});
// Target lookup must ask for ACTIVE takes.
expect(listCalls[0]?.active).toBe(true);
expect(supersedeCalls.length).toBe(1);
expect(supersedeCalls[0]?.[0]).toBe(11); // pageId
expect(supersedeCalls[0]?.[1]).toBe(1); // rowNum
} finally {
exitSpy.mockRestore();
}
});
test('already-superseded row yields a clear error, not "not found"', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup2-'));
const home = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup2-home-'));
tmpRoots.push(brainDir, home);
const listCalls: TakesListOpts[] = [];
const engine = {
getConfig: async () => null,
executeRaw: async (sql: string, params: unknown[] = []) => {
if (sql.includes('FROM sources WHERE id = $1')) return [{ id: params[0] as string }];
if (sql.includes('FROM sources')) return [];
if (sql.includes('FROM pages WHERE slug = $1')) return [{ id: 11 }];
return [];
},
listTakes: async (opts: TakesListOpts = {}) => {
listCalls.push(opts);
// Row #1 exists only in the superseded set.
return opts.active === false ? [fakeTake({ active: false })] : [];
},
supersedeTake: async () => { throw new Error('must not be called'); },
} as unknown as BrainEngine;
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const errSpy = spyOn(console, 'error');
try {
await expect(withEnv({ GBRAIN_HOME: home, GBRAIN_SOURCE: undefined }, async () => {
await runTakes(engine, [
'supersede', 'ideas/widget-co', '--row', '1',
'--claim', 'newer claim', '--dir', brainDir,
]);
})).rejects.toThrow('EXIT');
const messages = errSpy.mock.calls.map(c => String(c[0]));
expect(messages.some(m => m.includes('already superseded'))).toBe(true);
} finally {
errSpy.mockRestore();
exitSpy.mockRestore();
}
});
});
describe('takes list is a reserved subcommand (#2079)', () => {
test('`takes list` lists brain-wide, not a page named "list"', async () => {
const { engine, listCalls } = makeEngine();
await runTakes(engine, ['list', '--json']);
expect(listCalls.length).toBe(1);
expect(listCalls[0]?.page_slug).toBeUndefined();
expect(listCalls[0]?.active).toBe(true);
});
test('`takes list <slug>` scopes to the page', async () => {
const { engine, listCalls } = makeEngine();
await runTakes(engine, ['list', 'ideas/widget-co', '--json']);
expect(listCalls[0]?.page_slug).toBe('ideas/widget-co');
});
});