From 7a275bf0b5c5deb045a419284eb1b4b72df5c4ab Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:31:47 +0800 Subject: [PATCH] fix(takes): scope page lookup by source (#2698) --- src/commands/takes.ts | 50 ++++++++++------ test/takes-command-source-scope.test.ts | 76 +++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 test/takes-command-source-scope.test.ts diff --git a/src/commands/takes.ts b/src/commands/takes.ts index a89513b4e..4e2c30b80 100644 --- a/src/commands/takes.ts +++ b/src/commands/takes.ts @@ -28,6 +28,7 @@ import { type ParsedTake, } from '../core/takes-fence.ts'; import { withPageLock } from '../core/page-lock.ts'; +import { resolveSourceId } from '../core/source-resolver.ts'; // --- Helpers --- @@ -83,18 +84,31 @@ function ensureFloat(raw: string | undefined, fallback: number): number { return n; } -async function getPageId(engine: BrainEngine, slug: string): Promise { - const rows = await engine.executeRaw<{ id: number }>( - `SELECT id FROM pages WHERE slug = $1 LIMIT 1`, - [slug], - ); +async function getPageId(engine: BrainEngine, slug: string, sourceId?: string): Promise { + const rows = sourceId + ? await engine.executeRaw<{ id: number }>( + `SELECT id FROM pages WHERE slug = $1 AND source_id = $2 LIMIT 1`, + [slug, sourceId], + ) + : await engine.executeRaw<{ id: number }>( + `SELECT id FROM pages WHERE slug = $1 LIMIT 1`, + [slug], + ); if (!rows[0]) { - console.error(`Page not found in brain: ${slug}. Run \`gbrain sync\` first.`); + console.error(`Page not found in brain: ${slug}${sourceId ? ` (source=${sourceId})` : ''}. Run \`gbrain sync\` first.`); process.exit(1); } return rows[0].id; } +async function resolveTakesSourceId(engine: BrainEngine): Promise { + try { + return await resolveSourceId(engine, null); + } catch { + return undefined; + } +} + function readBodyOrEmpty(path: string): string { if (!existsSync(path)) return ''; return readFileSync(path, 'utf-8'); @@ -169,7 +183,7 @@ async function cmdSearch(engine: BrainEngine, args: string[]): Promise { } } -async function cmdAdd(engine: BrainEngine, args: string[]): Promise { +async function cmdAdd(engine: BrainEngine, args: string[], sourceId?: string): Promise { const slug = args[0]; if (!slug) { console.error('Usage: gbrain takes add --claim "..." --kind --who [--weight 0.5] [--source "..."] [--since YYYY-MM]'); @@ -195,7 +209,7 @@ async function cmdAdd(engine: BrainEngine, args: string[]): Promise { writeBody(path, nextBody); // Mirror to DB. Page may not be in DB yet if not synced — caller must run sync first. - const pageId = await getPageId(engine, slug); + const pageId = await getPageId(engine, slug, sourceId); await engine.addTakesBatch([{ page_id: pageId, row_num: rowNum, claim, kind, holder, weight, since_date: since, source, active: true, superseded_by: null, @@ -204,7 +218,7 @@ async function cmdAdd(engine: BrainEngine, args: string[]): Promise { }); } -async function cmdUpdate(engine: BrainEngine, args: string[]): Promise { +async function cmdUpdate(engine: BrainEngine, args: string[], sourceId?: string): Promise { const slug = args[0]; const rowNumStr = flagValue(args, '--row'); if (!slug || !rowNumStr) { @@ -223,7 +237,7 @@ async function cmdUpdate(engine: BrainEngine, args: string[]): Promise { const brainDir = await resolveBrainDir(engine, dirArg ?? null); await withPageLock(slug, async () => { - const pageId = await getPageId(engine, slug); + const pageId = await getPageId(engine, slug, sourceId); await engine.updateTake(pageId, rowNum, fields); // Sync the markdown table: read fence, find row, apply field updates, re-render. @@ -254,7 +268,7 @@ async function cmdUpdate(engine: BrainEngine, args: string[]): Promise { }); } -async function cmdSupersede(engine: BrainEngine, args: string[]): Promise { +async function cmdSupersede(engine: BrainEngine, args: string[], sourceId?: string): Promise { const slug = args[0]; const rowNumStr = flagValue(args, '--row'); if (!slug || !rowNumStr) { @@ -268,7 +282,7 @@ async function cmdSupersede(engine: BrainEngine, args: string[]): Promise const brainDir = await resolveBrainDir(engine, dirArg ?? null); await withPageLock(slug, async () => { - const pageId = await getPageId(engine, slug); + 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 }); @@ -302,7 +316,7 @@ async function cmdSupersede(engine: BrainEngine, args: string[]): Promise }); } -async function cmdResolve(engine: BrainEngine, args: string[]): Promise { +async function cmdResolve(engine: BrainEngine, args: string[], sourceId?: string): Promise { const slug = args[0]; const rowNumStr = flagValue(args, '--row'); const qualityStr = flagValue(args, '--quality'); @@ -347,7 +361,7 @@ async function cmdResolve(engine: BrainEngine, args: string[]): Promise { const resolvedBy = flagValue(args, '--by') ?? 'garry'; const dirArg = flagValue(args, '--dir'); - const pageId = await getPageId(engine, slug); + const pageId = await getPageId(engine, slug, sourceId); await engine.resolveTake(pageId, rowNum, { quality, outcome, @@ -564,10 +578,10 @@ Common flags: switch (sub) { case 'search': return cmdSearch(engine, rest); - case 'add': return cmdAdd(engine, rest); - case 'update': return cmdUpdate(engine, rest); - case 'supersede': return cmdSupersede(engine, rest); - case 'resolve': return cmdResolve(engine, rest); + case 'add': return cmdAdd(engine, rest, await resolveTakesSourceId(engine)); + case 'update': return cmdUpdate(engine, rest, await resolveTakesSourceId(engine)); + case 'supersede': return cmdSupersede(engine, rest, await resolveTakesSourceId(engine)); + case 'resolve': return cmdResolve(engine, rest, await resolveTakesSourceId(engine)); case 'scorecard': return cmdScorecard(engine, rest); case 'calibration': return cmdCalibration(engine, rest); case 'revisit': return cmdRevisit(engine, rest); diff --git a/test/takes-command-source-scope.test.ts b/test/takes-command-source-scope.test.ts new file mode 100644 index 000000000..9c88d54f4 --- /dev/null +++ b/test/takes-command-source-scope.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runTakes } from '../src/commands/takes.ts'; +import type { BrainEngine, TakeBatchInput } 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 makeEngine() { + const added: TakeBatchInput[][] = []; + const pageLookups: 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 pages WHERE slug = $1 AND source_id = $2')) { + pageLookups.push(params); + if (params[0] === 'shared/page' && params[1] === 'dept') return [{ id: 22 }]; + if (params[0] === 'shared/page' && params[1] === 'default') return [{ id: 11 }]; + return []; + } + if (sql.includes('FROM pages WHERE slug = $1 LIMIT 1')) { + pageLookups.push(params); + return [{ id: 11 }]; + } + return []; + }, + addTakesBatch: async (rows: TakeBatchInput[]) => { + added.push(rows); + return rows.length; + }, + } as unknown as BrainEngine; + return { engine, added, pageLookups }; +} + +describe('gbrain takes CLI source scoping', () => { + test('add mirrors to the page in GBRAIN_SOURCE, not an arbitrary same-slug page (#2684)', async () => { + const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-takes-source-')); + const home = mkdtempSync(join(tmpdir(), 'gbrain-takes-home-')); + tmpRoots.push(brainDir, home); + const { engine, added, pageLookups } = makeEngine(); + + await withEnv({ GBRAIN_SOURCE: 'dept', GBRAIN_HOME: home }, async () => { + await runTakes(engine, [ + 'add', + 'shared/page', + '--claim', + 'Dept-scoped claim', + '--kind', + 'take', + '--who', + 'self', + '--dir', + brainDir, + ]); + }); + + expect(pageLookups).toEqual([['shared/page', 'dept']]); + expect(added).toHaveLength(1); + expect(added[0]![0]!.page_id).toBe(22); + + const written = join(brainDir, 'shared/page.md'); + expect(existsSync(written)).toBe(true); + expect(readFileSync(written, 'utf-8')).toContain('Dept-scoped claim'); + }); +});