diff --git a/src/cli.ts b/src/cli.ts index 80a324552..3e14c39f9 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -2315,7 +2315,14 @@ BRAIN (capture / ideate / explore — v0.37/v0.38) SOURCES (multi-repo / multi-brain) sources list Show registered sources sources add --path

Register a source (id = short name, e.g. 'wiki') - sources remove Remove a source + its pages + sources remove Remove a source + its pages (--confirm-destructive) + sources archive Soft-delete: hide from search, recoverable for 72h + sources restore Un-archive a soft-deleted source + sources archived List soft-deleted sources and their purge expiry + sources purge [] Permanently delete archived sources + sources status Per-source dashboard (sync lag, embed coverage) + sources --help Full subcommand list (rename, default, attach, + current, federate, set-cr-mode, webhook, harden, ...) sync --all Sync all sources with a local_path sync --source Sync one specific source repos ... DEPRECATED alias for 'sources' (v0.19.0) diff --git a/src/commands/config.ts b/src/commands/config.ts index d4e0bb1c6..98a58e7bf 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -98,9 +98,25 @@ export async function runConfig(engine: BrainEngine, args: string[]) { const value = args[2]; if (action === 'get' && key) { - const val = await engine.getConfig(key); - if (val !== null) { - console.log(val); + // #2120: `get` used to read only the DB plane, so a runtime-effective key + // in ~/.gbrain/config.json (or env) reported not-found. Resolve the way + // the runtime does — env/file plane wins over DB (loadConfig() already + // overlays env onto the file) — and report which plane answered on + // stderr, keeping stdout a bare value for scripts. + const filePlane = loadConfig() as Record | null; + const fileVal = filePlane?.[key]; + const dbVal = await engine.getConfig(key); + const val = fileVal !== undefined && fileVal !== null ? fileVal : dbVal; + if (val !== null && val !== undefined) { + console.log(typeof val === 'string' ? val : JSON.stringify(val)); + if (fileVal !== undefined && fileVal !== null) { + const shadow = dbVal !== null && dbVal !== undefined + ? ' — a DB-plane value also exists and is shadowed at runtime' + : ''; + console.error(`[config] source: file/env plane (~/.gbrain/config.json or env)${shadow}`); + } else { + console.error(`[config] source: db plane`); + } } else { console.error(`Config key not found: ${key}`); process.exit(1); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4be9e9a2d..8ab668c67 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -5138,13 +5138,7 @@ export async function buildChecks( checks.push({ name: 'multi_source_drift', status: 'warn', - message: - `${result.count} page slug(s) appear at 'default' but NOT at the intended source ` + - `(e.g., ${sampleStr}). Two possible causes: (1) pre-v0.30.3 putPage misroutes; ` + - `(2) source X never completed initial sync and the default page is unrelated. ` + - `Verify with 'gbrain sources status', then either re-sync with ` + - `'gbrain sync --source --full' or 'gbrain delete ' if the default-source ` + - `row is the misroute. (A 'gbrain sources rehome' cleanup command is tracked for v0.32.0.)`, + message: multiSourceDriftAdvice(result.count, sampleStr), }); } else { checks.push({ @@ -8074,3 +8068,24 @@ async function checkSchemaPackSourceDrift(engine: BrainEngine): Promise { }; } } + +/** + * #1123 — multi_source_drift remediation advice. Exported so the regression + * test can pin that it only references CLI surfaces that actually exist + * (the pre-fix text pointed at 'gbrain sources rehome', which was never + * built, and at 'gbrain delete ' without explaining that delete + * targets the ACTIVE source — following it literally on a multi-source + * brain deletes the correctly-routed row). + */ +export function multiSourceDriftAdvice(count: number, sampleStr: string): string { + return ( + `${count} page slug(s) appear at 'default' but NOT at the intended source ` + + `(e.g., ${sampleStr}). Two possible causes: (1) pre-v0.30.3 putPage misroutes; ` + + `(2) the intended source never completed initial sync and the default page is unrelated. ` + + `Verify with 'gbrain sources status', then re-sync with ` + + `'gbrain sync --source --full' (reconciles drift without deleting data). ` + + `If a misrouted default-source row remains after re-sync, remove it with ` + + `'GBRAIN_SOURCE=default gbrain delete ' — delete targets the active source, ` + + `so pin it to 'default' explicitly.` + ); +} diff --git a/src/commands/sources.ts b/src/commands/sources.ts index 77caafff7..acbc4b6e3 100644 --- a/src/commands/sources.ts +++ b/src/commands/sources.ts @@ -502,6 +502,19 @@ async function runArchive(engine: BrainEngine, args: string[]): Promise { const result = await softDeleteSource(engine, id); if (!result) { + // #2792: softDeleteSource returns null both for "not found" (handled by + // the impact check above) and for "already archived" (UPDATE matched no + // `archived = false` row). Distinguish them: already-archived is a + // friendly idempotent no-op, not a reasonless failure. + const rows = await engine.executeRaw<{ archived: boolean }>( + `SELECT archived FROM sources WHERE id = $1`, + [id], + ); + if (rows[0]?.archived) { + console.log(`Source "${id}" is already archived — nothing to do.`); + console.log(` 'gbrain sources archived' shows its purge expiry; 'gbrain sources restore ${id}' un-archives it.`); + return; + } console.error(`Failed to archive source "${id}".`); process.exit(4); } diff --git a/test/cli-help-discoverability.test.ts b/test/cli-help-discoverability.test.ts index 81ec76839..451c5821b 100644 --- a/test/cli-help-discoverability.test.ts +++ b/test/cli-help-discoverability.test.ts @@ -97,3 +97,19 @@ describe('WARN-6 — main `gbrain --help` lists capture/brainstorm/lsd', () => { expect(stdout).toContain('embed'); }); }); + +describe('#1175 — main `gbrain --help` SOURCES block matches the real subcommand set', () => { + test('archive and its lifecycle siblings are listed', () => { + const { stdout, status } = runCli(['--help']); + expect(status).toBe(0); + // Pre-fix the SOURCES block listed only list/add/remove; the soft-delete + // alternative that `sources remove` itself recommends was undiscoverable. + expect(stdout).toMatch(/^\s*sources archive \s/m); + expect(stdout).toMatch(/^\s*sources restore \s/m); + expect(stdout).toMatch(/^\s*sources archived\s/m); + expect(stdout).toMatch(/^\s*sources purge/m); + expect(stdout).toMatch(/^\s*sources status\s/m); + // Pointer at the full per-subcommand help for the long tail. + expect(stdout).toMatch(/^\s*sources --help\s/m); + }); +}); diff --git a/test/config-get-plane.test.ts b/test/config-get-plane.test.ts new file mode 100644 index 000000000..70fe0d241 --- /dev/null +++ b/test/config-get-plane.test.ts @@ -0,0 +1,93 @@ +/** + * #2120 — `gbrain config get` must resolve the file/env plane, not just the + * DB plane. Pre-fix, `get` called only `engine.getConfig(key)`, so a + * runtime-effective key in ~/.gbrain/config.json reported not-found while + * the runtime happily used it. + * + * Hermetic: GBRAIN_HOME points at a tmp dir (configDir() honors it) via + * withEnv (check-test-isolation R1), and the engine is a getConfig-only + * stub — the `get` path touches nothing else. + */ + +import { describe, test, expect, spyOn } from 'bun:test'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runConfig } from '../src/commands/config.ts'; +import { withEnv } from './helpers/with-env.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; + +const home = mkdtempSync(join(tmpdir(), 'gbrain-config-get-')); +mkdirSync(join(home, '.gbrain'), { recursive: true }); + +function stubEngine(dbValues: Record): BrainEngine { + return { + getConfig: async (key: string) => dbValues[key] ?? null, + } as unknown as BrainEngine; +} + +function writeFileConfig(cfg: Record): void { + writeFileSync(join(home, '.gbrain', 'config.json'), JSON.stringify(cfg)); +} + +/** Run `config get ` with GBRAIN_HOME pinned to the tmp brain and the + * chat-model env overlay cleared, capturing output + exit code. */ +async function runGet( + dbValues: Record, + key: string, +): Promise<{ logs: string[]; errs: string[]; exit: number | null }> { + const logs: string[] = []; + const errs: string[] = []; + let exit: number | null = null; + const logSpy = spyOn(console, 'log').mockImplementation((...a: unknown[]) => { logs.push(a.join(' ')); }); + const errSpy = spyOn(console, 'error').mockImplementation((...a: unknown[]) => { errs.push(a.join(' ')); }); + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + exit = code ?? 0; + throw new Error(`EXIT:${code}`); + }) as never); + try { + await withEnv( + { GBRAIN_HOME: home, GBRAIN_CHAT_MODEL: undefined }, + () => runConfig(stubEngine(dbValues), ['get', key]), + ); + } catch (e) { + if (!(e as Error).message.startsWith('EXIT:')) throw e; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + return { logs, errs, exit }; +} + +describe('#2120 — config get resolves file plane with DB fallback', () => { + test('file-plane key with no DB row is found (the pre-fix not-found bug)', async () => { + writeFileConfig({ engine: 'pglite', chat_model: 'anthropic:claude-sonnet-4-6' }); + const { logs, errs, exit } = await runGet({}, 'chat_model'); + expect(exit).toBeNull(); + expect(logs).toContain('anthropic:claude-sonnet-4-6'); + expect(errs.join('\n')).toContain('file/env plane'); + }); + + test('file plane wins over DB plane (matches runtime precedence) and reports the shadow', async () => { + writeFileConfig({ engine: 'pglite', chat_model: 'anthropic:claude-sonnet-4-6' }); + const { logs, errs } = await runGet({ chat_model: 'openai:gpt-5' }, 'chat_model'); + expect(logs).toContain('anthropic:claude-sonnet-4-6'); + expect(logs).not.toContain('openai:gpt-5'); + expect(errs.join('\n')).toContain('shadowed'); + }); + + test('DB-plane-only key still resolves (no regression for dotted keys)', async () => { + writeFileConfig({ engine: 'pglite' }); + const { logs, errs } = await runGet({ 'search.mode': 'balanced' }, 'search.mode'); + expect(logs).toContain('balanced'); + expect(errs.join('\n')).toContain('db plane'); + }); + + test('key in neither plane is still not-found (exit 1)', async () => { + writeFileConfig({ engine: 'pglite' }); + const { errs, exit } = await runGet({}, 'chat_model'); + expect(exit).toBe(1); + expect(errs.join('\n')).toContain('Config key not found: chat_model'); + }); +}); diff --git a/test/doctor-drift-advice.test.ts b/test/doctor-drift-advice.test.ts new file mode 100644 index 000000000..b00397a47 --- /dev/null +++ b/test/doctor-drift-advice.test.ts @@ -0,0 +1,33 @@ +/** + * #1123 — the multi_source_drift doctor recommendation must only reference + * CLI surfaces that actually exist. Pre-fix it pointed at + * 'gbrain sources rehome' (never built) and at 'gbrain delete ' + * without saying that delete targets the ACTIVE source — following it + * literally on a multi-source brain deletes the correctly-routed row. + */ + +import { describe, test, expect } from 'bun:test'; +import { multiSourceDriftAdvice } from '../src/commands/doctor.ts'; + +describe('#1123 — multiSourceDriftAdvice references only real surfaces', () => { + const advice = multiSourceDriftAdvice(45, 'foo (intended=wiki)'); + + test('carries the count and sample', () => { + expect(advice).toContain('45 page slug(s)'); + expect(advice).toContain('foo (intended=wiki)'); + }); + + test('points at the re-sync path that reconciles drift', () => { + expect(advice).toContain("gbrain sources status"); + expect(advice).toContain("gbrain sync --source --full"); + }); + + test('does not reference the never-built rehome command', () => { + expect(advice).not.toContain('rehome'); + }); + + test('delete advice pins the source explicitly instead of implying delete targets default', () => { + expect(advice).toContain('GBRAIN_SOURCE=default gbrain delete '); + expect(advice).not.toContain('delete --source'); + }); +}); diff --git a/test/sources-archive-idempotent.test.ts b/test/sources-archive-idempotent.test.ts new file mode 100644 index 000000000..c39d96ca9 --- /dev/null +++ b/test/sources-archive-idempotent.test.ts @@ -0,0 +1,81 @@ +/** + * #2792 — `gbrain sources archive` idempotency. + * + * Pre-fix, archiving an already-archived source collapsed into the same + * reasonless "Failed to archive" exit-4 path as a DB error, pushing operators + * toward the destructive `sources remove`. Already-archived is now a friendly + * no-op (exit 0); not-found stays a clear exit-4 error. + * + * Runs against PGLite like test/destructive-guard.test.ts (same contract on + * Postgres; PGLite is fast + DATABASE_URL-free). + */ + +import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { runSources } from '../src/commands/sources.ts'; +import { withEnv } from './helpers/with-env.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + // Cold-init schema path so the archive columns exist (same intent as + // destructive-guard tests, but env-isolated per check-test-isolation R1). + await withEnv({ GBRAIN_PGLITE_SNAPSHOT: undefined }, async () => { + await engine.connect({}); + await engine.initSchema(); + }); + await engine.executeRaw( + `INSERT INTO sources (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, + ['arch-idem', 'arch-idem'], + ); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +function captureRun(args: string[]): Promise<{ logs: string[]; errs: string[]; exit: number | null }> { + const logs: string[] = []; + const errs: string[] = []; + const logSpy = spyOn(console, 'log').mockImplementation((...a: unknown[]) => { logs.push(a.join(' ')); }); + const errSpy = spyOn(console, 'error').mockImplementation((...a: unknown[]) => { errs.push(a.join(' ')); }); + let exit: number | null = null; + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + exit = code ?? 0; + throw new Error(`EXIT:${code}`); + }) as never); + return runSources(engine, args) + .catch((e: Error) => { + if (!e.message.startsWith('EXIT:')) throw e; + }) + .then(() => ({ logs, errs, exit })) + .finally(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + }); +} + +describe('#2792 — sources archive is idempotent', () => { + test('first archive succeeds', async () => { + const { exit, logs } = await captureRun(['archive', 'arch-idem']); + expect(exit).toBeNull(); + expect(logs.join('\n')).toContain('arch-idem'); + }); + + test('second archive is a friendly no-op, exit 0, and points at restore/archived', async () => { + const { exit, logs, errs } = await captureRun(['archive', 'arch-idem']); + expect(exit).toBeNull(); // no process.exit — success path + const out = logs.join('\n'); + expect(out).toContain('already archived'); + expect(out).toContain('gbrain sources restore arch-idem'); + expect(errs.join('\n')).not.toContain('Failed to archive'); + }); + + test('unknown source still fails loud with exit 4', async () => { + const { exit, errs } = await captureRun(['archive', 'no-such-source-xyz']); + expect(exit).toBe(4); + expect(errs.join('\n')).toContain('not found'); + }); +});