mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* feat(v0.34 pre-w0): add code-retrieval eval harness for v0.34 ship gate
Captures pre-v0.34 retrieval quality on the gbrain self-corpus before any
code-intel work lands, so the v0.34 ship gate (precision@5 +10pp OR
answered_rate +15pp on >=15/30 questions) measures real improvement
rather than an after-the-fact retuned baseline.
* src/eval/code-retrieval/harness.ts -- pure-function metrics (precision@k,
recall@k, top-1 stability, gate evaluator) + EvalRunReport types stable
across schema_version 1
* src/eval/code-retrieval/questions.json -- 30 questions across callers /
callees / definition / references / blast_radius / execution_flow /
cluster_membership kinds, expected_files captured against current
gbrain layout
* src/eval/code-retrieval/strategies.ts -- BaselineStrategy (hybridSearch)
+ WithCodeIntelStrategy stub (post-W3 fills in code_blast/code_flow/etc.)
* src/commands/eval-code-retrieval.ts -- gbrain eval code-retrieval CLI
with --baseline / --with-code-intel / --compare subcommands
* test/code-retrieval-harness.test.ts -- 26 unit tests across metrics,
loader, gate logic; no engine dependency
PRE-V0.34 BASELINE WORKFLOW:
gbrain eval code-retrieval --baseline --save /tmp/baseline-1.json
(run 3x for noise floor)
V0.34 SHIP GATE (after W3 lands):
gbrain eval code-retrieval --with-code-intel --save /tmp/v034.json
gbrain eval code-retrieval --compare /tmp/baseline-1.json /tmp/v034.json
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v0.34 W0a): source-routing leak across query + two-pass
Codex outside-voice review on the v0.34 plan caught two load-bearing
sites where sourceId was advertised but never applied — multi-source
brains silently cross-contaminated structural retrieval:
* operations.ts ~323 — `query` op handler called hybridSearch without
threading ctx.sourceId. Multi-source agents querying with a
--source flag got cross-source results.
* two-pass.ts:81 (nearSymbol lookup) and two-pass.ts:131 (unresolved
edge resolution) — TwoPassOpts.sourceId was declared and threaded
through hybridSearch's expandAnchors call, but the actual SQL ignored
it. The walk window crossed source boundaries every time.
Fix:
* `query` op now reads ctx.sourceId AND accepts a new `source_id`
param (with '__all__' as the explicit force-cross-source escape
hatch). Per-call param wins over ctx context.
* two-pass.ts both lookups join through pages.source_id when
opts.sourceId is set; omitted opts.sourceId preserves the legacy
cross-source contract for callers who want it.
Regression test: test/e2e/source-routing.test.ts seeds two sources
with the same `parseMarkdown` symbol + a cross-source caller edge.
Pins:
- nearSymbol + sourceId='source-a' returns ONLY source-a chunks
- nearSymbol + sourceId='source-b' returns ONLY source-b chunks
- nearSymbol with no sourceId still crosses sources (contract preserved)
- walk_depth=1 unresolved-edge resolution stays in source-a
PGLite in-memory, no DATABASE_URL needed. The fix proves out under
realistic structural retrieval not just a contrived unit test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v0.34 W0b): flip CLI source-scoping default to truly source-scoped
Codex outside-voice review (finding #7) caught that the v0.20.0
docstring claim "by default we only match the caller's source_id"
contradicted the implementation in code-callers.ts:54 + code-callees.ts:43:
allSources: allSources || !sourceId
The right side made `allSources` TRUE whenever `--source` was omitted,
INVERTING the documented default. Multi-source brains silently cross-
contaminated structural retrieval; `gbrain code-callers parseMarkdown`
on a brain with two repos returned callers from both even though the
docstring promised per-source scoping.
Fix:
* New canonical helper `resolveDefaultSource(engine)` in sources-ops.ts.
Contract per eng review D7:
- exactly 1 source registered → return its id (single-source brains,
the 80% case; --source flag is unnecessary friction there)
- 2+ sources → throw SourceResolutionError(multiple_sources_ambiguous)
with the list of valid ids
- 0 sources → throw SourceResolutionError(no_sources)
* code-callers.ts + code-callees.ts now resolve to the default source
when both --source AND --all-sources are absent. To get the pre-v0.34
cross-source behavior, callers must pass --all-sources explicitly.
* Same hint text on both commands. Pinned by test/e2e/cli-source-scoping-pglite.test.ts.
IRON RULE regression R2: docstring promise now holds. Multi-source brain
running `gbrain code-callers <symbol>` without --source gets a clear
error listing valid source ids instead of silent cross-resolution.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.34 W0c): within-file two-pass symbol resolver + edges_backfilled_at watermark
Codex's outside-voice review caught that the v0.20.0 graph stores BARE
callee tokens (`render`, `find`, `execute`) — not qualified names. Pre-v0.34
recursive blast/flow would alias every same-named function across classes.
W0c is the foundation that fixes this: resolve `code_edges_symbol` rows by
matching `to_symbol_qualified` against the SAME-FILE chunks'
`symbol_name_qualified`, then write the outcome to `edge_metadata`.
This commit is the resolver primitive + schema. The cycle-phase wiring
that calls it on every quick-cycle tick lands in the next commit.
Schema (v51 migration `edges_backfilled_at_v0_34`):
* `content_chunks.edges_backfilled_at TIMESTAMPTZ` — resume watermark.
Chunks where the column is NULL OR older than EDGE_EXTRACTOR_VERSION_TS
get re-walked next tick. SIGINT/OOM/sleep mid-backfill loses at most
one batch.
* Indexes per D11 from eng review:
- `idx_code_edges_symbol_resolver(source_id, to_symbol_qualified)` —
composite for the resolver's per-source lookup.
- `idx_content_chunks_symbol_lookup(page_id, symbol_name_qualified)`
WHERE `symbol_name_qualified IS NOT NULL` — file-batched candidate
fetch; also reused by W4-5 cluster recompute.
- `idx_content_chunks_edges_backfill(edges_backfilled_at)` WHERE
`edges_backfilled_at IS NULL` — fast unresumed-row scan.
Module (`src/core/chunkers/symbol-resolver.ts`):
* `resolveSymbolEdgesIncremental(engine, {sourceId, maxChunks?, onProgress?})`
walks stale chunks in 200-chunk batches. For each chunk, loads its
unresolved edges, finds same-page candidates by symbol_name_qualified,
and writes outcome to `edge_metadata`:
- exactly 1 candidate → `{resolved_chunk_id: <id>}`
- 2+ candidates → `{ambiguous: true, candidates: [...]}`
- 0 candidates → unchanged (cross-file; two-pass.ts handles those)
Each batch bumps `edges_backfilled_at = NOW()` for the chunks.
* `readEdgeResolution(metadata)` — public helper for downstream code
(two-pass.ts, code_blast op, eval-capture) to consume the resolver's
output without parsing JSON directly. Returns a tagged union.
* `EDGE_EXTRACTOR_VERSION_TS` exported constant — bump when extractor
shape changes and the next cycle re-walks all chunks.
Tests (5 E2E in test/e2e/symbol-resolver-pglite.test.ts, all PGLite,
no DATABASE_URL): unambiguous match, ambiguous multi-match, no match,
watermark advance + idempotency, source isolation (no cross-source
candidate leak).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.34 W0c): wire resolve_symbol_edges as a new cycle phase
W0c's symbol resolver lands as a 12th cycle phase between extract and
patterns. The autopilot's quick-cycle path (60s watchdog interval per
D2 from eng review) now resolves stale chunks incrementally so agents
see resolved edges within ~60s of writes rather than waiting on the
slow full-walk path.
* CyclePhase + ALL_PHASES + NEEDS_LOCK_PHASES extended with
'resolve_symbol_edges'. Position: between extract (which emits new
bare-token edges from sync diffs) and patterns (which reads the
graph). Acquires the cycle lock because it writes edge_metadata.
* CycleReport.totals adds edges_resolved + edges_ambiguous so doctor
and autopilot summaries surface the numbers.
* runPhaseResolveSymbolEdges walks every registered source via
listSources() + resolveSymbolEdgesIncremental(). Per-call cap is
BATCH_SIZE*10 = 2000 chunks so a single watchdog tick stays bounded
even on a 100K-chunk brain. Subsequent ticks pick up the leftovers
via the edges_backfilled_at watermark.
* Test count bumped from 11 → 12 phases in cycle.serial.test.ts and
cycle.test.ts (both pinned by the regression guards). Existing 28
cycle tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.34 W3): MCP-expose code_callers / code_callees / code_def / code_refs
Pre-v0.34 these four code-intelligence commands lived in CLI_ONLY at
cli.ts:30 — agents calling gbrain via MCP couldn't reach them and fell
through to text search. This commit ships the agent-facing MCP surface
for v0.34 against the existing v0.20+ tree-sitter call graph; recursive
blast/flow and clusters land in subsequent commits.
* `code_callers(symbol, [limit, source_id, all_sources])` — wraps
engine.getCallersOf. Reverse view of the A1 call graph.
* `code_callees(symbol, [limit, source_id, all_sources])` — wraps
engine.getCalleesOf. Forward view.
* `code_def(symbol, [limit, lang])` — wraps findCodeDef. Returns
definition sites with file/line/snippet.
* `code_refs(symbol, [limit, lang])` — wraps findCodeRefs. Returns
every reference (comments, strings, imports, call sites).
All four are scope:'read', source-scoped by default via ctx.sourceId
(W0a contract). Per-call source_id param wins over ctx; pass '__all__'
or all_sources=true to force cross-source.
* operations-descriptions.ts: 4 new constants per the eng review D10
finding — every description carries an inline example response so
agents don't burn first-call context discovering shape. Resolver-grade
wording ("BEFORE editing any function, run code_callers...") routes
plan-mode questions straight to the right op.
* SEARCH_DESCRIPTION gains a cross-link clause pointing at the four new
ops so agents stop falling through to text search for code-symbol
questions.
Tests (11 E2E in test/e2e/code-intel-mcp-ops-pglite.test.ts):
- All four ops registered + scope:read + description pinned by constant
- All four ops have required symbol param
- code_callers / code_callees return the documented envelope shape
- Source scoping honors ctx.sourceId
- all_sources=true / source_id='__all__' force cross-source
- code_def returns the def-site snippet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(v0.33.0): agent-readable migration doc for the code-intel foundation
skills/migrations/v0.33.0.md gives existing-user upgrade guidance for the
v0.33.0 foundation pre-release (this branch's accumulated work toward
v0.34 Cathedral III):
* Source-routing fix (Codex #2) — query / two-pass now honor sourceId
* CLI source-scoping default flipped (Codex #7) — gbrain code-callers
defaults to source-scoped, --all-sources is the explicit opt-out
* MCP exposure of code-callers / code-callees / code-def / code-refs
with resolver-grade descriptions agents auto-route to
* Within-file symbol resolver runs as a new `resolve_symbol_edges`
cycle phase between extract and patterns
* Schema migration v51: edges_backfilled_at watermark + 3 composite/
partial indexes for the resolver hot path
* Verification commands the agent runs after `gbrain upgrade`
Bumps the existing-user migration ladder so the auto-update agent
(SKILLPACK Section 17) discovers + runs the v0.33.0 migration steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.33.0): bump VERSION + package.json + CHANGELOG
v0.33.0 ships the v0.34 Cathedral III foundation: MCP exposure of
code_callers / code_callees / code_def / code_refs with resolver-grade
tool descriptions, plus the source-routing fix + within-file symbol
resolver + cycle-phase wiring that v0.34's recursive blast/flow and
Leiden clusters will build on.
Full release notes in CHANGELOG.md. Trio in lockstep:
VERSION: 0.33.0
package.json: 0.33.0
CHANGELOG.md: ## [0.33.0] - 2026-05-11
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(v0.33.0): update dream-cycle phase-order assertions for resolve_symbol_edges
E2E test pinned the canonical phase sequence as a regression guard. The
v0.33.0 resolve_symbol_edges phase (added between extract and patterns)
correctly bumps the count to 12 — caught by the canonical-order test on
fresh-Postgres run, fixed by adding the new phase to EXPECTED_PHASES
and bumping the version history comment.
Both cycle.serial.test.ts and cycle.test.ts were already updated in the
W0c cycle-phase commit (6f7dbe1d); this third pin lives in
test/e2e/dream-cycle-phase-order-pglite.test.ts and was missed.
Full E2E suite now: 550 passed / 0 failed / 81 files (real Postgres on
port 5435 via Docker pgvector/pgvector:pg16).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.33.3.0): rebump from v0.33.2.0 → v0.33.3.0
User asked to ship as v0.33.3.0 instead of v0.33.2.0. Single sweep:
* VERSION + package.json bumped to 0.33.3.0
* CHANGELOG header + body rewritten to v0.33.3
* skills/migrations/v0.33.0.md → skills/migrations/v0.33.3.0.md
(migration files use the version they ship FROM; renaming aligns with
the v0.21.0.md / v0.31.0.md convention in CLAUDE.md)
* Schema migration name edges_backfilled_at_v0_33_2 →
edges_backfilled_at_v0_33_3 in src/core/migrate.ts (also bumps the
in-code identifier so the registry name matches the version)
* All v0.33.2 comment references swept to v0.33.3 in cycle.ts,
operations.ts, operations-descriptions.ts, eval.ts, symbol-resolver.ts
+ cycle test phase-history comments
* llms.txt + llms-full.txt regenerated
Trio verified:
VERSION: 0.33.3.0
package.json: 0.33.3.0
CHANGELOG.md: ## [0.33.3.0] - 2026-05-12
bun run verify clean; 90 v0.33.3-touched tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
547 lines
22 KiB
TypeScript
547 lines
22 KiB
TypeScript
/**
|
||
* Unit tests for src/core/cycle.ts — runCycle primitive.
|
||
*
|
||
* Tests use mock.module to replace each phase's library function with
|
||
* deterministic stubs. Zero fixtures, zero DB, zero network. Covers
|
||
* the dryRun × phases × lock_held × engine-null matrix.
|
||
*
|
||
* The lock primitives are tested against an in-memory PGLite engine
|
||
* so they exercise real SQL paths.
|
||
*/
|
||
|
||
import { describe, test, expect, mock, beforeEach, beforeAll, afterAll, afterEach } from 'bun:test';
|
||
import { existsSync, unlinkSync } from 'fs';
|
||
|
||
// ─── Mocks ──────────────────────────────────────────────────────────
|
||
// Track what each phase was called with so tests can assert.
|
||
|
||
let lintCalls: Array<{ target: string; fix: boolean; dryRun: boolean | undefined }> = [];
|
||
let backlinksCalls: Array<{ action: string; dir: string; dryRun: boolean | undefined }> = [];
|
||
let syncCalls: Array<{ dryRun: boolean | undefined; noPull: boolean | undefined; noExtract: boolean | undefined; sourceId: string | undefined }> = [];
|
||
let extractCalls: Array<{ mode: string; dir: string; slugs: string[] | undefined }> = [];
|
||
let embedCalls: Array<{ stale: boolean | undefined; dryRun: boolean | undefined }> = [];
|
||
let orphansCalls: number = 0;
|
||
|
||
// Mock lint
|
||
mock.module('../../src/commands/lint.ts', () => ({
|
||
runLintCore: async (opts: any) => {
|
||
lintCalls.push({ target: opts.target, fix: opts.fix, dryRun: opts.dryRun });
|
||
return { total_issues: 2, total_fixed: opts.dryRun ? 0 : 2, pages_scanned: 5 };
|
||
},
|
||
}));
|
||
|
||
// Mock backlinks
|
||
mock.module('../../src/commands/backlinks.ts', () => ({
|
||
runBacklinksCore: async (opts: any) => {
|
||
backlinksCalls.push({ action: opts.action, dir: opts.dir, dryRun: opts.dryRun });
|
||
return { action: opts.action, gaps_found: 3, fixed: opts.dryRun ? 0 : 3, pages_affected: 2, dryRun: !!opts.dryRun };
|
||
},
|
||
// keep other exports present so import doesn't error
|
||
extractEntityRefs: () => [],
|
||
extractPageTitle: () => '',
|
||
hasBacklink: () => false,
|
||
buildBacklinkEntry: () => '',
|
||
findBacklinkGaps: () => [],
|
||
fixBacklinkGaps: () => 0,
|
||
runBacklinks: async () => {},
|
||
}));
|
||
|
||
// Mock sync
|
||
mock.module('../../src/commands/sync.ts', () => ({
|
||
performSync: async (_engine: any, opts: any) => {
|
||
syncCalls.push({ dryRun: opts.dryRun, noPull: opts.noPull, noExtract: opts.noExtract, sourceId: opts.sourceId });
|
||
return {
|
||
status: opts.dryRun ? 'dry_run' : 'synced',
|
||
fromCommit: 'abcd',
|
||
toCommit: 'efgh',
|
||
added: opts.dryRun ? 0 : 4,
|
||
modified: opts.dryRun ? 0 : 2,
|
||
deleted: 0,
|
||
renamed: 0,
|
||
chunksCreated: opts.dryRun ? 0 : 10,
|
||
embedded: 0,
|
||
pagesAffected: opts.dryRun ? [] : ['a', 'b'],
|
||
};
|
||
},
|
||
runSync: async () => {},
|
||
buildSyncManifest: () => ({ added: [], modified: [], deleted: [], renamed: [] }),
|
||
isSyncable: () => true,
|
||
pathToSlug: (s: string) => s,
|
||
}));
|
||
|
||
// Mock extract
|
||
mock.module('../../src/commands/extract.ts', () => ({
|
||
runExtractCore: async (_engine: any, opts: any) => {
|
||
extractCalls.push({ mode: opts.mode, dir: opts.dir, slugs: opts.slugs });
|
||
return { links_created: 7, timeline_entries_created: 3, pages_processed: opts.slugs?.length ?? 5 };
|
||
},
|
||
walkMarkdownFiles: () => [],
|
||
extractMarkdownLinks: () => [],
|
||
resolveSlug: () => null,
|
||
}));
|
||
|
||
// Mock embed
|
||
mock.module('../../src/commands/embed.ts', () => ({
|
||
runEmbedCore: async (_engine: any, opts: any) => {
|
||
embedCalls.push({ stale: opts.stale, dryRun: opts.dryRun });
|
||
return {
|
||
embedded: opts.dryRun ? 0 : 8,
|
||
skipped: 2,
|
||
would_embed: opts.dryRun ? 8 : 0,
|
||
total_chunks: 10,
|
||
pages_processed: 3,
|
||
dryRun: !!opts.dryRun,
|
||
};
|
||
},
|
||
runEmbed: async () => {},
|
||
}));
|
||
|
||
// Mock orphans
|
||
mock.module('../../src/commands/orphans.ts', () => ({
|
||
findOrphans: async () => {
|
||
orphansCalls++;
|
||
return {
|
||
orphans: [],
|
||
total_orphans: 1,
|
||
total_linkable: 20,
|
||
total_pages: 20,
|
||
excluded: 0,
|
||
};
|
||
},
|
||
queryOrphanPages: async () => [],
|
||
shouldExclude: () => false,
|
||
deriveDomain: () => 'root',
|
||
formatOrphansText: () => '',
|
||
}));
|
||
|
||
// Import after mocks.
|
||
const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');
|
||
const { PGLiteEngine } = await import('../../src/core/pglite-engine.ts');
|
||
|
||
// Shared PGLite engine per describe block. Each block does its own
|
||
// beforeAll/afterAll (below). `truncateCycleLocks` clears the cycle
|
||
// lock row between tests so state doesn't leak across assertions.
|
||
async function truncateCycleLocks(engine: InstanceType<typeof PGLiteEngine>) {
|
||
await (sharedEngine as any).db.query('DELETE FROM gbrain_cycle_locks');
|
||
}
|
||
|
||
// One shared PGLite engine for the whole file. Creating a fresh engine
|
||
// per describe (15 migrations each) was causing the parallel test suite
|
||
// to hit beforeAll timeouts. truncateCycleLocks between tests keeps
|
||
// state clean.
|
||
let sharedEngine: InstanceType<typeof PGLiteEngine>;
|
||
|
||
beforeAll(async () => {
|
||
sharedEngine = new PGLiteEngine();
|
||
await sharedEngine.connect({});
|
||
await sharedEngine.initSchema();
|
||
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
||
|
||
afterAll(async () => {
|
||
if (sharedEngine) await sharedEngine.disconnect();
|
||
}, 60_000);
|
||
|
||
beforeEach(() => {
|
||
lintCalls = [];
|
||
backlinksCalls = [];
|
||
syncCalls = [];
|
||
extractCalls = [];
|
||
embedCalls = [];
|
||
orphansCalls = 0;
|
||
});
|
||
|
||
// ─── dryRun propagation (regression guards) ────────────────────────
|
||
|
||
describe('runCycle — dryRun propagates to every phase', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
});
|
||
|
||
test('dryRun:true reaches lint, backlinks, sync, embed', async () => {
|
||
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: true });
|
||
|
||
expect(lintCalls.at(-1)?.dryRun).toBe(true);
|
||
expect(backlinksCalls.at(-1)?.dryRun).toBe(true);
|
||
expect(syncCalls.at(-1)?.dryRun).toBe(true);
|
||
expect(embedCalls.at(-1)?.dryRun).toBe(true);
|
||
});
|
||
|
||
test('dryRun:false writes in every phase', async () => {
|
||
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: false });
|
||
|
||
expect(lintCalls.at(-1)?.dryRun).toBe(false);
|
||
expect(backlinksCalls.at(-1)?.dryRun).toBe(false);
|
||
expect(syncCalls.at(-1)?.dryRun).toBe(false);
|
||
expect(embedCalls.at(-1)?.dryRun).toBe(false);
|
||
});
|
||
|
||
test('dryRun skips extract phase (no dry-run support)', async () => {
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: true });
|
||
expect(extractCalls.length).toBe(0);
|
||
const extractPhase = report.phases.find(p => p.phase === 'extract');
|
||
expect(extractPhase?.status).toBe('skipped');
|
||
expect(extractPhase?.details.reason).toBe('no_dry_run_support');
|
||
});
|
||
});
|
||
|
||
// ─── Phase selection ──────────────────────────────────────────────
|
||
|
||
describe('runCycle — phase selection', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
});
|
||
|
||
test('default: all 6 phases run in order', async () => {
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
|
||
expect(report.phases.map(p => p.phase)).toEqual(ALL_PHASES);
|
||
});
|
||
|
||
test('--phase lint only runs lint', async () => {
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['lint'] });
|
||
expect(report.phases.map(p => p.phase)).toEqual(['lint']);
|
||
expect(lintCalls.length).toBe(1);
|
||
expect(backlinksCalls.length).toBe(0);
|
||
expect(syncCalls.length).toBe(0);
|
||
});
|
||
|
||
test('--phase orphans only runs orphans', async () => {
|
||
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['orphans'] });
|
||
expect(orphansCalls).toBe(1);
|
||
expect(syncCalls.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ─── Lock-skip for non-DB-write phase selections ──────────────────
|
||
|
||
describe('runCycle — cycle lock acquire/release semantics', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
});
|
||
|
||
test('phases: [orphans] (read-only) skips the lock entirely', async () => {
|
||
// We can tell the lock wasn't acquired because the lock table is
|
||
// never written to. Seeding a stale holder and verifying it survives
|
||
// the run would also work, but a simpler assertion: no rows ever
|
||
// existed for a read-only-only selection.
|
||
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['orphans'] });
|
||
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
|
||
expect(rows[0].n).toBe(0);
|
||
});
|
||
|
||
test('phases including lint DOES acquire + release (table empty after run)', async () => {
|
||
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['lint'] });
|
||
// Lock is released in finally, so no rows survive the run.
|
||
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
|
||
expect(rows[0].n).toBe(0);
|
||
});
|
||
|
||
test('phases including sync DOES acquire + release the lock', async () => {
|
||
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['sync'] });
|
||
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
|
||
expect(rows[0].n).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ─── Lock held by another live holder ──────────────────────────────
|
||
|
||
describe('runCycle — cycle_already_running skip', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
});
|
||
|
||
test('returns status=skipped when lock is held by live pid in the future', async () => {
|
||
// Seed a lock row that looks live (far-future TTL, different PID).
|
||
await (sharedEngine as any).db.query(
|
||
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
||
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`
|
||
);
|
||
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
|
||
|
||
expect(report.status).toBe('skipped');
|
||
expect(report.reason).toBe('cycle_already_running');
|
||
expect(report.phases.length).toBe(0);
|
||
// None of the phase runners were called.
|
||
expect(lintCalls.length).toBe(0);
|
||
expect(syncCalls.length).toBe(0);
|
||
});
|
||
|
||
test('TTL-expired lock is auto-claimed (crashed holder)', async () => {
|
||
// Seed a lock row that looks stale (TTL already past).
|
||
await (sharedEngine as any).db.query(
|
||
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
||
VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`
|
||
);
|
||
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
|
||
|
||
expect(report.status).not.toBe('skipped');
|
||
expect(syncCalls.length).toBe(1); // cycle ran
|
||
});
|
||
});
|
||
|
||
// ─── Engine null path ─────────────────────────────────────────────
|
||
|
||
describe('runCycle — engine = null (filesystem-only mode)', () => {
|
||
const lockFile = require('path').join(require('os').homedir(), '.gbrain', 'cycle.lock');
|
||
|
||
afterEach(() => {
|
||
if (existsSync(lockFile)) { try { unlinkSync(lockFile); } catch { /* */ } }
|
||
});
|
||
|
||
test('filesystem phases still run when engine is null', async () => {
|
||
const report = await runCycle(null, { brainDir: '/tmp/brain' });
|
||
|
||
// Lint and backlinks ran.
|
||
expect(lintCalls.length).toBe(1);
|
||
expect(backlinksCalls.length).toBe(1);
|
||
// DB phases skipped with reason:no_database.
|
||
const syncPhase = report.phases.find(p => p.phase === 'sync');
|
||
expect(syncPhase?.status).toBe('skipped');
|
||
expect(syncPhase?.details.reason).toBe('no_database');
|
||
const embedPhase = report.phases.find(p => p.phase === 'embed');
|
||
expect(embedPhase?.status).toBe('skipped');
|
||
// syncCalls + embedCalls are empty because DB-required phases skipped.
|
||
expect(syncCalls.length).toBe(0);
|
||
expect(embedCalls.length).toBe(0);
|
||
});
|
||
|
||
test('file lock blocks concurrent engine=null cycles', async () => {
|
||
// Seed a lock file pointing at PID 1 (init/launchd — always alive on
|
||
// unix, and never equals our test PID). Fresh mtime means "live holder".
|
||
// With engine=null + the default phases selection, lint + backlinks
|
||
// trigger NEEDS_LOCK_PHASES → acquireFileLock sees the live holder and
|
||
// returns null → runCycle returns skipped/cycle_already_running.
|
||
const { writeFileSync, mkdirSync } = require('fs');
|
||
const path = require('path');
|
||
mkdirSync(path.dirname(lockFile), { recursive: true });
|
||
writeFileSync(lockFile, `1\n${new Date().toISOString()}\n`);
|
||
|
||
const report = await runCycle(null, { brainDir: '/tmp/brain' });
|
||
expect(report.status).toBe('skipped');
|
||
expect(report.reason).toBe('cycle_already_running');
|
||
// None of the filesystem phases ran because the lock blocked entry.
|
||
expect(lintCalls.length).toBe(0);
|
||
expect(backlinksCalls.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ─── Status derivation ─────────────────────────────────────────────
|
||
|
||
describe('runCycle — status derivation', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
});
|
||
|
||
test('ok when work was done (non-dry-run)', async () => {
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
|
||
expect(['ok', 'partial']).toContain(report.status);
|
||
// Non-dry-run fixtures produce work (fixes:2, added:4 etc.), so:
|
||
expect(report.status).toBe('ok');
|
||
expect(report.totals.lint_fixes).toBe(2);
|
||
expect(report.totals.backlinks_added).toBe(3);
|
||
expect(report.totals.pages_synced).toBe(6); // added + modified from sync mock
|
||
expect(report.totals.pages_embedded).toBe(8);
|
||
expect(report.totals.orphans_found).toBe(1);
|
||
});
|
||
|
||
test('schema_version is stable at "1"', async () => {
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
|
||
expect(report.schema_version).toBe('1');
|
||
});
|
||
|
||
test('CycleReport shape includes all required top-level fields', async () => {
|
||
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
|
||
expect(report).toHaveProperty('schema_version');
|
||
expect(report).toHaveProperty('timestamp');
|
||
expect(report).toHaveProperty('duration_ms');
|
||
expect(report).toHaveProperty('status');
|
||
expect(report).toHaveProperty('brain_dir');
|
||
expect(report).toHaveProperty('phases');
|
||
expect(report).toHaveProperty('totals');
|
||
});
|
||
});
|
||
|
||
// ─── yieldBetweenPhases hook ─────────────────────────────────────
|
||
|
||
describe('runCycle — yieldBetweenPhases hook', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
});
|
||
|
||
test('hook is called between every phase', async () => {
|
||
let hookCalls = 0;
|
||
await runCycle(sharedEngine,{
|
||
brainDir: '/tmp/brain',
|
||
yieldBetweenPhases: async () => {
|
||
hookCalls++;
|
||
},
|
||
});
|
||
// v0.26.5: 9 phases (added `purge`).
|
||
// v0.29: 10 phases (added `recompute_emotional_weight`).
|
||
// v0.31: 11 phases (added `consolidate` between recompute and embed).
|
||
// v0.32.2: 12 phases (added `extract_facts` between extract and patterns).
|
||
// v0.33.3: 13 phases (added `resolve_symbol_edges` between extract_facts and patterns) → 13 yield calls.
|
||
expect(hookCalls).toBe(13);
|
||
});
|
||
|
||
test('hook exceptions do not abort the cycle', async () => {
|
||
const report = await runCycle(sharedEngine,{
|
||
brainDir: '/tmp/brain',
|
||
yieldBetweenPhases: async () => {
|
||
throw new Error('synthetic hook error');
|
||
},
|
||
});
|
||
// v0.33.3: 13 phases (v0.32.2's 12 + resolve_symbol_edges).
|
||
expect(report.phases.length).toBe(13);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Wave regression guards (#417 + Codex F2)
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
describe('runCycle — incremental extract slug propagation (#417)', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
syncCalls = [];
|
||
extractCalls = [];
|
||
});
|
||
|
||
test('cycle threads sync.pagesAffected into extract phase as the slugs argument', async () => {
|
||
// performSync mock returns pagesAffected = ['a', 'b']. The extract phase
|
||
// must receive those exact slugs, not undefined (which would trigger a full walk).
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain' });
|
||
|
||
// Sync ran once
|
||
expect(syncCalls.length).toBe(1);
|
||
// Extract ran once with the slugs from sync (not undefined)
|
||
expect(extractCalls.length).toBe(1);
|
||
expect(extractCalls[0].slugs).toEqual(['a', 'b']);
|
||
});
|
||
|
||
test('extract phase falls back to full walk when sync was skipped (slugs undefined)', async () => {
|
||
// Run only the extract phase — sync didn't run, so syncPagesAffected
|
||
// is undefined and extract should walk the full directory (slugs:undefined).
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['extract'] });
|
||
|
||
expect(syncCalls.length).toBe(0);
|
||
expect(extractCalls.length).toBe(1);
|
||
expect(extractCalls[0].slugs).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('runCycle — Codex F2: noExtract is gated on whether extract phase runs', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
syncCalls = [];
|
||
extractCalls = [];
|
||
});
|
||
|
||
test('full cycle (sync + extract): noExtract=true so sync skips inline extraction (extract phase handles it)', async () => {
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['sync', 'extract'] });
|
||
|
||
expect(syncCalls.length).toBe(1);
|
||
expect(syncCalls[0].noExtract).toBe(true); // dedupe enabled
|
||
expect(extractCalls.length).toBe(1); // extract phase ran
|
||
});
|
||
|
||
test('phases:[sync] only: noExtract=false so sync runs inline extraction (no silent extract drop)', async () => {
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['sync'] });
|
||
|
||
expect(syncCalls.length).toBe(1);
|
||
// Critical: noExtract must be false here. If it were true, the user just lost
|
||
// their extraction without any indication. This is the F2 regression guard.
|
||
expect(syncCalls[0].noExtract).toBe(false);
|
||
expect(extractCalls.length).toBe(0); // extract phase did NOT run
|
||
});
|
||
});
|
||
|
||
// ─── sourceId resolution (regression #475) ─────────────────────────
|
||
//
|
||
// Production OpenClaw deployment hit a 30+ min hang on every autopilot
|
||
// cycle because runPhaseSync was calling performSync without sourceId,
|
||
// so sync read the global config.sync.last_commit key (which had drifted
|
||
// out of git history after a force-push GC'd the commit). The per-source
|
||
// sources.last_commit anchor was valid the entire time. PR #475 added
|
||
// resolveSourceForDir() so the cycle reads the per-source anchor instead.
|
||
//
|
||
// These tests pin the resolver -> performSync(opts.sourceId) plumbing.
|
||
|
||
describe('runCycle — sourceId resolution (regression #475)', () => {
|
||
beforeEach(async () => {
|
||
await truncateCycleLocks(sharedEngine);
|
||
await (sharedEngine as any).db.query('DELETE FROM sources');
|
||
});
|
||
|
||
test('seeded sources row → performSync receives matching sourceId', async () => {
|
||
await (sharedEngine as any).db.query(
|
||
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
|
||
['default', 'default', '/tmp/brain-475-a'],
|
||
);
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-a' });
|
||
expect(syncCalls.at(-1)?.sourceId).toBe('default');
|
||
});
|
||
|
||
test('no matching sources row → performSync receives sourceId=undefined', async () => {
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-b' });
|
||
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
|
||
});
|
||
|
||
test('different brainDir than registered source → undefined (no cross-match)', async () => {
|
||
await (sharedEngine as any).db.query(
|
||
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
|
||
['other', 'other', '/some/other/brain'],
|
||
);
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-c' });
|
||
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
|
||
});
|
||
|
||
test('sources table missing (very old brain) → catch returns undefined, sync still runs', async () => {
|
||
// CRITICAL: do NOT DROP TABLE on the shared engine. initSchema() only
|
||
// re-runs PENDING migrations; once schema_version is at latest, the
|
||
// v20 migration that creates `sources` will not re-execute. Use a
|
||
// fresh one-shot engine so the shared engine isn't degraded for
|
||
// every later test in this file.
|
||
const fresh = new PGLiteEngine();
|
||
await fresh.connect({});
|
||
await fresh.initSchema();
|
||
await (fresh as any).db.query('DROP TABLE IF EXISTS sources CASCADE');
|
||
try {
|
||
await runCycle(fresh, { brainDir: '/tmp/brain-475-d' });
|
||
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
|
||
} finally {
|
||
await fresh.disconnect();
|
||
}
|
||
});
|
||
|
||
test('multiple rows with same local_path → resolver returns one matching id (non-deterministic)', async () => {
|
||
// Schema has no UNIQUE on local_path; SQL has no ORDER BY. Either id
|
||
// is acceptable; the contract is "any matching id, never null when
|
||
// matches exist." This test pins behavior so the follow-up
|
||
// UNIQUE-constraint TODO has a regression target.
|
||
await (sharedEngine as any).db.query(
|
||
`INSERT INTO sources (id, name, local_path) VALUES
|
||
('first', 'first', '/tmp/brain-475-e'),
|
||
('second', 'second', '/tmp/brain-475-e')`,
|
||
);
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-e' });
|
||
const sourceId = syncCalls.at(-1)?.sourceId;
|
||
expect(sourceId).toBeDefined();
|
||
expect(['first', 'second']).toContain(sourceId as string);
|
||
});
|
||
|
||
test('empty-string id row → resolver propagates as "" (defensive)', async () => {
|
||
// Schema has id as PRIMARY KEY (NOT NULL), so NULL id can't happen.
|
||
// Empty string CAN be inserted, and the resolver's `rows[0]?.id`
|
||
// would treat any falsy id as "no source" via the optional chain.
|
||
// This test pins the current behavior (we DO pass '' through to
|
||
// performSync) so a future refactor doesn't silently regress it.
|
||
await (sharedEngine as any).db.query(
|
||
`INSERT INTO sources (id, name, local_path) VALUES ('', 'empty', '/tmp/brain-475-f')`,
|
||
);
|
||
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-f' });
|
||
expect(syncCalls.at(-1)?.sourceId).toBe('');
|
||
});
|
||
});
|