mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.41.38.0 fix: code-callers/callees honor .gbrain-source pin + gbrain dream runs on postgres engines (#1666)
* fix(code-callers/callees): honor .gbrain-source pin via full source-resolution chain code-callers and code-callees called resolveDefaultSource directly, which only knew "1 source -> use it, else multiple_sources_ambiguous" and ignored the .gbrain-source pin (and env / local_path / brain_default / sole_non_default tiers). On a multi-source brain they errored even when a pin clearly selected one source, while code-def/code-refs "worked" only because they never scope by source at all. New shared helper resolveScopedSourceOrThrow(engine, cwd) in sources-ops.ts runs the full resolveSourceWithTier chain and applies the ambiguity guard ONLY when nothing matched (tier seed_default). Both commands route through it; an explicit --source/--all-sources still overrides. Adds source_id + scope to the JSON envelope, a stderr nudge on the sole_non_default tier (matches sync/import), a zero-result "try --all-sources" hint, and clean exit-2 handling for a bad pin. Tests: test/code-scoped-source-resolve.test.ts (8 helper cases incl. dotfile pin, env/brain_default/sole_non_default tiers, ambiguity, bad pin) and test/code-callers-pin.serial.test.ts (9 CLI-wiring cases via process.chdir). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(dream): run against postgres engines (skip filesystem-only phases when no checkout) gbrain dream hard-failed with "No brain directory found" on a postgres/Supabase brain with no local checkout, so the DB-only maintenance phases (notably resolve_symbol_edges, the call-graph builder) could never run. doctor even recommended `gbrain dream --source <id>`, a command that couldn't run. - dream.ts: resolveBrainDir returns string|null (order: --dir -> resolved source's local_path -> sync.repo_path -> null); runDream owns the both-null (no checkout AND no engine) exit 1. - cycle.ts: CycleOpts.brainDir is string|null; resolveSourceForDir null-tolerant; the 6 filesystem phases (lint/backlinks/sync/synthesize/extract/patterns) skip with reason 'no_brain_dir' when there's no checkout; DB phases run. cycleSourceId = opts.sourceId ?? resolveSourceForDir(...) scopes the per-source DB phases (extract_facts/extract_atoms/calibration) correctly even on a checkout-less brain (previously they scoped to 'default' while the cycle stamped the requested source fresh — a freshness stamp that lied). deriveStatus counts resolved/ambiguous edges as work so an edges-only cycle reports 'ok'. - jobs.ts: the autopilot-cycle handler passes null (not cwd '.') when no repo is configured, so the queued cycle follows the same no_brain_dir contract. Tests: test/dream-postgres.test.ts (8: null-brainDir path, --source scope regression, edges->ok, both-null exit) + test/jobs-autopilot-cycle-braindir.test.ts (1: handler passes null -> FS phases skip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.38.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(review): scope dream brainDir to --source; null fallback for phase handlers; quarantine heavy PGLite tests Addresses pre-landing review findings (codex P1/P2): - dream.ts resolveBrainDir: when --source resolves but that source has no on-disk checkout, return null (DB-only) instead of falling through to the global sync.repo_path. That global path belongs to the default/unscoped brain; running FS phases against it while DB phases + the last_full_cycle_at stamp target the requested source mixed scopes (codex P1). Adds a regression test (--source repo-a + a configured global sync.repo_path → brain_dir null). - jobs.ts makePhaseHandler: fall back to null (not cwd '.') when no repo is configured, matching the autopilot-cycle handler + gbrain dream. A direct phase job (synthesize/patterns) on a checkout-less brain now skips FS phases as no_brain_dir instead of running against the worker cwd (codex P2). - Move dream-postgres + jobs-autopilot-cycle-braindir tests to *.serial.test.ts (they run full runCycle passes; the serial pass avoids parallel-shard PGLite cold-start contention). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): record v0.41.38.0 dream-postgres / source-pin follow-ups Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update CLAUDE.md Key Files for v0.41.38.0 (dream-postgres + source pin) - dream.ts entry: resolveBrainDir returns string|null; checkout-less postgres runs DB phases + skips FS phases (no_brain_dir); --source-with-no-checkout doesn't borrow a different source's global repo. - cycle.ts entry: CycleOpts.brainDir nullable; cycleSourceId per-source scope; deriveStatus counts edges; jobs.ts handlers pass null not '.'. - Regenerated llms-full.txt (bun run build:llms). 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
6f26d5e4df
commit
248fb7a90f
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* v0.41.30.0 — code-callers / code-callees end-to-end source resolution (BUG 1).
|
||||
*
|
||||
* Serial (`*.serial.test.ts`) because it process.chdir()s into a temp dir
|
||||
* holding a .gbrain-source pin — process.cwd() is process-global and races
|
||||
* with parallel files. Drives the real runCodeCallers / runCodeCallees through
|
||||
* their process.cwd()-based resolution, asserting:
|
||||
* - a .gbrain-source pin resolves on a multi-source brain (no exit 2)
|
||||
* - no pin + no flag + multi-source still errors (exit 2)
|
||||
* - explicit --source overrides
|
||||
* - A4: JSON envelope carries source_id + scope; --all-sources → null/'all'
|
||||
* - A4: sole_non_default tier emits the stderr nudge
|
||||
* - A5: zero-result implicit scope appends the "try --all-sources" hint
|
||||
*
|
||||
* PGLite in-memory, no DATABASE_URL.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import { runCodeCallers } from '../src/commands/code-callers.ts';
|
||||
import { runCodeCallees } from '../src/commands/code-callees.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let origCwd: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
origCwd = process.cwd();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(origCwd);
|
||||
});
|
||||
|
||||
async function addSource(id: string, localPath: string | null): Promise<void> {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, created_at)
|
||||
VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
|
||||
[id, localPath],
|
||||
);
|
||||
}
|
||||
|
||||
function pinnedDir(sourceId: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-pin-cli-'));
|
||||
writeFileSync(join(dir, '.gbrain-source'), `${sourceId}\n`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Run fn with process.exit + console.log/error spied. Returns captured output
|
||||
* + the exit code if process.exit was called (spied to throw an EXIT sentinel). */
|
||||
async function capture(fn: () => Promise<void>): Promise<{ logs: string[]; errs: string[]; exitCode: number | null }> {
|
||||
const logs: string[] = [];
|
||||
const errs: string[] = [];
|
||||
let exitCode: number | null = null;
|
||||
const logSpy = spyOn(console, 'log').mockImplementation((m?: unknown) => { logs.push(String(m)); });
|
||||
const errSpy = spyOn(console, 'error').mockImplementation((m?: unknown) => { errs.push(String(m)); });
|
||||
const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error('EXIT');
|
||||
}) as never);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error) || e.message !== 'EXIT') throw e;
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
return { logs, errs, exitCode };
|
||||
}
|
||||
|
||||
describe('code-callers / code-callees — .gbrain-source pin (CLI wiring)', () => {
|
||||
test('pin resolves on a multi-source brain: no exit 2, output names the pinned source', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = pinnedDir('repo-a');
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const callers = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--no-json'])));
|
||||
expect(callers.exitCode).toBeNull(); // resolved, did NOT error
|
||||
expect(callers.logs.join('\n')).toContain("repo-a");
|
||||
|
||||
const callees = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallees(engine, ['someSym', '--no-json'])));
|
||||
expect(callees.exitCode).toBeNull();
|
||||
expect(callees.logs.join('\n')).toContain("repo-a");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no pin + no flag + multi-source → exit 2 (ambiguous preserved)', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-nopin-cli-'));
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const callers = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--no-json'])));
|
||||
expect(callers.exitCode).toBe(2);
|
||||
expect(callers.errs.join('\n')).toContain('--source');
|
||||
|
||||
const callees = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallees(engine, ['someSym', '--no-json'])));
|
||||
expect(callees.exitCode).toBe(2);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit --source overrides (even with a conflicting pin)', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = pinnedDir('repo-a');
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { logs, exitCode } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--source', 'repo-b', '--json'])));
|
||||
expect(exitCode).toBeNull();
|
||||
const env = JSON.parse(logs.join('\n'));
|
||||
expect(env.source_id).toBe('repo-b');
|
||||
expect(env.scope).toBe('single');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('A4: JSON envelope carries source_id + scope (resolved pin)', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = pinnedDir('repo-a');
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { logs } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--json'])));
|
||||
const env = JSON.parse(logs.join('\n'));
|
||||
expect(env.source_id).toBe('repo-a');
|
||||
expect(env.scope).toBe('single');
|
||||
expect(env.symbol).toBe('someSym');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('A4: --all-sources → source_id null, scope "all"', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-all-cli-'));
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { logs, exitCode } = await capture(() =>
|
||||
runCodeCallers(engine, ['someSym', '--all-sources', '--json']));
|
||||
expect(exitCode).toBeNull();
|
||||
const env = JSON.parse(logs.join('\n'));
|
||||
expect(env.source_id).toBeNull();
|
||||
expect(env.scope).toBe('all');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('A4: sole_non_default tier emits the stderr nudge', async () => {
|
||||
await addSource('repo-a', '/fake/a'); // default + one non-default w/ local_path
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-sole-cli-'));
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { errs, exitCode } = await withEnv(
|
||||
{ GBRAIN_SOURCE: undefined, GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: undefined },
|
||||
() => capture(() => runCodeCallers(engine, ['someSym', '--json'])));
|
||||
expect(exitCode).toBeNull();
|
||||
expect(errs.join('\n')).toContain("routing to source 'repo-a'");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('A4: dotfile-pin tier does NOT emit the sole_non_default nudge', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = pinnedDir('repo-a');
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { errs } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--json'])));
|
||||
expect(errs.join('\n')).not.toContain('routing to source');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('A5: zero-result implicit scope appends the "try --all-sources" hint', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = pinnedDir('repo-a');
|
||||
process.chdir(dir);
|
||||
try {
|
||||
// human output
|
||||
const human = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--no-json'])));
|
||||
expect(human.logs.join('\n')).toContain('Try --all-sources');
|
||||
|
||||
// JSON hint field
|
||||
const jsonRun = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallees(engine, ['someSym', '--json'])));
|
||||
const env = JSON.parse(jsonRun.logs.join('\n'));
|
||||
expect(env.count).toBe(0);
|
||||
expect(env.hint).toContain('--all-sources');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bad .gbrain-source pin → exit 2 with JSON error envelope', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const dir = pinnedDir('nonexistent-src');
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const { logs, exitCode } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
capture(() => runCodeCallers(engine, ['someSym', '--json'])));
|
||||
expect(exitCode).toBe(2);
|
||||
const env = JSON.parse(logs.join('\n'));
|
||||
expect(env.error.code).toBe('invalid_source_pin');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* v0.41.30.0 — resolveScopedSourceOrThrow resolution rule (BUG 1).
|
||||
*
|
||||
* code-callers / code-callees used to call resolveDefaultSource directly,
|
||||
* which only knew "1 source → use it, else multiple_sources_ambiguous" and
|
||||
* ignored the .gbrain-source pin. The new helper runs the full 7-tier chain
|
||||
* (flag → env → dotfile → local_path → brain_default → sole_non_default →
|
||||
* seed_default) and only applies the ambiguity guard on the no-signal
|
||||
* seed_default tier.
|
||||
*
|
||||
* This drives the helper directly with an explicit cwd (hermetic). The CLI
|
||||
* end-to-end wiring (process.cwd() + chdir) lives in
|
||||
* test/code-callers-pin.serial.test.ts.
|
||||
*
|
||||
* PGLite in-memory, no DATABASE_URL.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resolveScopedSourceOrThrow, SourceResolutionError } from '../src/core/sources-ops.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
async function addSource(id: string, localPath: string | null): Promise<void> {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, created_at)
|
||||
VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
|
||||
[id, localPath],
|
||||
);
|
||||
}
|
||||
|
||||
/** A throwaway directory NOT under any registered source's local_path and
|
||||
* with no .gbrain-source, so the resolver falls through to the DB tiers. */
|
||||
function cleanCwd(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'gbrain-scoped-clean-'));
|
||||
}
|
||||
|
||||
describe('resolveScopedSourceOrThrow', () => {
|
||||
test('single-source brain: returns default via seed_default tier (no throw)', async () => {
|
||||
const cwd = cleanCwd();
|
||||
try {
|
||||
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
expect(r.source_id).toBe('default');
|
||||
expect(r.tier).toBe('seed_default');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('.gbrain-source pin resolves on a multi-source brain (THE bug)', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-pin-'));
|
||||
writeFileSync(join(cwd, '.gbrain-source'), 'repo-a\n');
|
||||
try {
|
||||
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
expect(r.source_id).toBe('repo-a');
|
||||
expect(r.tier).toBe('dotfile');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no pin + no signal + multi-source → multiple_sources_ambiguous', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const cwd = cleanCwd();
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SourceResolutionError);
|
||||
if (caught instanceof SourceResolutionError) {
|
||||
expect(caught.code).toBe('multiple_sources_ambiguous');
|
||||
expect(caught.availableSources).toContain('repo-a');
|
||||
expect(caught.availableSources).toContain('repo-b');
|
||||
}
|
||||
});
|
||||
|
||||
test('default + one non-default source with local_path → sole_non_default tier', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
const cwd = cleanCwd();
|
||||
try {
|
||||
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
expect(r.source_id).toBe('repo-a');
|
||||
expect(r.tier).toBe('sole_non_default');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('GBRAIN_SOURCE env tier wins on a multi-source brain', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const cwd = cleanCwd();
|
||||
try {
|
||||
const r = await withEnv({ GBRAIN_SOURCE: 'repo-b' }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
expect(r.source_id).toBe('repo-b');
|
||||
expect(r.tier).toBe('env');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('brain_default (sources.default config) tier wins when no pin/env/cwd-match', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
await engine.setConfig('sources.default', 'repo-a');
|
||||
const cwd = cleanCwd();
|
||||
try {
|
||||
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
expect(r.source_id).toBe('repo-a');
|
||||
expect(r.tier).toBe('brain_default');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('zero sources → no_sources throw', async () => {
|
||||
await engine.executeRaw(`DELETE FROM sources WHERE id = 'default'`, []);
|
||||
const cwd = cleanCwd();
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SourceResolutionError);
|
||||
if (caught instanceof SourceResolutionError) {
|
||||
expect(caught.code).toBe('no_sources');
|
||||
}
|
||||
});
|
||||
|
||||
test('bad .gbrain-source pin (nonexistent source) throws a resolver user error', async () => {
|
||||
await addSource('repo-a', '/fake/a');
|
||||
await addSource('repo-b', '/fake/b');
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-badpin-'));
|
||||
writeFileSync(join(cwd, '.gbrain-source'), 'does-not-exist\n');
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
|
||||
resolveScopedSourceOrThrow(engine, cwd));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
// Bad pin surfaces as a plain Error from assertSourceExists, NOT a
|
||||
// SourceResolutionError — the command layer maps this to a clean exit 2.
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect(caught).not.toBeInstanceOf(SourceResolutionError);
|
||||
expect((caught as Error).message).toContain('does-not-exist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* v0.41.30.0 — `gbrain dream` runs against a checkout-less (postgres-shaped)
|
||||
* brain (BUG 2).
|
||||
*
|
||||
* Pre-fix dream.ts:resolveBrainDir process.exit(1)'d with "No brain directory
|
||||
* found" when neither --dir nor an on-disk sync.repo_path existed — so the
|
||||
* DB-only maintenance phases (notably resolve_symbol_edges, the call-graph
|
||||
* builder) could never run on a Supabase brain. Now brainDir can be null: the
|
||||
* 6 filesystem phases skip with reason `no_brain_dir` and the DB phases run.
|
||||
*
|
||||
* Covers: the null-brainDir path, A1 (the --source per-source scope fix),
|
||||
* A7 (deriveStatus reports `ok` not `clean` when edges resolve), and the
|
||||
* both-null hard error. PGLite in-memory (the bug branches on
|
||||
* `brainDir === null`, not engine.kind, so PGLite is a faithful proxy).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runDream } from '../src/commands/dream.ts';
|
||||
import { runCycle } from '../src/core/cycle.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
function phase(report: any, name: string) {
|
||||
return report.phases.find((p: any) => p.phase === name);
|
||||
}
|
||||
|
||||
// ─── BUG 2: no checkout → DB phases run, FS phases skip ──────────────
|
||||
|
||||
describe('runDream — checkout-less brain (no --dir, no sync.repo_path)', () => {
|
||||
test('full --dry-run returns a report with brain_dir null (no "No brain directory found" exit)', async () => {
|
||||
const report = await runDream(engine, ['--dry-run', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (!report) return;
|
||||
expect(report.brain_dir).toBeNull();
|
||||
// A filesystem phase is present and skipped with the no_brain_dir reason.
|
||||
const lint = phase(report, 'lint');
|
||||
expect(lint?.status).toBe('skipped');
|
||||
expect(lint?.details?.reason).toBe('no_brain_dir');
|
||||
// A DB-only phase ran (not a no_brain_dir skip).
|
||||
const orphans = phase(report, 'orphans');
|
||||
expect(orphans).toBeTruthy();
|
||||
expect(orphans?.details?.reason).not.toBe('no_brain_dir');
|
||||
});
|
||||
|
||||
test('--phase resolve_symbol_edges runs on a checkout-less brain (the call-graph phase)', async () => {
|
||||
const report = await runDream(engine, ['--phase', 'resolve_symbol_edges', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (!report) return;
|
||||
expect(report.brain_dir).toBeNull();
|
||||
const rse = phase(report, 'resolve_symbol_edges');
|
||||
expect(rse).toBeTruthy();
|
||||
// It RAN — not skipped for no_brain_dir / no_database, not failed.
|
||||
expect(rse?.status).not.toBe('fail');
|
||||
expect(rse?.details?.reason).not.toBe('no_brain_dir');
|
||||
expect(rse?.details?.reason).not.toBe('no_database');
|
||||
});
|
||||
|
||||
test('--phase lint skips with no_brain_dir (does not exit 1)', async () => {
|
||||
const report = await runDream(engine, ['--phase', 'lint', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (!report) return;
|
||||
const lint = phase(report, 'lint');
|
||||
expect(lint?.status).toBe('skipped');
|
||||
expect(lint?.details?.reason).toBe('no_brain_dir');
|
||||
});
|
||||
|
||||
test('--source <id> --dry-run on a checkout-less brain succeeds (the command doctor recommends)', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, created_at)
|
||||
VALUES ('repo-a', 'repo-a', NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
|
||||
[],
|
||||
);
|
||||
const report = await runDream(engine, ['--source', 'repo-a', '--dry-run', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (!report) return;
|
||||
expect(report.brain_dir).toBeNull();
|
||||
expect(report.status).not.toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A1: --source scopes per-source DB phases correctly on null brainDir ──
|
||||
|
||||
describe('runDream — A1 per-source scope (no checkout)', () => {
|
||||
test('dream --source repo-a --phase extract_facts reconciles facts for repo-a, not default', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, created_at)
|
||||
VALUES ('repo-a', 'repo-a', '/fake/repo-a', '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
|
||||
[],
|
||||
);
|
||||
// A page that exists ONLY under repo-a, carrying a Facts fence. With the
|
||||
// pre-fix bug, brainDir===null → resolveSourceForDir(null)→undefined →
|
||||
// xfSourceId='default' → getPage('people/bob',{sourceId:'default'}) → null →
|
||||
// 0 facts. The fix makes cycleSourceId = opts.sourceId = 'repo-a'.
|
||||
const fence =
|
||||
`# Bob\n\nBody.\n\n## Facts\n\n` +
|
||||
`<!--- gbrain:facts:begin -->\n` +
|
||||
`| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |\n` +
|
||||
`|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|\n` +
|
||||
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |\n` +
|
||||
`<!--- gbrain:facts:end -->\n`;
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO pages (slug, source_id, title, type, page_kind, compiled_truth, timeline, frontmatter, updated_at, created_at)
|
||||
VALUES ('people/bob', 'repo-a', 'Bob', 'person', 'markdown', $1, '', '{}'::jsonb, NOW(), NOW())`,
|
||||
[fence],
|
||||
);
|
||||
|
||||
const report = await runDream(engine, ['--source', 'repo-a', '--phase', 'extract_facts', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (!report) return;
|
||||
expect(report.brain_dir).toBeNull();
|
||||
|
||||
const rows = await engine.executeRaw<{ source_id: string; fact: string }>(
|
||||
`SELECT source_id, fact FROM facts WHERE source_markdown_slug = 'people/bob'`,
|
||||
[],
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].source_id).toBe('repo-a'); // NOT 'default' (the A1 bug)
|
||||
expect(rows[0].fact).toBe('Founded Acme');
|
||||
});
|
||||
|
||||
test('--source repo-a (no checkout) does NOT borrow the global sync.repo_path of a different source', async () => {
|
||||
// codex P1 regression: with --source set but that source having no on-disk
|
||||
// checkout, resolveBrainDir must return null (DB-only) and NOT fall through
|
||||
// to the global sync.repo_path — otherwise FS phases run against the default
|
||||
// brain's checkout while DB phases + the freshness stamp target repo-a.
|
||||
const globalRepo = mkdtempSync(join(tmpdir(), 'gbrain-global-repo-'));
|
||||
try {
|
||||
await engine.setConfig('sync.repo_path', globalRepo); // exists on disk
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, created_at)
|
||||
VALUES ('repo-a', 'repo-a', NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
|
||||
[],
|
||||
);
|
||||
const report = await runDream(engine, ['--source', 'repo-a', '--phase', 'lint', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (!report) return;
|
||||
// brain_dir must be null — NOT the globalRepo path.
|
||||
expect(report.brain_dir).toBeNull();
|
||||
const lint = report.phases.find((p: any) => p.phase === 'lint');
|
||||
expect(lint?.status).toBe('skipped');
|
||||
expect(lint?.details?.reason).toBe('no_brain_dir');
|
||||
} finally {
|
||||
rmSync(globalRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── A7: edges-only cycle reports ok, not clean ─────────────────────
|
||||
|
||||
describe('runCycle — A7 deriveStatus counts resolved edges as work', () => {
|
||||
test('an edges-only cycle that resolves an edge reports status ok (not clean)', async () => {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, created_at)
|
||||
VALUES ('src-a', 'src-a', '/fake/src-a', '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
|
||||
[],
|
||||
);
|
||||
const pageRows = await engine.executeRaw<{ id: number }>(
|
||||
`INSERT INTO pages (slug, source_id, title, type, page_kind, compiled_truth, frontmatter, updated_at, created_at)
|
||||
VALUES ('src/foo.ts', 'src-a', 'src/foo.ts', 'code', 'code', '', '{}'::jsonb, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[],
|
||||
);
|
||||
const pageId = pageRows[0]!.id;
|
||||
const caller = await engine.executeRaw<{ id: number }>(
|
||||
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, language, symbol_name_qualified, symbol_type)
|
||||
VALUES ($1, 0, '// caller', 'compiled_truth', 'typescript', 'callerA', 'function') RETURNING id`,
|
||||
[pageId],
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, language, symbol_name_qualified, symbol_type)
|
||||
VALUES ($1, 1, '// def', 'compiled_truth', 'typescript', 'targetFn', 'function')`,
|
||||
[pageId],
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO code_edges_symbol (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, source_id, edge_metadata)
|
||||
VALUES ($1, 'callerA', 'targetFn', 'calls', 'src-a', '{}'::jsonb)`,
|
||||
[caller[0]!.id],
|
||||
);
|
||||
|
||||
const report = await runCycle(engine, { brainDir: null, phases: ['resolve_symbol_edges'] });
|
||||
expect(report.totals.edges_resolved).toBe(1);
|
||||
expect(report.status).toBe('ok'); // pre-A7 this was 'clean'
|
||||
});
|
||||
});
|
||||
|
||||
// ─── both-null hard error preserved ─────────────────────────────────
|
||||
|
||||
describe('runDream — both-null (no engine, no dir) still exits 1', () => {
|
||||
test('runDream(null, []) exits 1', async () => {
|
||||
const exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as never);
|
||||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
let threw = '';
|
||||
try {
|
||||
await runDream(null, []);
|
||||
} catch (e) {
|
||||
threw = (e as Error).message;
|
||||
}
|
||||
// Assert BEFORE mockRestore — bun's mockRestore clears recorded calls.
|
||||
expect(threw).toBe('EXIT');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("runDream(null, ['--phase','orphans']) exits 1", async () => {
|
||||
const exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as never);
|
||||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||||
let threw = '';
|
||||
try {
|
||||
await runDream(null, ['--phase', 'orphans']);
|
||||
} catch (e) {
|
||||
threw = (e as Error).message;
|
||||
}
|
||||
expect(threw).toBe('EXIT');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* v0.41.30.0 (T2) — the Minions `autopilot-cycle` handler runs on a
|
||||
* checkout-less brain.
|
||||
*
|
||||
* Pre-fix jobs.ts defaulted repoPath to cwd `'.'` when no repo was configured,
|
||||
* then fed that into runCycle — so a queued cycle (what `gbrain remote ping`
|
||||
* triggers) on a checkout-less postgres brain ran filesystem phases against the
|
||||
* worker's cwd instead of skipping them. Now it passes `null`, so the handler
|
||||
* follows the same no_brain_dir contract as `gbrain dream`.
|
||||
*
|
||||
* Drives the REAL handler (captured from registerBuiltinHandlers) — not a
|
||||
* source-grep — so a future refactor that reintroduces the '.' fallback fails
|
||||
* here. PGLite in-memory.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
/** Capture registered handlers via a minimal fake worker. */
|
||||
async function captureHandlers(): Promise<Map<string, (job: any) => Promise<any>>> {
|
||||
const handlers = new Map<string, (job: any) => Promise<any>>();
|
||||
const fakeWorker = { register(name: string, fn: (job: any) => Promise<any>) { handlers.set(name, fn); } };
|
||||
await registerBuiltinHandlers(fakeWorker as never, engine);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
describe('jobs autopilot-cycle handler — no repo configured', () => {
|
||||
test('feeds null brainDir → filesystem phases skip (no_brain_dir), DB phases run', async () => {
|
||||
const handlers = await captureHandlers();
|
||||
const handler = handlers.get('autopilot-cycle');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
// No sync.repo_path config on a fresh brain, no repoPath in job data →
|
||||
// the handler must pass null (not cwd '.') to runCycle.
|
||||
const result = await handler!({
|
||||
data: { phases: ['lint', 'resolve_symbol_edges'] },
|
||||
signal: undefined,
|
||||
});
|
||||
|
||||
const report = result.report;
|
||||
expect(report.brain_dir).toBeNull();
|
||||
const lint = report.phases.find((p: any) => p.phase === 'lint');
|
||||
expect(lint?.status).toBe('skipped');
|
||||
expect(lint?.details?.reason).toBe('no_brain_dir');
|
||||
const rse = report.phases.find((p: any) => p.phase === 'resolve_symbol_edges');
|
||||
expect(rse).toBeTruthy();
|
||||
expect(rse?.details?.reason).not.toBe('no_brain_dir');
|
||||
expect(rse?.status).not.toBe('fail');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user