mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(dream): stamp path-derived sources so --dir runs land cycle freshness (#1869) gbrain dream --dir <path> (and the configured sync.repo_path fallback) never wrote last_source_cycle_at / last_full_cycle_at because runCycle's stamp gate reads opts.sourceId and dream only set it from --source. Doctor's cycle_freshness stayed perpetually stale on path-scoped brains. Fix at the command level: dream derives the source id from the resolved brain dir via resolveSourceForDir (now exported from cycle.ts) and passes it as opts.sourceId. runCycle's stamp/lock semantics are untouched, so legacy global callers (autopilot-global-maintenance runs GLOBAL_PHASES with a brainDir and no sourceId) cannot falsely stamp per-source freshness — the flaw that sank the runCycle-wide variant in PR #2549. A derived match on an archived source is skipped (mirrors the explicit --source archived guard). Takeover of #2549. Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(autopilot): close the engine on SIGTERM/SIGINT instead of hard-exiting (#1872) systemctl stop (SIGTERM) previously hard-exited autopilot without ever closing the engine. On PGLite the cycle steps run INLINE in the autopilot process, so a mid-write exit kills WASM Postgres with the WAL dirty and can corrupt the brain. Now both exit paths close the engine first: - autopilot's own shutdown() (SIGINT + internal stops like max_crashes / cycle-failure-cap) aborts the in-flight inline cycle via an AbortController threaded into runCycle, drains it briefly, and awaits engine.disconnect() before process.exit(0). - process-cleanup's SIGTERM handler (installed at cli.ts module load, exits within its 3s cleanup deadline) reaches the same closeEngine via a registered 'autopilot-engine-close' cleanup callback. PGLite's disconnect() drains the pending query and checkpoints before closing; a second call is a no-op, so both paths firing is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(dream): conform dream-dir-source-stamp to canonical PGLite isolation pattern check:test-isolation R3/R4 flagged the new test file: engine was created in beforeEach (outside beforeAll) and never disconnected in afterAll. Switch to the canonical shared-engine pattern (beforeAll create, beforeEach resetPgliteState, afterAll disconnect) per test/helpers/reset-pglite.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
704 lines
30 KiB
TypeScript
704 lines
30 KiB
TypeScript
/**
|
||
* Unit tests for src/commands/dream.ts — CLI alias over runCycle.
|
||
*
|
||
* dream is intentionally thin. These tests exercise the CLI surface
|
||
* (argv parsing, brainDir resolution, output format, exit codes)
|
||
* against a REAL runCycle + real library calls, backed by an
|
||
* in-memory PGLite engine.
|
||
*
|
||
* Why no mocks: `mock.module` in bun is process-global and leaks
|
||
* across test files (a stub of ../src/commands/orphans.ts breaks
|
||
* every test that imports shouldExclude/deriveDomain/formatOrphansText).
|
||
* Testing against real calls is honest and mock-leak-free.
|
||
*
|
||
* What this test file does NOT cover: the exhaustive dryRun-×-phases-×-
|
||
* lock matrix, which test/core/cycle.test.ts handles (in isolation).
|
||
* Here we only verify that dream.ts routes args correctly.
|
||
*/
|
||
|
||
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||
import { mkdtempSync, rmSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { tmpdir } from 'os';
|
||
import { execSync } from 'child_process';
|
||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||
import { runDream } from '../src/commands/dream.ts';
|
||
|
||
// ─── Helpers ───────────────────────────────────────────────────────
|
||
|
||
/** Make an empty, engine-backed PGLite brain. */
|
||
async function makePGLite() {
|
||
const engine = new PGLiteEngine();
|
||
await engine.connect({});
|
||
await engine.initSchema();
|
||
return engine;
|
||
}
|
||
|
||
/** Make an empty git repo. Lint/backlinks have nothing to scan → status=clean. */
|
||
function makeGitRepo(): string {
|
||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-dream-repo-'));
|
||
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
||
execSync('git config user.email t@t.co', { cwd: dir, stdio: 'pipe' });
|
||
execSync('git config user.name t', { cwd: dir, stdio: 'pipe' });
|
||
// Commit an empty .gitkeep so rev-parse HEAD succeeds.
|
||
require('fs').writeFileSync(join(dir, '.gitkeep'), '');
|
||
execSync('git add -A && git commit -m init', { cwd: dir, stdio: 'pipe' });
|
||
return dir;
|
||
}
|
||
|
||
// ─── brainDir resolution ───────────────────────────────────────────
|
||
|
||
describe('runDream — brainDir resolution', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('explicit --dir takes precedence over engine config', async () => {
|
||
await engine.setConfig('sync.repo_path', '/configured/dir');
|
||
const report = await runDream(engine, ['--dir', repo, '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) expect(report.brain_dir).toBe(repo);
|
||
});
|
||
|
||
test('no --dir + engine-configured: uses engine.getConfig("sync.repo_path")', async () => {
|
||
await engine.setConfig('sync.repo_path', repo);
|
||
const report = await runDream(engine, ['--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) expect(report.brain_dir).toBe(repo);
|
||
});
|
||
|
||
test('no --dir + engine=null exits 1', async () => {
|
||
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(null, []);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(spy).toHaveBeenCalledWith(1);
|
||
spy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
test('--dir pointing at nonexistent path exits 1', async () => {
|
||
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(null, ['--dir', '/does/not/exist/hopefully']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(spy).toHaveBeenCalledWith(1);
|
||
spy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
// ─── Phase selection (single-phase runs stay fast) ─────────────────
|
||
|
||
describe('runDream — --phase <name> restricts the cycle', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('--phase lint produces a report with exactly one phase = lint', async () => {
|
||
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) {
|
||
expect(report.phases.length).toBe(1);
|
||
expect(report.phases[0].phase).toBe('lint');
|
||
}
|
||
});
|
||
|
||
test('--phase orphans produces a report with exactly one phase = orphans', async () => {
|
||
const report = await runDream(engine, ['--dir', repo, '--phase', 'orphans', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) {
|
||
expect(report.phases.length).toBe(1);
|
||
expect(report.phases[0].phase).toBe('orphans');
|
||
}
|
||
});
|
||
|
||
test('--phase garbage exits 1 with an error message', async () => {
|
||
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(null, ['--dir', repo, '--phase', 'garbage']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(errSpy).toHaveBeenCalled();
|
||
spy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
// ─── --once (issue #2860) ───────────────────────────────────────────
|
||
|
||
describe('runDream — --once (issue #2860)', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000);
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('bare --once (no --phase) exits 2 with a usage hint', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--dir', repo, '--once']);
|
||
throw new Error('expected runDream to exit');
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--once requires an explicit --phase <name>/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
// Codex P3 finding: --input implicitly sets `phase = 'synthesize'` and
|
||
// --drain implicitly sets `phase = 'extract_atoms'` — an EARLIER version
|
||
// of the --once validation checked the derived `phase` value, which was
|
||
// already non-null by the time it ran, so both of these silently slipped
|
||
// past the "explicit --phase required" contract (and --once became a
|
||
// no-op: --drain returns before onceForPhase is ever read; --input
|
||
// already bypasses the synthesize gate on its own). The fix validates
|
||
// against `phaseWasExplicit` (whether the user actually typed --phase)
|
||
// instead of the derived value.
|
||
test('--input <file> --once (no explicit --phase) exits 2 — an implied phase does not count', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--dir', repo, '--input', '/tmp/gbrain-2860-nonexistent.txt', '--once']);
|
||
throw new Error('expected runDream to exit');
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--once requires an explicit --phase <name>/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
test('--drain --once (no explicit --phase) exits 2 — an implied phase does not count', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--dir', repo, '--drain', '--once']);
|
||
throw new Error('expected runDream to exit');
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--once requires an explicit --phase <name>/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
// Codex review finding: --help must short-circuit BEFORE the bare---once
|
||
// usage error, matching the same IRON RULE pinned above for --source
|
||
// ("--help --source whatever prints help and exits 0").
|
||
test('--help --once (no --phase) prints help and exits 0, not the usage error', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||
try {
|
||
const result = await runDream(engine, ['--help', '--once']);
|
||
expect(result).toBeUndefined();
|
||
} catch (e: any) {
|
||
throw new Error('--help with --once should NOT exit; got: ' + e.message);
|
||
}
|
||
expect(exitSpy).not.toHaveBeenCalled();
|
||
expect(logSpy.mock.calls.flat().join(' ')).toMatch(/Usage: gbrain dream/);
|
||
exitSpy.mockRestore();
|
||
logSpy.mockRestore();
|
||
});
|
||
|
||
test('--phase patterns --once bypasses dream.patterns.enabled=false without writing config', async () => {
|
||
await engine.setConfig('dream.patterns.enabled', 'false');
|
||
const report = await runDream(engine, ['--dir', repo, '--phase', 'patterns', '--once', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) {
|
||
expect(report.phases.length).toBe(1);
|
||
// Bypassed 'disabled' → falls through to the next gate. No
|
||
// reflections seeded, so it reports insufficient_evidence, not
|
||
// 'disabled' — proving --once actually forced the run.
|
||
expect(report.phases[0].phase).toBe('patterns');
|
||
expect((report.phases[0].details as { reason?: string }).reason).toBe('insufficient_evidence');
|
||
}
|
||
expect(await engine.getConfig('dream.patterns.enabled')).toBe('false');
|
||
});
|
||
|
||
test('--phase patterns (no --once) with dream.patterns.enabled=false still skips as disabled', async () => {
|
||
await engine.setConfig('dream.patterns.enabled', 'false');
|
||
const report = await runDream(engine, ['--dir', repo, '--phase', 'patterns', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) {
|
||
expect((report.phases[0].details as { reason?: string }).reason).toBe('disabled');
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── Output format ─────────────────────────────────────────────────
|
||
|
||
describe('runDream — output format', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('--json emits parsable CycleReport JSON with schema_version', async () => {
|
||
const lines: string[] = [];
|
||
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
|
||
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
|
||
logSpy.mockRestore();
|
||
const parsed = JSON.parse(lines.join('\n'));
|
||
expect(parsed.schema_version).toBe('1');
|
||
expect(parsed).toHaveProperty('status');
|
||
expect(parsed).toHaveProperty('phases');
|
||
expect(parsed).toHaveProperty('totals');
|
||
});
|
||
|
||
// #394 / takeover of #854: the embed phase's `[dry-run] Would embed ...`
|
||
// summary must not leak onto stdout ahead of the JSON CycleReport.
|
||
test('--dry-run --json emits only JSON even when embed has stale chunks', async () => {
|
||
await engine.putPage('concepts/testing', {
|
||
type: 'concept',
|
||
title: 'Testing',
|
||
compiled_truth: 'Testing keeps JSON contracts honest.',
|
||
timeline: '',
|
||
});
|
||
await engine.upsertChunks('concepts/testing', [
|
||
{ chunk_index: 0, chunk_text: 'Testing keeps JSON contracts honest.', chunk_source: 'compiled_truth' },
|
||
]);
|
||
|
||
const lines: string[] = [];
|
||
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
|
||
await runDream(engine, ['--dir', repo, '--phase', 'embed', '--dry-run', '--json']);
|
||
logSpy.mockRestore();
|
||
|
||
const output = lines.join('\n');
|
||
expect(output.trimStart().startsWith('{')).toBe(true);
|
||
const parsed = JSON.parse(output);
|
||
expect(parsed.schema_version).toBe('1');
|
||
expect(parsed.phases[0].phase).toBe('embed');
|
||
// The stale chunk was still counted in the structured report.
|
||
expect(parsed.phases[0].details.would_embed).toBe(1);
|
||
});
|
||
|
||
test('human output for clean status mentions "Brain is healthy"', async () => {
|
||
const lines: string[] = [];
|
||
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
|
||
// Single-phase lint run on a clean repo → status=clean.
|
||
await runDream(engine, ['--dir', repo, '--phase', 'lint']);
|
||
logSpy.mockRestore();
|
||
expect(lines.join('\n')).toContain('Brain is healthy');
|
||
});
|
||
});
|
||
|
||
// ─── Dry-run propagation ───────────────────────────────────────────
|
||
|
||
describe('runDream — dry-run propagates through to runCycle', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('--dry-run produces a report where no DB-mutating work happened', async () => {
|
||
// Before: empty pages table.
|
||
const { rows: before } = await (engine as any).db.query('SELECT COUNT(*)::int AS n FROM pages');
|
||
expect(before[0].n).toBe(0);
|
||
|
||
await runDream(engine, ['--dir', repo, '--dry-run', '--json']);
|
||
|
||
// After dry-run: still 0 pages. The cycle ran but wrote nothing.
|
||
const { rows: after } = await (engine as any).db.query('SELECT COUNT(*)::int AS n FROM pages');
|
||
expect(after[0].n).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ─── Exit-code semantics ───────────────────────────────────────────
|
||
|
||
describe('runDream — exit-code semantics', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('clean/ok/partial statuses do not call process.exit', async () => {
|
||
const spy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('UNEXPECTED_EXIT'); });
|
||
await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
|
||
expect(spy).not.toHaveBeenCalled();
|
||
spy.mockRestore();
|
||
});
|
||
});
|
||
|
||
// ─── v0.41.13: --source / --source-id wiring (supersedes PR #1559) ────
|
||
//
|
||
// Covers:
|
||
// - argv repetition + conflict rules (parseArgs path)
|
||
// - engine-null guard (D1 from eng review)
|
||
// - assertSourceExists propagation (resolveSourceId path)
|
||
// - archived-source guard (D2)
|
||
// - --source-id alias equivalence (D3)
|
||
// - --help short-circuit ordering (C-8)
|
||
// - typed-error propagation (T3: TypeError must NOT be swallowed)
|
||
// - end-to-end dream→doctor (D5): writeback flips cycle_freshness
|
||
// - back-compat regression: bare `gbrain dream` writes no per-source stamp
|
||
|
||
describe('runDream — --source / --source-id (v0.41.13)', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
async function seedSource(id: string, archived: boolean = false): Promise<void> {
|
||
await engine.executeRaw(
|
||
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
||
VALUES ($1, $2, $3, '{}'::jsonb, $4, NOW())
|
||
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path, archived = EXCLUDED.archived`,
|
||
[id, id, repo, archived],
|
||
);
|
||
}
|
||
|
||
async function readLastFullCycleAt(sourceId: string): Promise<string | null> {
|
||
const sources = await engine.listAllSources();
|
||
const s = sources.find(x => x.id === sourceId);
|
||
if (!s) return null;
|
||
const raw = (s.config as any)?.last_full_cycle_at;
|
||
return typeof raw === 'string' ? raw : null;
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000);
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
// ─── parseArgs: --source missing / conflict / repetition ────────────
|
||
|
||
test('--source with no value exits 2 with usage hint', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--source']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--source.*missing value/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
test('--source-id with no value exits 2 with usage hint', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--source-id']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/--source-id.*missing value/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
test('--source X --source Y (repeated, different values) exits 2', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--source', 'foo', '--source', 'bar']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/specify --source once/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
test('--source X --source X (repeated, same value) is accepted', async () => {
|
||
await seedSource('alpha');
|
||
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--source', 'alpha', '--source', 'alpha', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) expect(['ok', 'clean']).toContain(report.status);
|
||
}, 60_000);
|
||
|
||
test('--source X --source-id Y (conflict) exits 2', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--source', 'foo', '--source-id', 'bar']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/use --source OR --source-id, not both/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
// ─── Help short-circuit ordering (C-8 IRON RULE) ────────────────────
|
||
|
||
test('--help --source whatever prints help and exits 0 (no engine-null error)', async () => {
|
||
// engine null + --source set would normally exit 1, but --help short-circuits first.
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||
try {
|
||
const result = await runDream(null, ['--help', '--source', 'anything']);
|
||
expect(result).toBeUndefined();
|
||
} catch (e: any) {
|
||
throw new Error('--help with --source should NOT exit; got: ' + e.message);
|
||
}
|
||
expect(exitSpy).not.toHaveBeenCalled();
|
||
expect(logSpy.mock.calls.flat().join(' ')).toMatch(/Usage: gbrain dream/);
|
||
exitSpy.mockRestore();
|
||
logSpy.mockRestore();
|
||
});
|
||
|
||
// ─── Engine-null guard (D1) ─────────────────────────────────────────
|
||
|
||
test('engine=null + --source set exits 1 with "requires a connected brain"', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(null, ['--source', 'whatever']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||
expect(errSpy.mock.calls.flat().join(' ')).toMatch(/requires a connected brain/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
// ─── Unknown source (resolveSourceId throw) ─────────────────────────
|
||
|
||
test('--source <unknown> exits 1 with assertSourceExists hint', async () => {
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--source', 'no-such-source']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||
const errOut = errSpy.mock.calls.flat().join(' ');
|
||
expect(errOut).toMatch(/Source "no-such-source" not found/);
|
||
expect(errOut).toMatch(/gbrain sources list/);
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
// ─── Archived source guard (D2) ─────────────────────────────────────
|
||
|
||
test('--source <archived> exits 1 and leaves last_full_cycle_at untouched', async () => {
|
||
await seedSource('archived-thing', /* archived = */ true);
|
||
const before = await readLastFullCycleAt('archived-thing');
|
||
expect(before).toBeNull();
|
||
|
||
const exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('EXIT'); });
|
||
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
|
||
try {
|
||
await runDream(engine, ['--source', 'archived-thing']);
|
||
} catch (e: any) {
|
||
expect(e.message).toBe('EXIT');
|
||
}
|
||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||
const errOut = errSpy.mock.calls.flat().join(' ');
|
||
expect(errOut).toMatch(/source archived-thing is archived/);
|
||
expect(errOut).toMatch(/gbrain sources restore archived-thing/);
|
||
|
||
const after = await readLastFullCycleAt('archived-thing');
|
||
expect(after).toBeNull(); // archived guard prevents writeback
|
||
exitSpy.mockRestore();
|
||
errSpy.mockRestore();
|
||
});
|
||
|
||
// ─── Happy path: --source writes last_full_cycle_at (the bug fix) ───
|
||
|
||
test('--source <existing> writes last_full_cycle_at on success (PR #1559 regression)', async () => {
|
||
await seedSource('media-corpus');
|
||
const before = await readLastFullCycleAt('media-corpus');
|
||
expect(before).toBeNull();
|
||
|
||
const t0 = Date.now();
|
||
const report = await runDream(engine, ['--dir', repo, '--source', 'media-corpus', '--phase', 'lint', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) expect(['ok', 'clean']).toContain(report.status);
|
||
|
||
const after = await readLastFullCycleAt('media-corpus');
|
||
expect(after).not.toBeNull();
|
||
const writtenMs = new Date(after!).getTime();
|
||
expect(writtenMs).toBeGreaterThanOrEqual(t0);
|
||
expect(writtenMs).toBeLessThanOrEqual(Date.now() + 1000);
|
||
}, 60_000);
|
||
|
||
// ─── Back-compat: bare `gbrain dream` does NOT write per-source stamp ─
|
||
|
||
test('gbrain dream (no --source) stamps only the source whose local_path matches --dir (#1869)', async () => {
|
||
// Pre-#1869 this asserted NO source was ever stamped without an explicit
|
||
// --source — which is exactly the bug: a path-scoped `gbrain dream --dir`
|
||
// run never landed a freshness stamp and doctor's cycle_freshness stayed
|
||
// stale forever. New truth: the source whose local_path matches the
|
||
// resolved brain dir is derived and stamped; unrelated sources stay
|
||
// untouched (cross-source isolation).
|
||
await seedSource('alpha'); // local_path = repo → derived + stamped
|
||
await engine.executeRaw(
|
||
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
||
VALUES ($1, $2, $3, '{}'::jsonb, false, NOW())`,
|
||
['beta', 'beta', '/somewhere/else'],
|
||
);
|
||
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
|
||
expect(report).toBeTruthy();
|
||
expect(await readLastFullCycleAt('alpha')).not.toBeNull();
|
||
expect(await readLastFullCycleAt('beta')).toBeNull();
|
||
}, 60_000);
|
||
|
||
// ─── --source-id alias equivalence (D3) ─────────────────────────────
|
||
|
||
test('--source-id <existing> is equivalent to --source (writes timestamp)', async () => {
|
||
await seedSource('beta');
|
||
const before = await readLastFullCycleAt('beta');
|
||
expect(before).toBeNull();
|
||
|
||
const report = await runDream(engine, ['--dir', repo, '--source-id', 'beta', '--phase', 'lint', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) expect(['ok', 'clean']).toContain(report.status);
|
||
|
||
const after = await readLastFullCycleAt('beta');
|
||
expect(after).not.toBeNull();
|
||
}, 60_000);
|
||
|
||
// ─── T3: TypeError MUST propagate (not swallowed by predicate-gated catch) ─
|
||
|
||
test('non-resolver-user errors propagate uncaught (T3)', async () => {
|
||
// Monkey-patch executeRaw to throw a synthetic TypeError on the
|
||
// assertSourceExists SELECT. The typed-error catch in runDream only
|
||
// matches resolver-user-error message shapes; a TypeError thrown
|
||
// from any source-resolution path must bubble up with its original
|
||
// stack trace (proving real programmer bugs are NOT hidden behind
|
||
// operator-error UX).
|
||
await seedSource('gamma');
|
||
const original = (engine as any).executeRaw.bind(engine);
|
||
let restored = false;
|
||
try {
|
||
// Throw a TypeError on the source-lookup SELECT that assertSourceExists runs.
|
||
// Other executeRaw calls (used by engine internals during cycle) keep
|
||
// working so the test exercises ONLY the resolution-path failure.
|
||
(engine as any).executeRaw = async (sql: string, params?: unknown[]) => {
|
||
if (typeof sql === 'string' && /FROM\s+sources\s+WHERE\s+id\s*=/i.test(sql)) {
|
||
throw new TypeError('synthetic-test-bug');
|
||
}
|
||
return original(sql, params);
|
||
};
|
||
await expect(
|
||
runDream(engine, ['--dir', repo, '--source', 'gamma', '--phase', 'lint', '--json'])
|
||
).rejects.toThrow(/synthetic-test-bug/);
|
||
} finally {
|
||
(engine as any).executeRaw = original;
|
||
restored = true;
|
||
}
|
||
expect(restored).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── v0.41.13 D5: end-to-end dream → checkCycleFreshness parity ───────
|
||
//
|
||
// Closes the column-rename drift class: if a future PR renames
|
||
// last_full_cycle_at on one side but not the other, both isolated
|
||
// tests stay green but production breaks. This exercises the FULL
|
||
// chain through the exact seam both sides consume.
|
||
|
||
describe('runDream → checkCycleFreshness end-to-end (D5)', () => {
|
||
let repo: string;
|
||
let engine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeEach(async () => {
|
||
repo = makeGitRepo();
|
||
engine = await makePGLite();
|
||
}, 60_000);
|
||
|
||
afterEach(async () => {
|
||
if (engine) await engine.disconnect();
|
||
rmSync(repo, { recursive: true, force: true });
|
||
}, 60_000);
|
||
|
||
test('stale source becomes fresh after dream --source (column-name drift guard)', async () => {
|
||
// Seed source with last_full_cycle_at backdated 25h (above warn floor).
|
||
const stale = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
||
await engine.executeRaw(
|
||
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
||
VALUES ('gamma', 'gamma', $1, jsonb_build_object('last_full_cycle_at', $2::text), false, NOW())`,
|
||
[repo, stale],
|
||
);
|
||
|
||
// Doctor sees stale (warn or fail; we only care that it's NOT ok)
|
||
const { checkCycleFreshness } = await import('../src/commands/doctor.ts');
|
||
const beforeCheck = await checkCycleFreshness(engine);
|
||
expect(beforeCheck.status).not.toBe('ok');
|
||
|
||
// Run dream against the stale source.
|
||
const report = await runDream(engine, ['--dir', repo, '--source', 'gamma', '--phase', 'lint', '--json']);
|
||
expect(report).toBeTruthy();
|
||
if (report) expect(['ok', 'clean']).toContain(report.status);
|
||
|
||
// Doctor now sees fresh.
|
||
const afterCheck = await checkCycleFreshness(engine);
|
||
expect(afterCheck.status).toBe('ok');
|
||
}, 60_000);
|
||
});
|