fix(takes): scope page lookup by source (#2698)

This commit is contained in:
Ziyang Guo
2026-07-16 16:31:47 -07:00
committed by GitHub
parent 285cf39f9a
commit 7a275bf0b5
2 changed files with 108 additions and 18 deletions
+32 -18
View File
@@ -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<number> {
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<number> {
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<string | undefined> {
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<void> {
}
}
async function cmdAdd(engine: BrainEngine, args: string[]): Promise<void> {
async function cmdAdd(engine: BrainEngine, args: string[], sourceId?: string): Promise<void> {
const slug = args[0];
if (!slug) {
console.error('Usage: gbrain takes add <slug> --claim "..." --kind <k> --who <h> [--weight 0.5] [--source "..."] [--since YYYY-MM]');
@@ -195,7 +209,7 @@ async function cmdAdd(engine: BrainEngine, args: string[]): Promise<void> {
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<void> {
});
}
async function cmdUpdate(engine: BrainEngine, args: string[]): Promise<void> {
async function cmdUpdate(engine: BrainEngine, args: string[], sourceId?: string): Promise<void> {
const slug = args[0];
const rowNumStr = flagValue(args, '--row');
if (!slug || !rowNumStr) {
@@ -223,7 +237,7 @@ async function cmdUpdate(engine: BrainEngine, args: string[]): Promise<void> {
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<void> {
});
}
async function cmdSupersede(engine: BrainEngine, args: string[]): Promise<void> {
async function cmdSupersede(engine: BrainEngine, args: string[], sourceId?: string): Promise<void> {
const slug = args[0];
const rowNumStr = flagValue(args, '--row');
if (!slug || !rowNumStr) {
@@ -268,7 +282,7 @@ async function cmdSupersede(engine: BrainEngine, args: string[]): Promise<void>
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<void>
});
}
async function cmdResolve(engine: BrainEngine, args: string[]): Promise<void> {
async function cmdResolve(engine: BrainEngine, args: string[], sourceId?: string): Promise<void> {
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<void> {
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);
+76
View File
@@ -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');
});
});