mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(multi-source): thread ctx.sourceId through op handlers + engine read-surface
Closes the multi-source threading gaps that the v0.31.1.1-fixwave codex
review caught. Multi-source brains were silently misrouting writes from
every CLI/MCP-driven op (put_page, add_tag, add_link, add_timeline_entry,
revert_version, put_raw_data, etc.) because the op handlers in
operations.ts ignored ctx.sourceId. Read-side ops were arbitrary-row
under same-slug-across-sources because the engine's read methods had no
source filter.
Engine layer (D12 + D16 + D21):
- engine.ts interface: getLinks/getBacklinks/getTimeline/getRawData/
getVersions/getAllSlugs/revertToVersion/putRawData all take
opts?: { sourceId?: string }.
- pglite-engine.ts + postgres-engine.ts: two-branch query for each
read method. Without opts.sourceId, NO source filter applies
(preserves pre-v0.31.8 cross-source semantics for back-link
validators and any caller that hasn't threaded sourceId yet). With
opts.sourceId, scoped to that source — the new path used by
reconcileLinks and ctx.sourceId-aware op handlers.
Op-handler layer (D7 + D16 + D20):
- operations.ts threads ctx.sourceId through 16+ handler sites:
put_page, revert_version, put_raw_data, add_tag, remove_tag,
add_link, remove_link, add_timeline_entry, create_version,
delete_page, restore_page, get_page, get_tags, get_links,
get_backlinks, get_timeline, get_versions, get_raw_data,
get_chunks, plus reconcileLinks's tx.getLinks/getBacklinks/
addLink/removeLink and engine.getAllSlugs.
- Pattern: const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
When ctx.sourceId is unset, engine falls through to cross-source
view (back-compat). MCP callers populate ctx.sourceId via the
transport layer.
CLI wiring (D11 + D22):
- cli.ts: makeContext is async, calls resolveSourceId() from
src/core/source-resolver.ts:58 (the canonical 6-tier chain:
--source flag → GBRAIN_SOURCE env → .gbrain-source dotfile →
path-match → brain default → 'default'). Wrapped in try/catch
so a fresh pre-init brain still returns a clean ctx with no
sourceId set.
- commands/call.ts: runCall accepts --source <id> flag. Resolves
through the same 6-tier chain and threads to handleToolCall
via the new opts.sourceId param.
- mcp/server.ts: handleToolCall accepts opts.sourceId and threads
to buildOperationContext.
Tests (D7 + D16 + D20 regression coverage):
- test/source-id-tx-regression.test.ts: 8 new op-handler-layer
cases covering add_tag/get_tags/add_link/get_links/delete_page/
put_raw_data routing under ctx.sourceId='X' vs unset, plus
D16's two-branch back-compat invariant for getLinks (cross-
source view preserved when ctx.sourceId is unset).
Closes the codex OV-1/OV-2/OV-3 findings from the v0.31.8 plan
review. Back-compat is strictly additive: callers that don't pass
opts.sourceId see the same results they did pre-v0.31.8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(doctor): multi_source_drift check surfaces pre-v0.30.3 misroutes
Pre-v0.30.3 putPage misrouted multi-source writes from intended source X
to (default, slug). The fix-wave fixed forward-going writes but explicitly
deferred backfilling the misrouted rows. Operators have had no signal of
this silent corruption.
Adds src/core/multi-source-drift.ts exporting findMisroutedPages(engine,
sources, opts). The heuristic walks each non-default source's local_path
and surfaces slugs that exist at (default, slug) in DB but are MISSING
from (X, slug) — unambiguous evidence of the misroute shape.
Implementation notes (codex OV12 + OV13 + D17):
- FS walk handles BOTH .md and .mdx (matches src/core/sync.ts:133, which
treats both as markdown). Walks own helper instead of importing from
extract.ts so doctor doesn't crash if local_path is unreadable
(try/catch on root statSync; ENOENT/EACCES yields zero files, NOT a
thrown error that takes down doctor).
- Single batched SQL with VALUES clause: collect all candidate slugs
into one array, then ONE LEFT JOIN against pages with source_id IN
('default', X). Materialize into Map<slug, Set<source_id>>. NOT a
per-file 20K-round-trip loop.
- Bounded by limit (10K files) AND timeoutMs (5s). Bail with
walk_truncated=true rather than letting doctor hang.
- Heuristic softened per OV12: "appears misrouted to default" with TWO
possible causes flagged (pre-v0.30.3 misroute OR source X never
completed initial sync). The doctor warning suggests verification
('gbrain sources status'), not a destructive action.
Wired into runDoctor (3b-multi-source slot, after sync_failures) AND
into doctorReportRemote (D14) so thin-client operators see the check
when 'gbrain doctor' routes through the remote MCP path. Single-source
brains skip the check entirely.
Tests: test/multi-source-drift.test.ts (7 PGLite cases) covers:
- Single-source brain → skip
- Multi-source no-misroutes → ok
- Multi-source 2 misrouted slugs → warn with sample
- Healthy same-slug-across-sources NOT a false positive (the codex
OV4 redesign case — original heuristic would have false-positived)
- FS walk hits limit → walk_truncated=true
- Unreadable local_path doesn't crash
- .mdx files walked alongside .md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(doctor): wire multi_source_drift + wedge force-retry hint (D14 + D19)
Wires the new multi_source_drift check into both runDoctor (local) and
doctorReportRemote (thin-client remote MCP path), and extends the existing
minions_migration block to detect 3-consecutive-partials wedges and emit
gbrain apply-migrations --force-retry <v> hints (D19).
Pre-v0.31.8, operators wedged on v0.29.1 (or any future migration that
hits the apply-migrations runner's 3-consecutive-partials guard) got the
generic "Run: gbrain apply-migrations --yes" hint. That command refuses
to advance past the guard — so the hint was wrong. Codex OV-11 (and the
v0.31.1.1-fixwave commit message) flagged this, but the prior plan said
to delegate to apply-migrations.ts:statusForVersion(), which would have
re-opened a separate regression: the existing forward-progress override
at doctor.ts:303 (newer completion suppresses old partials) is
cross-version and statusForVersion is per-version only.
This commit extends the existing block in place rather than replacing it:
1. Keep the forward-progress override (lines 348-356) byte-identical so
installs that moved past an old v0.11 partial don't light up with
stale wedge alerts.
2. Add a 3-consecutive-partials detector after the stuck filter. Since
`stuck` already excludes forward-progress-superseded versions, the
wedge counter only fires on actual unresolved partials.
3. Branch the message:
- wedged.length > 0 → "WEDGED MIGRATION(s): <v>. Run: gbrain
apply-migrations --force-retry <v>" (chain with && for multiple)
- else if stuck.length > 0 → existing --yes hint
- else → no message
Same shape duplicated in doctorReportRemote so thin-client operators
see the right command on the brain host.
Plus the multi_source_drift wiring (D14): same heuristic from the
new src/core/multi-source-drift.ts library, called from both local and
remote doctor paths. Single-source brains skip. Engine-null guard on
the local path (--fast and DB-down branches pass null).
Tests: test/doctor.test.ts gains 4 wedge-hint regression cases:
- Both branches present in source (forward-progress override + 3-partials
detection coexisting).
- Anti-regression guard: NO `import { statusForVersion }` from
apply-migrations.ts. The prior plan would have introduced this
import; keeping it out means doctor stays decoupled from the
migration runner's per-version semantics.
- Multiple wedged versions chain force-retry calls with `&&`.
- Both branches present in doctorReportRemote (thin-client coverage,
D14).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(voyage): Content-Length pre-check + per-item base64 cap (D2 + D10)
The voyage compat fetch wrapper at gateway.ts:294 called
\`await resp.clone().json()\` BEFORE iterating embeddings. A
malicious or compromised Voyage endpoint of arbitrary size was
fully parsed into the JS heap before any size check could fire.
The original v0.31.8 plan put the cap on per-item base64 length,
which fires AFTER the JSON parse — defeating the OOM defense
entirely (codex OV8).
Two-layer fix sized at MAX_VOYAGE_RESPONSE_BYTES = 256 MB
("unambiguously not legit" rather than tight against typical
batches; voyage-3-large × 16K embeddings ≈ 200 MB raw fits within
the cap):
Layer 1 (PRIMARY) — Content-Length header pre-check, fires
BEFORE resp.clone().json(). Throws a descriptive error if the
header reports a length over the cap. The JSON.parse OOM vector
is now gated.
Layer 2 (defense-in-depth) — per-embedding base64 length check
inside the iteration. Catches the rare case where Layer 1 was
skipped (chunked transfer encoding has no Content-Length) AND a
single embedding string is unreasonably large. Estimates decoded
size as 0.75 × base64 length (canonical base64 → bytes ratio).
Tests: test/voyage-response-cap.test.ts — 5 structural source-pin
cases including the critical D10 invariant: "Content-Length
pre-check appears BEFORE \`const json: any = await
resp.clone().json()\` in the inbound block". A future refactor
that moves the cap below the JSON parse fails this test loudly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.31.8)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): exclude *.serial.test.ts from sharded parallel run
scripts/test-shard.sh (the GitHub Actions runner) was including
*.serial.test.ts files alongside regular tests. Serial files use
top-level mock.module(...) which leaks across files in the same Bun
process — exactly what the .serial naming convention was meant to
quarantine.
Concretely: test/eval-takes-quality-runner.serial.test.ts mocks
src/core/ai/gateway.ts with `configureGateway: () => undefined`
(no-op). Because both files landed in shard 2, the mock leaked into
test/voyage-multimodal.test.ts: when its tests called
configureVoyageMultimodal() → configureGateway(), the no-op fired and
_config stayed null. Then embedMultimodal() called requireConfig()
which threw "AI gateway is not configured" — 18 tests failed at
gateway.ts:171 with [1.00ms] each.
Local fast loop (scripts/run-unit-shard.sh) already excludes
*.serial.test.ts AND *.slow.test.ts via the same find-arg pattern.
test-shard.sh just hadn't picked up the same exclusion when it was
written. This commit:
1. Mirrors run-unit-shard.sh's exclusion pattern in test-shard.sh
(`-not -name '*.slow.test.ts' -not -name '*.serial.test.ts'`).
2. Adds a "Run *.serial.test.ts" step to .github/workflows/test.yml
on shard 1 only, calling scripts/run-serial-tests.sh
(--max-concurrency=1). Shard 1 already runs extra setup work
(`bun run verify`), so it has the natural slot for the serial
pass without slowing the parallel critical path.
Verified locally: shard 2 went from 18 voyage-multimodal failures to
0. Shard 2 file count: 81 → 78 (3 serial files removed). Total test
count after fix: 1438 (1437 pass + 1 pre-existing env-sensitive
warm-create speed gate flake — unrelated to v0.31.8 or this fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
428 lines
21 KiB
TypeScript
428 lines
21 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
|
|
describe('doctor command', () => {
|
|
test('doctor module exports runDoctor', async () => {
|
|
const { runDoctor } = await import('../src/commands/doctor.ts');
|
|
expect(typeof runDoctor).toBe('function');
|
|
});
|
|
|
|
test('LATEST_VERSION is importable from migrate', async () => {
|
|
const { LATEST_VERSION } = await import('../src/core/migrate.ts');
|
|
expect(typeof LATEST_VERSION).toBe('number');
|
|
});
|
|
|
|
test('CLI registers doctor command', async () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', '--help'],
|
|
cwd: import.meta.dir + '/..',
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(stdout).toContain('doctor');
|
|
expect(stdout).toContain('--fast');
|
|
});
|
|
|
|
test('frontmatter_integrity subcheck added in v0.22.4', async () => {
|
|
const fs = await import('fs');
|
|
const src = fs.readFileSync('src/commands/doctor.ts', 'utf8');
|
|
// Subcheck name and call into shared scanner are present.
|
|
expect(src).toContain("name: 'frontmatter_integrity'");
|
|
expect(src).toContain('scanBrainSources');
|
|
// Fix hint points at the right CLI command.
|
|
expect(src).toContain('gbrain frontmatter validate');
|
|
});
|
|
|
|
test('Check interface supports issues array', async () => {
|
|
// `Check` is a TypeScript interface — type-only, no runtime value.
|
|
// Importing it for type assertion is enough to validate the shape.
|
|
const check: import('../src/commands/doctor.ts').Check = {
|
|
name: 'resolver_health',
|
|
status: 'warn',
|
|
message: '2 issues',
|
|
issues: [{ type: 'unreachable', skill: 'test-skill', action: 'Add trigger row' }],
|
|
};
|
|
expect(check.issues).toHaveLength(1);
|
|
expect(check.issues![0].action).toContain('trigger');
|
|
});
|
|
|
|
test('runDoctor accepts null engine for filesystem-only mode', async () => {
|
|
const { runDoctor } = await import('../src/commands/doctor.ts');
|
|
// runDoctor should accept null engine — it runs filesystem checks only.
|
|
// Signature is (engine, args, dbSource?) — third param is optional and
|
|
// used by --fast to distinguish "no config" from "user skipped DB check".
|
|
// Function.length counts required params only (JS ignores ?-marked).
|
|
expect(runDoctor.length).toBeGreaterThanOrEqual(2);
|
|
expect(runDoctor.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
// Bug 7 — --fast should differentiate "no config anywhere" from "user
|
|
// chose --fast with GBRAIN_DATABASE_URL / config-file URL present".
|
|
test('getDbUrlSource reflects GBRAIN_DATABASE_URL env var', async () => {
|
|
const { getDbUrlSource } = await import('../src/core/config.ts');
|
|
const orig = process.env.GBRAIN_DATABASE_URL;
|
|
const origAlt = process.env.DATABASE_URL;
|
|
try {
|
|
process.env.GBRAIN_DATABASE_URL = 'postgresql://test@localhost/x';
|
|
expect(getDbUrlSource()).toBe('env:GBRAIN_DATABASE_URL');
|
|
delete process.env.GBRAIN_DATABASE_URL;
|
|
process.env.DATABASE_URL = 'postgresql://test@localhost/x';
|
|
expect(getDbUrlSource()).toBe('env:DATABASE_URL');
|
|
} finally {
|
|
if (orig === undefined) delete process.env.GBRAIN_DATABASE_URL;
|
|
else process.env.GBRAIN_DATABASE_URL = orig;
|
|
if (origAlt === undefined) delete process.env.DATABASE_URL;
|
|
else process.env.DATABASE_URL = origAlt;
|
|
}
|
|
});
|
|
|
|
test('doctor --fast emits source-specific message when URL present', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
// The source-aware message must reference the variable name so users
|
|
// know where their URL is coming from.
|
|
expect(source).toContain('Skipping DB checks (--fast mode, URL present from');
|
|
// The null-source fallback must still mention both config + env paths.
|
|
expect(source).toContain('GBRAIN_DATABASE_URL');
|
|
});
|
|
|
|
// v0.12.2 reliability wave — doctor detects JSONB double-encode + truncated
|
|
// bodies and points users at the standalone `gbrain repair-jsonb` command.
|
|
// Detection only; repair lives in src/commands/repair-jsonb.ts.
|
|
test('doctor source contains jsonb_integrity and markdown_body_completeness checks', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
expect(source).toContain('jsonb_integrity');
|
|
expect(source).toContain('markdown_body_completeness');
|
|
expect(source).toContain('gbrain repair-jsonb');
|
|
});
|
|
|
|
test('jsonb_integrity check covers the four JSONB sites fixed in v0.12.1', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
expect(source).toMatch(/table:\s*'pages'.*col:\s*'frontmatter'/);
|
|
expect(source).toMatch(/table:\s*'raw_data'.*col:\s*'data'/);
|
|
expect(source).toMatch(/table:\s*'ingest_log'.*col:\s*'pages_updated'/);
|
|
expect(source).toMatch(/table:\s*'files'.*col:\s*'metadata'/);
|
|
});
|
|
|
|
// v0.31.2 — facts_extraction_health check added in PR1 commit 12.
|
|
// Reads ingest_log rows with source_type='facts:absorb' (written by
|
|
// writeFactsAbsorbLog from src/core/facts/absorb-log.ts), groups by
|
|
// (source_id, reason) over the last 24h, warns when any (source, reason)
|
|
// pair exceeds the configurable threshold (facts.absorb_warn_threshold,
|
|
// default 10).
|
|
test('doctor source contains facts_extraction_health check that iterates sources', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
expect(source).toContain('facts_extraction_health');
|
|
// The check must group by source_id, not hardcode 'default'.
|
|
const block = source.slice(
|
|
source.indexOf('// 11a-bis-2. facts_extraction_health'),
|
|
source.indexOf('// 11a-2. effective_date_health'),
|
|
);
|
|
expect(block.length).toBeGreaterThan(0);
|
|
expect(block).toContain('GROUP BY source_id');
|
|
expect(block).toContain("source_type = 'facts:absorb'");
|
|
expect(block).toContain('facts.absorb_warn_threshold');
|
|
// 24h window
|
|
expect(block).toMatch(/INTERVAL\s+'24\s*hours?'/i);
|
|
// Pre-v47 fallback (column missing) reports skipped not warn
|
|
expect(block).toContain("Skipped (ingest_log.source_id unavailable");
|
|
// RLS deny gives a useful message
|
|
expect(block).toContain('RLS denies SELECT on ingest_log');
|
|
// Negative: must NOT hardcode 'default' as the only source
|
|
expect(block).not.toMatch(/source_id\s*=\s*'default'/);
|
|
});
|
|
|
|
// v0.18 RLS hardening — regression guards for PR #336 + schema backfill.
|
|
// These are structural assertions on the source string so a silent revert
|
|
// of the severity or the IN-filter removal fails loudly without a live DB.
|
|
test('RLS check scans ALL public tables (no hardcoded tablename IN list near the RLS block)', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
expect(rlsBlock.length).toBeGreaterThan(0);
|
|
// Old pattern — must not come back. If it does, we're filtering the scan
|
|
// to a hardcoded set and every plugin/user table is invisible again.
|
|
expect(rlsBlock).not.toMatch(/tablename\s+IN\s*\(/);
|
|
// New semantics: the scan query has no WHERE-IN filter, just schemaname='public'.
|
|
expect(rlsBlock).toMatch(/FROM\s+pg_tables\b[\s\S]{0,200}schemaname\s*=\s*'public'/);
|
|
});
|
|
|
|
test('RLS check raises status=fail with quoted-identifier remediation SQL', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
// Severity upgraded from 'warn' to 'fail' so `gbrain doctor` exits 1 on gaps.
|
|
expect(rlsBlock).toMatch(/status:\s*'fail'/);
|
|
// Remediation SQL uses quoted identifiers — safe for names with hyphens,
|
|
// reserved words, mixed case.
|
|
expect(rlsBlock).toContain('ALTER TABLE "public"."');
|
|
expect(rlsBlock).toContain('ENABLE ROW LEVEL SECURITY');
|
|
});
|
|
|
|
test('RLS check skips on PGLite (no PostgREST, not applicable)', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
expect(rlsBlock).toMatch(/engine\.kind\s*===\s*'pglite'/);
|
|
expect(rlsBlock).toContain('PGLite');
|
|
});
|
|
|
|
test('RLS check reads pg_description and recognizes the GBRAIN:RLS_EXEMPT escape hatch', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const rlsBlock = source.slice(
|
|
source.indexOf('// 5. RLS'),
|
|
source.indexOf('// 6. Schema version'),
|
|
);
|
|
expect(rlsBlock).toContain('obj_description');
|
|
expect(rlsBlock).toContain('GBRAIN:RLS_EXEMPT');
|
|
// The regex must require a non-empty reason= segment. "Blood" is in the
|
|
// requirement to write a real justification, not just the prefix.
|
|
expect(rlsBlock).toMatch(/reason=/);
|
|
});
|
|
|
|
// v0.26.7 — rls_event_trigger check (post-install drift detector for v35).
|
|
// Lives AFTER `// 6. Schema version` so the existing `// 5. RLS` slice
|
|
// tests stay intact (codex correction).
|
|
test('rls_event_trigger check exists, scoped after schema_version, healthy on (O,A) only', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
const idx7 = source.indexOf('// 7. RLS event trigger');
|
|
const idx8 = source.indexOf('// 8. Embedding health');
|
|
expect(idx7).toBeGreaterThan(0);
|
|
expect(idx8).toBeGreaterThan(idx7);
|
|
const block = source.slice(idx7, idx8);
|
|
expect(block).toContain("name: 'rls_event_trigger'");
|
|
// Healthy set is origin (`O`) or always (`A`). `R` is replica-only and
|
|
// would not fire in normal sessions; `D` is disabled. Both are warn states.
|
|
expect(block).toMatch(/evtenabled\s*!==\s*'O'[\s\S]*?evtenabled\s*!==\s*'A'/);
|
|
// PGLite skip path is required (no event triggers there).
|
|
expect(block).toMatch(/engine\.kind\s*===\s*'pglite'/);
|
|
// Recovery command names the migration version explicitly.
|
|
expect(block).toContain('--force-retry 35');
|
|
});
|
|
|
|
// v0.31.7 IRON-RULE regression test for #376 + #536.
|
|
// The graph_coverage WARN message used to suggest stale verbs (`gbrain
|
|
// link-extract` / `gbrain timeline-extract`) that were removed in v0.16
|
|
// when extraction was consolidated into `gbrain extract <links|timeline|all>`.
|
|
// PR #376 (FUSED-ID) flagged the stale hint; PR #536 (mayazbay) replaced it
|
|
// with the canonical `gbrain extract all`. Pin the user-facing copy so a
|
|
// future edit can't silently re-regress to a stale verb.
|
|
test('graph_coverage hint uses canonical `gbrain extract all`, not removed verbs', async () => {
|
|
const fs = await import('fs');
|
|
const src = fs.readFileSync('src/commands/doctor.ts', 'utf8');
|
|
// Canonical form (post-v0.16 single-verb consolidation).
|
|
expect(src).toContain('Run: gbrain extract all');
|
|
// Stale verb names removed in v0.16 must not return.
|
|
expect(src).not.toContain('gbrain link-extract');
|
|
expect(src).not.toContain('gbrain timeline-extract');
|
|
});
|
|
|
|
// v0.32 — takes_weight_grid pure-helper export.
|
|
// Codex review #7 demanded the check be extracted as a pure function so
|
|
// tests target it directly with stubbed engines instead of running the
|
|
// full runDoctor pipeline. This block validates the export shape and the
|
|
// 4 branches (no-takes / fail / warn / ok) behaviorally against PGLite.
|
|
test('takesWeightGridCheck is exported as a pure function', async () => {
|
|
const mod = await import('../src/commands/doctor.ts');
|
|
expect(typeof mod.takesWeightGridCheck).toBe('function');
|
|
});
|
|
|
|
test('takes_weight_grid: 0 takes → ok with "No takes yet"', async () => {
|
|
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
|
|
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
try {
|
|
const result = await takesWeightGridCheck(engine);
|
|
expect(result.name).toBe('takes_weight_grid');
|
|
expect(result.status).toBe('ok');
|
|
expect(result.message).toContain('No takes yet');
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
});
|
|
|
|
test('takes_weight_grid: 100% on-grid → ok', async () => {
|
|
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
|
|
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
try {
|
|
// Seed a few on-grid takes via the engine's normalized path.
|
|
await engine.putPage('test/doc-on-grid', {
|
|
type: 'note', title: 't', compiled_truth: 'b', frontmatter: {},
|
|
});
|
|
const pageRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = 'test/doc-on-grid' LIMIT 1`,
|
|
);
|
|
await engine.addTakesBatch([
|
|
{ page_id: pageRows[0].id, row_num: 1, claim: 'a', kind: 'take', holder: 'world', weight: 0.75 },
|
|
{ page_id: pageRows[0].id, row_num: 2, claim: 'b', kind: 'take', holder: 'world', weight: 0.5 },
|
|
{ page_id: pageRows[0].id, row_num: 3, claim: 'c', kind: 'take', holder: 'world', weight: 1.0 },
|
|
]);
|
|
const result = await takesWeightGridCheck(engine);
|
|
expect(result.status).toBe('ok');
|
|
expect(result.message).toContain('on grid');
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
});
|
|
|
|
test('takes_weight_grid: >10% off-grid → fail with fix hint', async () => {
|
|
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
|
|
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
try {
|
|
await engine.putPage('test/doc-fail', {
|
|
type: 'note', title: 't', compiled_truth: 'b', frontmatter: {},
|
|
});
|
|
const pageRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = 'test/doc-fail' LIMIT 1`,
|
|
);
|
|
// Bypass engine normalization: write off-grid weights directly.
|
|
// 8 of 10 off-grid → 80%, well past the 10% fail threshold.
|
|
for (let i = 1; i <= 8; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
|
|
VALUES ($1, $2, 'c', 'take', 'world', $3::real, true)`,
|
|
[pageRows[0].id, i, 0.74],
|
|
);
|
|
}
|
|
for (let i = 9; i <= 10; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
|
|
VALUES ($1, $2, 'c', 'take', 'world', 0.5::real, true)`,
|
|
[pageRows[0].id, i],
|
|
);
|
|
}
|
|
const result = await takesWeightGridCheck(engine);
|
|
expect(result.status).toBe('fail');
|
|
expect(result.message).toMatch(/8\/10/);
|
|
expect(result.message).toContain('apply-migrations');
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
});
|
|
|
|
test('takes_weight_grid: 1-10% off-grid → warn', async () => {
|
|
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
|
|
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
try {
|
|
await engine.putPage('test/doc-warn', {
|
|
type: 'note', title: 't', compiled_truth: 'b', frontmatter: {},
|
|
});
|
|
const pageRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = 'test/doc-warn' LIMIT 1`,
|
|
);
|
|
// 5 off-grid out of 100 = 5% → warn band.
|
|
for (let i = 1; i <= 5; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
|
|
VALUES ($1, $2, 'c', 'take', 'world', 0.74::real, true)`,
|
|
[pageRows[0].id, i],
|
|
);
|
|
}
|
|
for (let i = 6; i <= 100; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
|
|
VALUES ($1, $2, 'c', 'take', 'world', 0.5::real, true)`,
|
|
[pageRows[0].id, i],
|
|
);
|
|
}
|
|
const result = await takesWeightGridCheck(engine);
|
|
expect(result.status).toBe('warn');
|
|
expect(result.message).toMatch(/5\/100/);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
});
|
|
|
|
test('takes_weight_grid: takes table missing → warn (graceful)', async () => {
|
|
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
|
|
// Stub engine: executeRaw throws like a "relation does not exist" error.
|
|
const stubEngine = {
|
|
executeRaw: async () => {
|
|
throw new Error('relation "takes" does not exist');
|
|
},
|
|
} as any;
|
|
const result = await takesWeightGridCheck(stubEngine);
|
|
expect(result.status).toBe('warn');
|
|
expect(result.message).toContain('Could not check takes weight grid');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// v0.31.8 D19 — wedge migration force-retry hint.
|
|
//
|
|
// The pre-v0.31.8 minions_migration check emitted a generic
|
|
// `gbrain apply-migrations --yes` hint regardless of how partial the
|
|
// migration was. Operators wedged on v0.29.1 (3 consecutive partials)
|
|
// needed `--force-retry <v>` first because the apply-migrations runner's
|
|
// 3-consecutive-partials guard rejected plain --yes. The v0.31.8 fix
|
|
// extends the existing block in place: detect the wedge condition,
|
|
// emit the force-retry hint when matched, fall back to the plain --yes
|
|
// hint when the partial count is < 3.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
describe('v0.31.8 — wedge migration force-retry hint (D19)', () => {
|
|
test('local doctor source contains wedge detection alongside the existing stuck path', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
// The existing forward-progress override stays intact. Both branches
|
|
// must be present and live next to each other; replacing the override
|
|
// with statusForVersion() would re-open stale wedge alerts (codex OV11).
|
|
expect(source).toContain('Forward-progress override');
|
|
expect(source).toContain('partialCount >= 3');
|
|
// Both branches must coexist. Wedged path builds the command list with
|
|
// --force-retry; partial path falls back to plain --yes. Order varies
|
|
// between the local + remote doctor blocks, so just assert presence.
|
|
expect(source).toContain('WEDGED MIGRATION(s)');
|
|
expect(source).toContain('MINIONS HALF-INSTALLED');
|
|
expect(source).toContain('--force-retry');
|
|
expect(source).toMatch(/MINIONS HALF-INSTALLED[\s\S]{0,400}--yes/);
|
|
});
|
|
|
|
test('wedge detection is local to doctor — no statusForVersion import (D19 anti-regression)', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
// D19 explicitly chose to extend the existing block in place rather than
|
|
// import statusForVersion, because statusForVersion is per-version only
|
|
// and doesn't encode the cross-version forward-progress override. If a
|
|
// future refactor re-introduces the import this regression guard
|
|
// catches it.
|
|
expect(source).not.toMatch(/import\s*\{\s*statusForVersion\s*\}/);
|
|
expect(source).not.toMatch(/from\s*['"]\.\/apply-migrations\.ts['"]/);
|
|
});
|
|
|
|
test('multiple wedged versions chain force-retry calls with &&', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
// The local doctor block uses `.join(' && ')` so multiple wedged
|
|
// versions render as a single copy-pasteable command line. Match BOTH
|
|
// engine.ts blocks (local doctor + remote doctor) — the regex finds
|
|
// either occurrence.
|
|
expect(source).toMatch(/wedged\.map\(v\s*=>\s*`gbrain apply-migrations --force-retry [^`]+`\)\.join\(' && '\)/);
|
|
});
|
|
|
|
test('remote doctor (doctorReportRemote) also emits the force-retry hint (D14)', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
// Check that the wedge detection is duplicated in the remote doctor
|
|
// path so thin-client operators see it. Find the doctorReportRemote
|
|
// function span and verify the wedge-hint code lives inside it.
|
|
const remoteStart = source.indexOf('export async function doctorReportRemote(');
|
|
expect(remoteStart).toBeGreaterThan(0);
|
|
const remoteEnd = source.indexOf('\nexport async function runDoctor(', remoteStart);
|
|
expect(remoteEnd).toBeGreaterThan(remoteStart);
|
|
const remoteBlock = source.slice(remoteStart, remoteEnd);
|
|
expect(remoteBlock).toContain('--force-retry');
|
|
expect(remoteBlock).toContain('partialCount >= 3');
|
|
expect(remoteBlock).toMatch(/WEDGED MIGRATION\(s\) on brain host/);
|
|
});
|
|
});
|