Files
gbrain/test/dream.test.ts
T
d2fd1f297c fix(cycle): stamp path-derived dream sources; close engine on autopilot shutdown (#3178)
* 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>
2026-07-23 14:47:11 -07:00

704 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});