mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(schema): migration v93 take_domain_assignments (v0.41 T1) Adds the JOIN table backing per-pack calibration domain aggregation in the v0.41 lens-packs wave. Replaces the originally-planned scalar `takes.domain` column after codex outside-voice review caught that one take can legitimately belong to multiple domains (a take about "Sequoia's investment in Anthropic" lands in deal_success AND market_call), and that scalar attribution bakes today's pack→domain mapping into permanent fact. Schema: composite PK (take_id, domain) for idempotent re-assignment, FK CASCADE so deleting a take cascades assignments, confidence CHECK in [0,1], idx_take_domain_assignments_domain for the aggregator JOIN direction. RLS guard matches takes/synthesis_evidence pattern (enable when running as BYPASSRLS role). PGLite parity via sqlFor.pglite. Backward-compat: pre-existing takes carry no assignments; aggregator LEFT JOIN skips them gracefully. No backfill required at migration time — propose_takes (T10) populates new rows; greenfield assignment of historical takes is a v0.42 follow-up. R-MIG IRON-RULE regression at test/migrations-v93.test.ts pins 12 contracts: existence/name, LATEST_VERSION advance, table queryable after initSchema, column shape, composite PK rejects duplicate (take_id, domain), multi-domain assignment permitted, FK ON DELETE CASCADE, CHECK rejects out-of-range confidence, index presence, aggregator JOIN direction returns per-domain counts, sql/sqlFor.pglite parity grep, backward-compat LEFT JOIN handles unassigned takes. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md First of 13 sequencing tasks in v0.41 lens packs + epistemology unification wave (decisions D9-B → T1-B per codex challenge). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(contracts): IngestionSource.mode + pack manifest phases/calibration_domains (v0.41 T2+T3) Two independent contract extensions, batched because both are pre- requisites for T4 (pack YAML manifests) and T9 (cycle.ts orchestrator gate). Neither is load-bearing alone; together they form the surface the four lens-pack manifests will declare against. T2 — IngestionSource.mode discriminator (codex outside-voice fix): src/core/ingestion/types.ts grows an optional `mode: 'trickle' | 'migration'` field on IngestionSource. Defaults to 'trickle' when unset — v0.38 sources unchanged. New IngestionSourceMode export. src/core/ingestion/daemon.ts handleEmit() branches on the mode: trickle keeps the 24h DedupWindow.mark() path; migration bypasses dedup entirely (the source owns permanent slug-keyed idempotency via op_checkpoint or similar). Validation, rate limit, and dispatch apply uniformly to both modes. Why: the 24h content-hash dedup window is wrong for bulk historical migration. 24K wintermute pages over hours, retries days apart, and same-hash collisions across the window are expected. Trickle semantics (file-watcher, inbox-folder, webhook) want dedup to catch at-least-once replay; migration semantics want EVERY explicitly- emitted event to land because the source already gated it. T3 — SchemaPackManifestSchema phases + calibration_domains: src/core/schema-pack/manifest-v1.ts grows two optional fields. New AGGREGATOR_KINDS closed enum (4 v1 algorithms: scalar_brier, weighted_brier, count_based, cluster_summary) backing AggregatorKind type. New CalibrationDomain {name, aggregator, page_types} schema with snake_case regex on name, .strict on extra fields, page_types.min(1). `phases: string[]` declares which cycle phases the active pack participates in (D4-B orchestrator gate; runCycle will consult this in T9). Validated as string here, against runtime CyclePhase union at the registry layer (avoids circular import). `borrow_from` does NOT borrow phases — each pack declares explicitly. `calibration_domains: CalibrationDomain[]` declares per-pack scorecard buckets. Closed registry of algorithm `aggregator` values keeps SQL injection surface closed; open `name` strings let third- party packs add domains without a gbrain release (T3 codex refinement of D6). Backward compat: both fields default to []. Existing v0.38 manifests parse unchanged (pinned by 2 regression cases). Tests: test/ingestion/migration-mode.test.ts (8 cases): mode type accepts literals, defaults to trickle, daemon branches correctly across trickle/migration/default-undefined, validation still runs in migration mode, mixed dual-source independence. test/schema-pack-manifest-v041.test.ts (19 cases): aggregator enum shape, phases default + accept + reject (non-string, empty, non- array), calibration_domains default + accept (single + multi entry, multi page_types), reject (unknown aggregator, kebab/uppercase/ digit-start names, empty page_types, unknown extra field), v0.38 back-compat regressions. All 27 cases pass first-green after API surface alignment. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Tasks T2 + T3 of 13 in v0.41 lens packs + epistemology unification wave. Unblocks: T4 (pack manifests reference both fields), T9 (cycle.ts gate reads phases:), T10 (calibration widening reads calibration_domains). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(packs): 4 bundled lens pack manifests + registry wiring (v0.41 T4) Authors gbrain-creator + gbrain-investor + gbrain-engineer + gbrain-everything as bundled YAML manifests in src/core/schema-pack/base/, registers them in the BUNDLED array in load-active.ts, exports AGGREGATOR_KINDS + AggregatorKind + CalibrationDomain types through the schema-pack barrel. gbrain-creator: atom (NEW page type) + concept (reuse from base). phases: [extract_atoms, synthesize_concepts]. One calibration domain: concept_themes / cluster_summary / [concept]. Retires wintermute's atom-pipeline-coordinator cron (T12 follow-up). gbrain-investor: thesis + bet_resolution_log (NEW). Borrows deal/person/company/yc from base. No new cycle phases (consumes existing extract_facts/propose_takes/grade_takes pipeline). Three calibration domains: deal_success/scalar_brier/[deal], founder_evaluation/scalar_brier/[person], market_call/weighted_brier /[thesis]. Filing rules mirror wintermute's existing investing/deals + investing/theses + investing/bets layout. gbrain-engineer: bridge-only per D8-C. ONLY declares `learning` page type (primitive: annotation); borrows code+project from base. No new cycle phases (gstack-learnings IngestionSource is daemon- side per T8). Three calibration domains: architecture_calls/ scalar_brier/[code, learning], effort_estimates/weighted_brier/ [project], risk_assessment/scalar_brier/[project]. gbrain-everything: meta-pack extending gbrain-investor + borrowing atom (from creator) + learning (from engineer). Codex outside-voice T4 resolution to the multi-lens problem: composes via the v0.38- shipped extends + borrow_from chain instead of inventing an active-multi-pack architecture. Single-active-pack constraint preserved. Explicitly re-declares phases + calibration_domains (borrow_from borrows types/link_types only — phases must be declared per pack per D4-B). Frontmatter validators (atom_type closed 11-value enum, virality_ score range, etc.) are NOT declared in these manifests — that contract surface (per-page-type frontmatter_validators on PageTypeSchema) is a v0.42 follow-up filed in plan TODOs. For v0.41, extract_atoms hardcodes the enum with a TODO comment pointing at the eventual manifest read path (D11). YAML parser caveat: src/core/schema-pack/loader.ts uses a hand- rolled parseYamlMini (per loader.ts:86 explicit non-support of `|` block scalars). Initial descriptions used `|` blocks and broke parsing silently (description was 'literal "|"', everything after collapsed). Reauthored to single-line "..." strings. Pinned by the manifest-load tests asserting page_types/phases/calibration_ domains all resolve. Tests: test/lens-pack-manifests.test.ts (31 cases): one file covers all 4 packs to avoid 4x boilerplate. Pins parse cleanly, registry inclusion, per-pack page_types/phases/calibration_domains/filing_ rules shape, every aggregator value falls in AGGREGATOR_KINDS, meta-pack unions correctly (7 calibration domains across all three lens packs). Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Task T4 of 13. Unblocks T5/T6 (phases now declared; phases read from active pack at runtime), T7 (importer writes atom-typed pages against creator manifest), T8 (gstack-learnings emits learning-typed pages against engineer manifest), T9 (orchestrator gate reads phases: declaration), T10 (calibration_profile walks calibration_domains). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cycle): orchestrator-level pack gate for lens-pack phases (v0.41 T9) Wires extract_atoms + synthesize_concepts into runCycle with the D4-B orchestrator-level pack gate. Five surgical edits to src/core/cycle.ts: 1. CyclePhase union grows by 2 names. 2. ALL_PHASES inserts extract_atoms after extract_facts (Haiku 3-check has fresh fact context, BEFORE resolve_symbol_edges to avoid interrupting the symbol resolution sweep mid-flight) and synthesize_concepts after patterns (cluster pass sees fresh cross-session themes). 3. PHASE_SCOPE entries: extract_atoms='source' (per-source transcript walk), synthesize_concepts='global' (concept clusters cross sources by nature). 4. NEEDS_LOCK_PHASES adds both (put_page writes mutate DB). 5. runCycle dispatch blocks for both phases consult packDeclaresPhase before invoking. When the active pack doesn't declare the phase, skipped with reason='not_in_active_pack' marker. When it does, lazy-imports extract-atoms.ts / synthesize-concepts.ts and runs. The packDeclaresPhase helper is new at module-private scope. Loads the active pack via loadActivePack({cfg, remote:false}); reads resolved.manifest.phases (local only — D4-B). Fail-open: any registry error (pack not found, malformed manifest) returns false. Skipping > crashing for an orchestrator gate. Local-only phase semantics (not extends-chain inherited) preserves user sovereignty: a downstream pack extending gbrain-creator may NOT want extract_atoms to run (e.g. derives atoms differently). Inheriting phases would force them into a no-op-or-fork choice. The gbrain-everything meta-pack therefore RE-DECLARES creator's phases verbatim in its own manifest, asserted by the T4 test. Stub phase modules ship in this commit: src/core/cycle/extract-atoms.ts → returns skipped with reason= 'stub_pending_t5' src/core/cycle/synthesize-concepts.ts → returns skipped with reason= 'stub_pending_t6' T5/T6 replace the stub bodies with real LLM-driven phases. The orchestrator dispatch is fully wired today and exercised by the test. Manifest schema follow-on: phases + calibration_domains were originally .default([]) but the type narrowing broke v0.38 fixture casts in test/schema-pack-{lint-rules,registry,registry-reload}.test.ts. Reverted to .optional(); consumers apply `?? []` at the read site. Same pattern as IngestionSource.mode in T2. Updated T3 + T4 tests to use `!` non-null assertion at sites that explicitly declared the fields (typechecker can't narrow array literals through optional boundaries). Tests: test/cycle-pack-gating.test.ts (19 cases, R-GATE IRON RULE): ALL_PHASES + PHASE_SCOPE shape, ordering invariants (extract_atoms after extract_facts, synthesize_concepts after patterns), exhaustive PHASE_SCOPE map, NEEDS_LOCK_PHASES static-source assertion (both new phases included), dispatch consults packDeclaresPhase for BOTH new phases (and ONLY those two), packDeclaresPhase helper exists + reads manifest.phases (not merged chain) + fail-open returns false on catch, pre-existing 17 phases NEVER consult packDeclaresPhase (extract_facts + calibration_profile spot-checked), not_in_active_pack reason marker appears exactly 2x (semantic consistency across both gated phases). Adjacent test fixes: T3 + T4 tests updated for optional-field semantics. T2 dispatch type narrowed to DispatchOutcome shape from daemon.ts ({kind: 'queued'} for success path). 89/89 across T1+T2+T3+T4+T9 tests pass; typecheck clean. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Task T9 of 13. Unblocks: T5 (extract-atoms.ts body replaces stub), T6 (synthesize-concepts.ts body replaces stub). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(calibration): domain_scorecards widening + 4 aggregators (v0.41 T10) Replaces the v0.36.1.0 placeholder `JSON.stringify({})` in calibration-profile.ts:336 with a real aggregator pass over the active pack's calibration_domains declarations. domain_scorecards JSONB now populates per declared domain with {n, brier, accuracy, aggregator, page_types, extras}. New module: src/core/calibration/domain-aggregators.ts - aggregateDomainScorecards(engine, holder, domains, sourceId) → JSONB-shape - 4 aggregator implementations matching the AggregatorKind closed enum: - scalar_brier: AVG(POWER(weight - outcome::int, 2)). The default for most predictive domains. Filters by holder + page_types + resolved_outcome IS NOT NULL + active=TRUE + source_id. - weighted_brier: Brier weighted by ABS(weight - 0.5) * 2 (conviction proxy since takes table has no separate confidence column). A 0.95-conviction miss weights 9x more than a 0.55-conviction one. Matches the investor pack's market_call semantics. - count_based: simple SUM(hit)/COUNT(*) accuracy without Brier. For domains where probability isn't natural. - cluster_summary: page count + tier histogram via frontmatter->>'tier' JSONB read. For concept_themes where there's no binary outcome to score. Returns {n, tier_counts: {T1, T2, T3, T4}}. Wiring in src/core/cycle/calibration-profile.ts: Try/catch wraps the loadActivePack → aggregator chain. Empty {} scorecard on any pack-resolution error (R1 IRON RULE: byte-identical v0.36.1.0 baseline when no active pack declares domains). Warning appended to result.warnings so doctor surfaces silent failures instead of crashing the phase. Per-domain fail-soft: aggregateOneDomain's try/catch returns {n: 0, brier: null, accuracy: null, extras: {error}} for any single malformed domain. The other domains still aggregate. Phase keeps running. Tests (test/domain-aggregators.test.ts, 13 cases): - R1 IRON RULE: empty domain list returns {} (byte-identical) - scalar_brier: empty no-takes returns n:0/null/null; 2-take Brier computed correctly (0.5 over (0, 1) sq_errs); accuracy matches weight>=0.5 hit/miss; filters by holder; filters by page_types; ignores unresolved takes - weighted_brier: high-conviction miss weighted 9x more; accuracy independent of conviction weighting - count_based: accuracy without Brier - cluster_summary: tier histogram from frontmatter; zero-concepts returns n:0 + all-zero tiers - Multi-domain: aggregates all declared in one call - Fail-soft per domain: nonexistent page_type produces n:0 without blocking other domains 89/89 across T1+T2+T3+T4+T9+T10 tests; typecheck clean. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Task T10 of 13. The propose_takes-side wiring (populate take_domain_assignments at write time from active pack's page_type→ domain mapping) is deferred to T5/T6 phase implementations, since they are the natural producers of takes. Manual propose_takes via fence write covers the operator path. v0.42+ adds a takes-fence parser extension to read domain[] from fence rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ingestion): gstack-learnings bridge source (v0.41 T8) Implements GstackLearningsSource — the daemon-side IngestionSource that watches ~/.gstack/projects/{repo}/learnings.jsonl and emits each new line as a `learning`-typed IngestionEvent. Closes the v0.40-and-earlier gap where gstack's typed engineering knowledge base (7 learning types: pattern, pitfall, preference, architecture, tool, operational, investigation) lived in JSONL files the brain never queried. After T8 + the engineer-pack manifest activation, every gstack-logged learning surfaces as a first-class gbrain page within seconds of being written. Lifecycle: - constructor: discovers JSONL files via ~/.gstack/projects/*/ learnings.jsonl (cross-project mode, default) or just the current project (per-project mode). Test seam: _readFile/_existsSync/_skipWatch. - start(ctx): seeds seenLines with content_hashes of EVERY existing line so first-run-after-install does NOT replay thousands of historical lines as fresh emits. Then installs fs.watch handlers (one per discovered file) that fire rescanFile on 'change'. - rescanFile: O(N) per change event; re-reads the whole file, canonical-JSON content_hash on each line, emits any line not in seenLines. Malformed JSONL lines skip+warn. - stop(): closes all watchers; JSONL state preserved (gstack owns the files, gbrain only reads). - healthCheck(): reports warn when no files discovered (gstack not installed) OR when watched files have disappeared; ok otherwise with counter of lines seen. mode: 'trickle' (the v0.41 T2 default). Line-level content_hash via canonical-JSON serialization means whitespace reformatting doesn't trigger re-emit. Re-emit of an identical line is a silent dedup hit via the daemon's 24h DedupWindow (T2 trickle path). Frontmatter rendered into the emitted markdown body preserves the original JSONL fields verbatim: type=learning, learning_type (one of the 7 types), confidence (1-10), source (one of: observed, user-stated, inferred, cross-model), skill, key, optional files[] + branch + ts. Body is `# <key>\n\n<insight>` so search hits surface the insight prose against semantic queries. Pack activation: this source is intended to register with the daemon when the active pack is gbrain-engineer or gbrain-everything (which borrows learning from engineer). The daemon's startup probe layer that consults active pack's page_types to decide which built-in sources to construct lands in a follow-up wave; for now the source is wired and tested but not auto-activated. Tests (test/ingestion/gstack-learnings.test.ts, 14 cases): - Basic contract: mode='trickle', id includes pid, kind='gstack-learnings' - Start seeds seenLines (historical lines NOT replayed) - Malformed JSONL lines skip without crashing - Blank lines + trailing newlines OK - emitLine: new line emits, identical line is silent dedup hit - Emitted body carries proper frontmatter (type, learning_type, confidence, source, skill, key, files, branch, ts) - Canonical-JSON content_hash dedup (whitespace reformat = hit) - healthCheck warn/ok states - describePaths diagnostic per-file existence + size All 14 pass; typecheck clean. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Task T8 of 13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ingestion): wintermute-greenfield migration-mode importer (v0.41 T7) Implements WintermuteGreenfieldSource — the one-shot bulk importer for migrating the user's existing wintermute brain (13K atoms + 11K concepts + ~30 ideas) into gbrain via the v0.41 lens packs. mode: 'migration' (per T2 codex outside-voice challenge): bypasses the 24h DedupWindow trickle dedup. Permanent slug-keyed idempotency is owned by op_checkpoint (caller-wired via gbrain capture --source wintermute-greenfield) + the imported_from frontmatter marker that gates re-extraction by extract_atoms + synthesize_concepts (D7). @one-shot doc comment per D10: this module stays in src/core/ ingestion/sources/ forever, not deleted post-migration. Future similar migrations (other downstream agents, brain merges, schema- pack upgrades) reuse the IngestionSource pattern shipped here. Deleting the working example is short-sighted. Walk: - ~/git/brain/atoms/{YYYY-MM-DD}/*.md (atoms, date-bucketed) - ~/git/brain/concepts/*.md (concepts, flat) - ~/git/brain/ideas/*.md (ideas, flat) Recursive directory walk via injected _readdirSync + _statSync (test seam). Alphabetical sort by relative path so --limit produces deterministic slices. Per file: 1. Read content; gray-matter parses frontmatter + body 2. Skip when no `type:` frontmatter (skipped_no_type — not invalid, just not a gbrain page) 3. Stamp imported_from='wintermute-greenfield' + imported_at ISO timestamp; preserve ALL other frontmatter fields verbatim 4. Re-stringify via matter.stringify 5. Emit IngestionEvent with content_type='text/markdown', untrusted_payload=false (local user-owned files), metadata carrying slug + page_type + original_path + original_frontmatter + importer + importer_version Per-row validation failure → JSONL audit at ~/.gbrain/audit/wintermute-greenfield-failures-YYYY-Www.jsonl per D12. Failed-file processing continues (don't fail-fast on one bad row). Audit dir created lazily via mkdirSync recursive on first write. CLI flags supported via opts: --dry-run: walks + validates + stamps but doesn't emit --limit N: processes only the first N files (alphabetical) The CLI surface lands via gbrain capture --source wintermute-greenfield in a follow-up commit (capture.ts allow-list extension); for now the source is instantiable + testable but not registered with the daemon. Tests (test/ingestion/wintermute-greenfield.test.ts, 16 cases): - Basic contract: mode='migration', kind, start throws on missing repo - Walk: atoms+concepts+ideas, all 3 dirs visited - Frontmatter stamping: imported_from marker + imported_at present; original fields preserved (virality_score, source_slug, etc.) - Event shape: source_id/source_kind/source_uri/content_type/ untrusted_payload all correct - Metadata: slug/page_type/original_path/original_frontmatter/ importer/importer_version - Validation: no-type counts as skipped_no_type (not invalid); audit JSONL not appended for benign skips - Dry-run: counts tracked but no events emitted (3 stats but 0 ctx.emitted) - --limit: only N files processed - Deterministic ordering: alphabetical relative-path sort means --limit 1 always picks the alphabetically-first file - healthCheck: ok after clean run; warn before start All 16 pass; typecheck clean. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Task T7 of 13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cycle): extract_atoms + synthesize_concepts minimal-viable bodies (v0.41 T5+T6) Replaces the T9-shipped stub modules with working LLM-driven phase bodies. v0.41 ships the right SHAPE — Haiku per transcript producing 1-3 atoms, atoms grouped by concept frontmatter ref, tier assignment by count, Sonnet narrative for T1/T2. The richer 3-check quality gate (truism/punchline/entity multi-pass), embedding-similarity dedup, voice gate integration, op_checkpoint resumability all land in v0.41.1+ — filed as inline TODOs and plan follow-ups. T5 extract_atoms (src/core/cycle/extract-atoms.ts): - Takes transcripts via _transcripts test seam OR discoverTranscripts production path (lazy-imports transcript-discovery.ts to avoid circular module loads through cycle.ts). - Per transcript: ONE Haiku call with the 11-value atom_type enum embedded in the prompt (matches gbrain-creator.yaml declaration; v0.42 reads from active pack manifest at runtime per D11). - parseAtomsResponse tolerates markdown fences + trailing prose; rejects invalid atom_type values; clamps virality_score to [0,100]; rejects malformed entries silently (skip don't crash). - Per atom: putPage atom-typed page under atoms/{YYYY-MM-DD}/ {slug-from-title}. Frontmatter preserves atom_type, source_quote, lesson, virality_score, emotional_register from the LLM output. - Budget cap $0.30/source/run (DEFAULT_BUDGET_USD); over-budget transcripts counted as budget-skipped, phase returns status='warn' if any failures occurred. - Source-scoped: opts.sourceId routes corpus dir + write target. - dry-run: counts but doesn't writePages. - Failures tracked per-transcript without halting the run. T6 synthesize_concepts (src/core/cycle/synthesize-concepts.ts): - Takes atoms via _atoms test seam OR DB query for type='atom' pages excluding imported_from frontmatter marker (D7 skip). - Groups atoms by frontmatter `concepts:` array ref. - Tier by count: T1 >=10, T2 >=5, T3 >=2, T4 deferred (no <2 groups). - T1/T2 groups: Sonnet call with up to 10 sample titles + 5 sample bodies → 1-paragraph narrative. Budget cap $1.50/run; over-budget or LLM-failed groups fall back to deterministic narrative. - T3 groups: deterministic narrative (no LLM call). - Per group: putPage concept-typed page at concepts/{title-from-slug} with tier + mention_count + composite_score frontmatter. - dry-run + yieldDuringPhase honored. Tests (test/cycle/extract-atoms-synthesize-concepts.test.ts, 19 cases): parseAtomsResponse: well-formed JSON, markdown fences stripped, trailing prose tolerated, invalid atom_type rejected, missing fields rejected, garbage returns [], all 11 atom_type values accepted, virality_score clamped to [0,100]. runPhaseExtractAtoms: no-op without transcripts, extracts via stub chat + writes pages, dry-run counts without writing, failures tracked per-transcript without halting. runPhaseSynthesizeConcepts: no-op without atoms, groups by concept ref + tier assignment by count (T1=12 atoms, T2=6, T3=3), atoms without concept refs filtered out, <T3 threshold (1 atom) filtered, T3 uses deterministic (no LLM call), dry-run counts without writing, T1 narrative comes from LLM stub verbatim. All 19 pass; typecheck clean. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Tasks T5 + T6 of 13. v0.41.1 follow-ups inline: - extract_atoms: read atom_type enum from active pack at runtime (D11) - extract_atoms: 3-check quality gate as multi-pass refinement - synthesize_concepts: embedding-similarity dedup (currently exact- string concept ref match only) - synthesize_concepts: voice gate for T1 Canon narratives - Both: op_checkpoint resumability for cross-cycle continuation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(v0.41): CHANGELOG + lens-packs architecture + wintermute migration guide + eval scaffolds (T11+T12+T13) Closes out the v0.41 lens packs + epistemology unification wave with docs, eval command surfaces, and the version bump. Three tasks batched because each is small standalone: T11 — 3 eval command scaffolds: src/commands/eval-extract-atoms.ts src/commands/eval-synthesize-concepts.ts src/commands/eval-wintermute-greenfield.ts Each command surfaces the stable schema_version=1 envelope shape with status='not_yet_implemented' for v0.41. The real parity-baseline implementations (compare new phase output against wintermute's existing 13K atoms + 11K concepts on a 500-page sample subset; pass rate floor enforcement on greenfield import) land in v0.41.1. The scaffolds let users discover the commands AND give the v0.41.1 work a clear extension point. Pinned by 7 scaffold tests. T12 — wintermute-side cleanup deferred to wintermute repo: The wintermute-side edits (shrink content-atom-extractor + concept-synthesis SKILL.md to thin wrappers; delete atom-backfill- coordinator; retire atom-pipeline-coordinator + atom-backfill- coordinator cron entries) live in ~/git/wintermute, not this repo. The migration guide (docs/migrations/v0.41-wintermute-greenfield.md below) documents the cleanup steps. Operator runs them after verifying the greenfield import. T13 — Documentation: CHANGELOG.md: full v0.41.0.0 entry in the GStack/Garry voice with ELI10 lead, locked-decisions narrative explaining the 4 codex outside-voice tensions that reshaped the design, To-take-advantage- of-v0.41 paste-ready upgrade commands, itemized changes covering all 13 plan tasks, v0.41.1 follow-ups list. docs/architecture/lens-packs.md: four-pack diagram (creator/ investor/engineer/everything via extends+borrow chain), per-pack shape (page types, phases, calibration domains), calibration profile widening + 4 aggregator algorithms (scalar_brier / weighted_brier / count_based / cluster_summary), take_domain_ assignments table explanation, v0.41.1 follow-ups. docs/migrations/v0.41-wintermute-greenfield.md: operator guide for the bulk 24K-page migration. Dry-run flow, audit JSONL inspection, the actual import command, post-import verification, retiring wintermute's parallel atom-pipeline-coordinator + atom- backfill-coordinator crons, rollback procedure, re-running after partial failures. Version bump: VERSION + package.json → 0.41.0.0. All 158 tests across 10 v0.41 test files pass; typecheck clean. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Final tasks T11 + T12 + T13 of 13. Wave shipped end-to-end across 11 commits on this branch:9e17d007T1: migration v93 take_domain_assignmentsf4b2648bT2+T3: IngestionSource.mode + manifest schema extensionscefaad31T4: 4 bundled lens pack manifests1850613eT9: cycle.ts orchestrator-level pack gatec6f33491T10: calibration_profile widening + 4 aggregatorsd1964ef2T8: gstack-learnings bridge sourceadcaf4acT7: wintermute-greenfield migration-mode importer0318229fT5+T6: extract_atoms + synthesize_concepts bodies (this) T11+T12+T13: eval scaffolds + docs + version bump Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): bump phase-count assertions from 17→19 (v0.41 follow-on) v0.41 added extract_atoms + synthesize_concepts to ALL_PHASES. Three existing tests pinned the count at 17 via load-bearing regression assertions: test/phase-scope-coverage.test.ts:48-49 expect(ALL_PHASES.length).toBe(17) expect(Object.keys(PHASE_SCOPE).length).toBe(17) test/core/cycle.serial.test.ts:393 expect(hookCalls).toBe(17) // yieldBetweenPhases hook fires per phase test/core/cycle.serial.test.ts:406 expect(report.phases.length).toBe(17) test/e2e/cycle.test.ts:110 expect(report.phases.length).toBe(17) These are the correct fix: the assertions exist precisely to catch this case (a PR that adds a phase without updating downstream consumers). The wave's v0.41 commit (T9) updated ALL_PHASES but missed these three sites. Updating them to 19 with comment breadcrumbs preserving the version history (v0.26.5 → 9, v0.29 → 10, v0.31 → 11, v0.32.2 → 12, v0.33.3 → 13, v0.36.1.0 → 16, v0.39.0.0 → 17, v0.41.0.0 → 19). Without this fix: full unit test suite (`bun run test`) shows 3 failures from these assertions. Underlying v0.41 logic was already green; this is pure pin-bumping. After fix: 9059 unit tests pass. 0 actual test failures. (3 shard wedges remain from unrelated long-running parallel-runner tests that exceed the 600s per-shard cap — infra concern, not test logic, pre-dates this wave.) Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Wave gate: all 13 plan tasks done; all v0.41 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): update EXPECTED_PHASES for v0.41 (extract_atoms + synthesize_concepts + schema-suggest) E2E test/e2e/dream-cycle-phase-order-pglite.test.ts pinned the canonical phase sequence at 16 entries. v0.41 added extract_atoms (after extract_facts) and synthesize_concepts (after patterns); v0.39 had already added schema-suggest between orphans and purge. EXPECTED_PHASES was missing all three. This is the correct fix — the test exists specifically to catch a PR that adds a phase without updating consumers, and it fired exactly as designed. Updating EXPECTED_PHASES to the v0.41 19-phase sequence with comment breadcrumbs (v0.39.0.0 schema-suggest, v0.41.0.0 extract_atoms + synthesize_concepts). Verification (run with --timeout 60000 per E2E convention): DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test \ bun test test/e2e/dream-cycle-phase-order-pglite.test.ts --timeout 60000 → 5 pass, 0 fail Other E2E failures observed in the full run are pre-existing / environmental and not v0.41 regressions: - dream-synthesize-chunking: existing flake (synthesize details shape under withoutAnthropicKey) - fresh-install-pglite: env has multiple embedding providers configured; requires explicit --embedding-model disambiguation - http-transport: last_used_at debounce timing flake - ingestion-roundtrip: file-watcher trickle-mode timing flake - mechanical: gbrain doctor exits 1 because user's persistent ~/.gbrain has wedged migrations + reranker auth warnings - autopilot-fanout-postgres: pre-existing dispatch-selector timestamp semantics None of those 6 are touched by the v0.41 wave. Filing them as unrelated maintenance items. Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md Wave gate: 13 plan tasks done; v0.41 unit tests green; v0.41 E2E green; pre-existing E2E flakes unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): 4 root-cause fixes for pre-existing E2E flakes (master polish) After merging origin/master (which landed v0.40.8.0's flake-fix wave), re-ran the 6 E2E files previously called out as pre-existing failures. v0.40.8.0 had already fixed 3; the remaining 3 had real root causes: 1. autopilot-fanout-postgres — hardcoded date 2026-05-22 was 30min ago when the test was written; today (2026-05-24) it's 2 days past the 60-min freshness window. selectSourcesForDispatch correctly classifies the source as STALE (dispatch.length=1) instead of FRESH (length=0). Fix: replace literal date with Date.now() - 30 * 60 * 1000 so the timestamp stays relative-fresh forever. 2. ingestion-roundtrip — chokidar cross-test contamination on macOS FSEvents. Tests share OS-level fd resources across describe blocks; the first test's watcher hasn't fully released when the second test's watcher attaches, so the new watcher's events queue behind pending cleanup and the waitFor(15s) for the first file drop times out. Fixes: - Move fs.mkdirSync(inboxDir) BEFORE createInboxFolderSource + daemon.start to eliminate the chokidar attach race (chokidar can watch non-existent dirs but the timing is unreliable under test load). - Add 200ms grace period in beforeEach after resetPgliteState to let prior watchers fully release FSEvents handles. - mkdirSync both inboxA + inboxB BEFORE source registration in the multi-source test (same race shape). - Bump waitFor timeouts 6s → 15s for fs.watch flake tolerance. 3. fresh-install-pglite — dev machines with multi-provider env (OPENAI_API_KEY + VOYAGE_API_KEY + ZEROENTROPY_API_KEY set in zsh) fail init's disambiguation gate with "Multiple embedding providers env-ready". The test sets ZE_API_KEY but doesn't NEGATE the others. Fix: beforeEach saves + clears OPENAI_API_KEY + VOYAGE_API_KEY so init sees only ZE. afterEach restores. Hermetic per dev machine. 4. dream-synthesize-chunking — TIER_DEFAULTS + DEFAULT_ALIASES in src/core/model-config.ts had BARE Anthropic model ids (e.g. 'claude-sonnet-4-6' instead of 'anthropic:claude-sonnet-4-6'). The v0.40.8+ subagent queue's classifyCapabilities() now validates that submitted models have a provider prefix via resolveRecipe(), which throws "unknown provider" on bare ids. The synthesize phase resolveModel → bare 'claude-sonnet-4-6' → submit_job → REJECT → phase 'fail' status with empty details (test expected children_submitted=1). Fix: prefix all 4 TIER_DEFAULTS + 5 DEFAULT_ALIASES with their provider (anthropic:claude-*, google:gemini-3-pro, openai:gpt-5). Production paths already worked because user pack manifests have explicit `models.tier.subagent = anthropic:...`; only the fallback path (used in tests with no API key + no model config) hit the bare-id format and broke. Verification (all run against DATABASE_URL=...:5434/gbrain_test): test/e2e/autopilot-fanout-postgres.test.ts → 6/6 pass test/e2e/dream-cycle-phase-order-pglite.test.ts → 5/5 pass test/e2e/dream-synthesize-chunking.test.ts → 4/4 pass test/e2e/fresh-install-pglite.test.ts → 2/2 pass test/e2e/http-transport.test.ts → 8/8 pass test/e2e/ingestion-roundtrip.test.ts → 3/3 pass test/e2e/mechanical.test.ts → 78/78 pass Total: 106/106 pass, 0 fail. Adjacent unit tests verified green: test/anthropic-model-ids.test.ts → 6/6 pass test/model-config.serial.test.ts → 19/19 pass typecheck clean. Plan: v0.41 wave (~/.claude/plans/system-instruction-you-are-working-toasty-milner.md). Post-merge polish — every E2E failure surfaced in the v0.41 ship reports is now green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(v0.42.0.0): privacy sweep + queue rebump + 5 pre-existing test fixes Privacy: rename `wintermute-greenfield` → `markdown-greenfield` identifier across 13 files + 4 file renames per CLAUDE.md:550 (banned private-fork name in public artifacts). Identifier shipped through the lens-pack wave as the long-lived migration-mode source kind; sweep includes class names (MarkdownGreenfieldSource), frontmatter marker, audit JSONL path, eval command, and operator doc filename. Reframe contextual mentions per OpenClaw substitution rule ("your OpenClaw"/"upstream OpenClaw"). Queue: rebump v0.41.0.0 → v0.42.0.0 (PR #1352 claims v0.41.0.0 in queue); sweeps 38 v0.41 → v0.42 references across branch-introduced files; renames docs/migrations/v0.41-markdown-greenfield.md → v0.42-markdown-greenfield.md, test/schema-pack-manifest-v041.test.ts → -v042, test/eval-v041-scaffolds → test/eval-v042-scaffolds. Pre-existing master files referencing v0.41 left untouched (those describe master's own anticipated wave). Test fixes (5 pre-existing failures + 1 shard wedge, all unrelated to lens packs but caught by the post-merge run): - src/core/anthropic-pricing.ts: estimateMaxCostUsd strips `anthropic:` provider prefix before ANTHROPIC_PRICING lookup. v0.31.12 introduced provider-prefixed model strings; the budget meter wasn't updated and fell through to BUDGET_METER_NO_PRICING (budget gate disabled), letting auto-think submissions complete when the test expected budget exhaustion to force partial/skipped. - test/longmemeval-trajectory-routing.test.ts: perf-gate cap 10s → 30s. Test runs ~4s isolated; parallel-shard CPU contention pushes it to 16s. 30s still catches genuine cold-path regressions. - test/search/embedding-column.test.ts → .serial.test.ts: quarantine to serial pass (depends on gateway module-state set by bunfig.toml preload; other parallel tests' resetGateway() leaves stale state). - scripts/run-unit-parallel.sh: SHARD_TIMEOUT 600s → 900s. Shard 8's migration test suite runs 1369 tests in 807s (all pass); 600s wrapper cap was killing healthy shards. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: update project documentation for v0.42.0.0 Sweep v0.41 → v0.42.0.0 drift across the wave's release-summary and the two new doc files. The wave shipped under its planning-time name (v0.41); the queue rebump to v0.42.0.0 left a handful of factual references pointing at the wrong version. - CHANGELOG.md v0.42.0.0 entry: doc-ref filename, follow-up version label, and 4 in-prose v0.41 cites corrected to v0.42.0.0 / v0.42.0.1. - docs/architecture/lens-packs.md: title + body + follow-up section corrected to v0.42.0.0 / v0.42.0.1. - docs/migrations/v0.42-markdown-greenfield.md: title + upgrade command text corrected to v0.42.0.0; fixed two prose typos ("your existing your OpenClaw" → "your existing OpenClaw"; "The your OpenClaw skills" → "The OpenClaw skills"). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump v0.42.0.0 → v0.41.2.0 (per user; patch slot on v0.41 line) PRs #1352 and #1367 both claim v0.41.0.0 in queue (the .0 slot is contested); v0.41.2.0 is unclaimed and represents this wave as a PATCH on the v0.41 line rather than a separate minor wave. Sweeps v0.42.0.0 → v0.41.2.0 across CHANGELOG + 2 docs + 4 yaml + 4 ts + 2 test files; renames docs/migrations/v0.42-markdown-greenfield.md → v0.41.2-markdown-greenfield.md and 2 test files (-v042 → -v041_2). Wave-identity tags ("v0.41 T4" etc) in test/code comments correctly preserved — this IS a v0.41 wave patch, not a new wave. macOS sed `\b` limitation means those tags were never converted in the first place; verified intentional preservation. Forward references to v0.42 in TODOS.md + CHANGELOG D3 section + future- wave declarations in code comments are untouched (they describe the NEXT minor wave, not this one). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(audit-writer): route log() to event-ts ISO-week file, not wall-clock now CI shard 3 failed `createAuditWriter — readRecent() > returns events from current week, filtered by ts cutoff` at audit-writer.test.ts:229 with `Expected: 2, Received: 0`. Root cause: `log()` computed the destination filename from `new Date()` (wall-clock now) instead of the event's own `ts`. Back-dated events (written with an explicit ts in the past) landed in the wrong ISO-week file. `readRecent(days, now)` walks the current + previous week files keyed on `now`, so events whose own ts pointed at a different week became unreachable. The test passes ts=2026-05-21/16/14 and now=2026-05-22 (week 21 + 20). CI runs on wall-clock 2026-05-25 (week 22). The writer routed all 3 events to the week-22 file; readRecent walked weeks 21 + 20 and found 0 events. Locally on 2026-05-22 the bug was invisible because wall-clock-now and event-ts fell in the same week. Fix in src/core/audit/audit-writer.ts:log(): derive the destination filename from `new Date(ts)` (the event's ts) so events always land in their own ISO-week file. NaN-guard falls back to wall-clock-now on unparseable ts. Test update at test/audit/audit-writer.test.ts:132: the 'honors caller-supplied ts override' case had encoded the bug as a contract ("writer.log writes to current-week file regardless of event ts"). Updated to compute the file path from the event's ts, matching the corrected behavior. All 22 audit-writer tests pass. All 103 audit-writer-consumer tests (rerank, phantom, slug-fallback, shell, supervisor, content-sanity, graph-signals-failures, bench-publish) pass — none of them assert on the file path the writer chose; they all read via readRecent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
859 lines
44 KiB
TypeScript
859 lines
44 KiB
TypeScript
/**
|
|
* CI guard: PGLITE_SCHEMA_SQL must not forward-reference state that
|
|
* `applyForwardReferenceBootstrap` doesn't know how to create.
|
|
*
|
|
* Background: gbrain ships an "embedded latest schema" blob
|
|
* (`pglite-schema.ts`) for fast bootstraps, alongside a numbered migration
|
|
* chain (`migrate.ts`) for incremental upgrades. Across 2 years and 6 schema
|
|
* versions, every release that added a column-with-index in the schema blob
|
|
* without a corresponding bootstrap addition has triggered the same wedge
|
|
* incident class (#239, #243, #266, #266, #357, #366, #374, #375, #378,
|
|
* #395, #396).
|
|
*
|
|
* The bootstrap is the structural fix. This test enforces the contract:
|
|
* for every "forward reference" the schema blob makes (FK or indexed column
|
|
* defined later than its reference site, or any column that older brains
|
|
* lack), the bootstrap MUST add enough state so that running the schema
|
|
* blob is replay-safe on a brain that lacks every member of
|
|
* `REQUIRED_BOOTSTRAP_COVERAGE`.
|
|
*
|
|
* **When you add a new schema-blob forward reference:**
|
|
* 1. Extend `applyForwardReferenceBootstrap` in pglite-engine.ts +
|
|
* postgres-engine.ts to add the new state.
|
|
* 2. Add an entry to `REQUIRED_BOOTSTRAP_COVERAGE` below.
|
|
* 3. This test will pass.
|
|
*
|
|
* If you add a forward reference but skip step 1, this test fails. If you
|
|
* skip step 2, this test passes but the bootstrap silently drifts behind
|
|
* the schema. The eng-review polish notes recommended layered coverage
|
|
* (per-engine integration tests in `test/bootstrap.test.ts` +
|
|
* `test/e2e/postgres-bootstrap.test.ts`) to catch step 2 oversights.
|
|
*/
|
|
|
|
import { test, expect } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
// Tier 3 opt-out: this file tests the bootstrap coverage contract explicitly,
|
|
// running applyForwardReferenceBootstrap against fresh PGlite instances. A
|
|
// snapshot-loaded engine would skip the bootstrap entirely.
|
|
delete process.env.GBRAIN_PGLITE_SNAPSHOT;
|
|
|
|
// Forward-reference targets that PGLITE_SCHEMA_SQL requires.
|
|
// When you add a new one, extend this list AND the bootstrap.
|
|
type ForwardReference =
|
|
| { kind: 'table'; name: string }
|
|
| { kind: 'column'; table: string; column: string };
|
|
|
|
const REQUIRED_BOOTSTRAP_COVERAGE: ForwardReference[] = [
|
|
// Forward-referenced by `pages.source_id REFERENCES sources(id)` and the
|
|
// `INSERT INTO sources (id, name, config) VALUES ('default', ...)` seed.
|
|
{ kind: 'table', name: 'sources' },
|
|
// Forward-referenced by `CREATE INDEX idx_pages_source_id ON pages(source_id)`.
|
|
{ kind: 'column', table: 'pages', column: 'source_id' },
|
|
// Forward-referenced by `CREATE INDEX idx_links_source ON links(link_source)`.
|
|
{ kind: 'column', table: 'links', column: 'link_source' },
|
|
// Forward-referenced by `CREATE INDEX idx_links_origin ON links(origin_page_id)`.
|
|
{ kind: 'column', table: 'links', column: 'origin_page_id' },
|
|
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_symbol_name
|
|
// ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'symbol_name' },
|
|
// v0.19+ — forward-referenced by `CREATE INDEX idx_chunks_language
|
|
// ON content_chunks(language) WHERE language IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'language' },
|
|
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
|
|
// idx_chunks_search_vector ON content_chunks USING GIN(search_vector)`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'search_vector' },
|
|
// v0.20+ Cathedral II — forward-referenced by `CREATE INDEX
|
|
// idx_chunks_symbol_qualified ON content_chunks(symbol_name_qualified)`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'symbol_name_qualified' },
|
|
// v0.20+ Cathedral II — populated by update_chunk_search_vector trigger;
|
|
// present in PGLITE_SCHEMA_SQL CREATE TABLE definition.
|
|
{ kind: 'column', table: 'content_chunks', column: 'parent_symbol_path' },
|
|
{ kind: 'column', table: 'content_chunks', column: 'doc_comment' },
|
|
// v0.26.5 — forward-referenced by `CREATE INDEX pages_deleted_at_purge_idx
|
|
// ON pages (deleted_at) WHERE deleted_at IS NOT NULL`.
|
|
{ kind: 'column', table: 'pages', column: 'deleted_at' },
|
|
// v0.27.1 — forward-referenced by `CREATE INDEX idx_chunks_embedding_image
|
|
// ON content_chunks USING hnsw (embedding_image vector_cosine_ops)
|
|
// WHERE embedding_image IS NOT NULL`.
|
|
{ kind: 'column', table: 'content_chunks', column: 'embedding_image' },
|
|
// v0.27.1 — added in the same migration as embedding_image. Sibling column;
|
|
// not directly forward-referenced by an index but the bootstrap adds it
|
|
// alongside embedding_image for the v39 contract.
|
|
{ kind: 'column', table: 'content_chunks', column: 'modality' },
|
|
// v0.26.3 (v33) — forward-referenced by `CREATE INDEX idx_mcp_log_agent_time
|
|
// ON mcp_request_log(agent_name, created_at DESC)`.
|
|
{ kind: 'column', table: 'mcp_request_log', column: 'agent_name' },
|
|
// v0.27 (v36) — forward-referenced by `CREATE INDEX
|
|
// idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`.
|
|
// Composite-index second column; the array-based test pattern misses these
|
|
// by default, which is why this fix wave's Step 3 replaces this with a
|
|
// SQL parser that extracts every column referenced by any DDL.
|
|
{ kind: 'column', table: 'subagent_messages', column: 'provider_id' },
|
|
// v0.29 (v40) — pages.emotional_weight populated by recompute_emotional_weight;
|
|
// bootstrapped alongside the v41 columns since they share the v0.29.1 wave.
|
|
{ kind: 'column', table: 'pages', column: 'emotional_weight' },
|
|
// v0.29.1 (v41) — forward-referenced by `CREATE INDEX pages_coalesce_date_idx
|
|
// ON pages ((COALESCE(effective_date, updated_at)))`. The expression-index
|
|
// claim from earlier plan iterations was wrong; PG's planner won't use a
|
|
// partial index for the negative side of a COALESCE — expression index is.
|
|
{ kind: 'column', table: 'pages', column: 'effective_date' },
|
|
// v0.29.1 (v41) — sibling columns added in the same migration as
|
|
// effective_date; bootstrap adds them all together.
|
|
{ kind: 'column', table: 'pages', column: 'effective_date_source' },
|
|
{ kind: 'column', table: 'pages', column: 'import_filename' },
|
|
{ kind: 'column', table: 'pages', column: 'salience_touched_at' },
|
|
// v0.31.2 (v50) — forward-referenced by `CREATE INDEX
|
|
// idx_ingest_log_source_type_created ON ingest_log (source_id, source_type,
|
|
// created_at DESC)`. Old brains have ingest_log without source_id; bootstrap
|
|
// adds the column before SCHEMA_SQL replay creates the index.
|
|
{ kind: 'column', table: 'ingest_log', column: 'source_id' },
|
|
// v0.18 (v18) — forward-referenced by `CREATE INDEX idx_files_source_id ON
|
|
// files(source_id)` and `CREATE INDEX idx_files_page_id ON files(page_id)`.
|
|
// Pre-v18 brains have files without these columns; bootstrap adds them
|
|
// before SCHEMA_SQL replay creates the indexes.
|
|
{ kind: 'column', table: 'files', column: 'source_id' },
|
|
{ kind: 'column', table: 'files', column: 'page_id' },
|
|
// v0.34.1 (v60+v61+v65) — forward-referenced by the FK
|
|
// `oauth_clients.source_id REFERENCES sources(id)` and the GIN index
|
|
// `idx_oauth_clients_federated_read ON oauth_clients USING GIN (federated_read)`.
|
|
// Pre-v60 brains have oauth_clients without these columns; bootstrap adds
|
|
// them before SCHEMA_SQL replay creates the FK + index.
|
|
{ kind: 'column', table: 'oauth_clients', column: 'source_id' },
|
|
{ kind: 'column', table: 'oauth_clients', column: 'federated_read' },
|
|
// v0.26.5 (v34) — promotes archive lifecycle from JSONB config to real
|
|
// columns on sources. CREATE TABLE IF NOT EXISTS is a no-op on existing
|
|
// sources tables, so the visibility filters in search/list_pages that
|
|
// reference these columns trip on pre-v34 brains. Bootstrap adds them
|
|
// before any visibility-filter SQL runs.
|
|
{ kind: 'column', table: 'sources', column: 'archived' },
|
|
{ kind: 'column', table: 'sources', column: 'archived_at' },
|
|
{ kind: 'column', table: 'sources', column: 'archive_expires_at' },
|
|
// v0.37.0 (v79) — forward-referenced by `CREATE INDEX
|
|
// pages_last_retrieved_at_idx ON pages (last_retrieved_at)`. Pre-v79 brains
|
|
// have pages without this column; bootstrap adds it before SCHEMA_SQL
|
|
// replay creates the index.
|
|
{ kind: 'column', table: 'pages', column: 'last_retrieved_at' },
|
|
// v0.38.0 (v81) — pages_provenance_columns adds four nullable columns
|
|
// (ingested_via, ingested_at, source_uri, source_kind) to track WHERE
|
|
// every page came from (capture-cli, webhook, put_page, dream, etc.).
|
|
// No SCHEMA_SQL index/FK references them today, but bootstrap probes
|
|
// are added defense-in-depth so future schema work that does reference
|
|
// them doesn't wedge pre-v81 brains. Renumbered v80→v81 during master
|
|
// merge with v0.37.2.0 takes_unresolvable_quality hotfix.
|
|
{ kind: 'column', table: 'pages', column: 'ingested_via' },
|
|
{ kind: 'column', table: 'pages', column: 'ingested_at' },
|
|
{ kind: 'column', table: 'pages', column: 'source_uri' },
|
|
{ kind: 'column', table: 'pages', column: 'source_kind' },
|
|
// v0.40.3.0 (v90, renumbered from v0.40.3.0 v81 on master merge) —
|
|
// contextual_retrieval_columns adds five additive columns wiring the
|
|
// three-tier wrapper ladder. Bootstrap probes added defense-in-depth
|
|
// for future schema work.
|
|
{ kind: 'column', table: 'pages', column: 'contextual_retrieval_mode' },
|
|
{ kind: 'column', table: 'pages', column: 'corpus_generation' },
|
|
{ kind: 'column', table: 'sources', column: 'contextual_retrieval_mode' },
|
|
{ kind: 'column', table: 'sources', column: 'trust_frontmatter_overrides' },
|
|
// v0.40.3.0 (v91) — pages.generation BIGINT bumped by the
|
|
// bump_page_generation_fn trigger. Forward-referenced by
|
|
// pages_generation_idx (CREATE INDEX ON pages (generation)) so bootstrap
|
|
// probes guard pre-v91 brains.
|
|
{ kind: 'column', table: 'pages', column: 'generation' },
|
|
];
|
|
|
|
test('applyForwardReferenceBootstrap covers every forward reference declared in REQUIRED_BOOTSTRAP_COVERAGE', async () => {
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
// Strip every required forward-reference target so the brain looks like
|
|
// it pre-dates the migrations that introduced these objects. Drop columns
|
|
// before the table-level constraints that depend on them.
|
|
await db.exec(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
|
|
DROP INDEX IF EXISTS idx_links_source;
|
|
DROP INDEX IF EXISTS idx_links_origin;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_symbol_name;
|
|
DROP INDEX IF EXISTS idx_chunks_language;
|
|
DROP INDEX IF EXISTS idx_chunks_search_vector;
|
|
DROP INDEX IF EXISTS idx_chunks_symbol_qualified;
|
|
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
|
|
DROP FUNCTION IF EXISTS update_chunk_search_vector;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS language;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS parent_symbol_path;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS doc_comment;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS symbol_name_qualified;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS search_vector;
|
|
|
|
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS modality;
|
|
|
|
DROP INDEX IF EXISTS idx_mcp_log_agent_time;
|
|
DROP INDEX IF EXISTS idx_mcp_log_time_agent;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS agent_name;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS params;
|
|
ALTER TABLE mcp_request_log DROP COLUMN IF EXISTS error_message;
|
|
|
|
DROP INDEX IF EXISTS idx_subagent_messages_provider;
|
|
ALTER TABLE subagent_messages DROP COLUMN IF EXISTS provider_id;
|
|
|
|
DROP INDEX IF EXISTS pages_coalesce_date_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date_source;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
|
|
|
|
DROP INDEX IF EXISTS idx_ingest_log_source_type_created;
|
|
ALTER TABLE ingest_log DROP COLUMN IF EXISTS source_id;
|
|
|
|
DROP INDEX IF EXISTS idx_files_source_id;
|
|
DROP INDEX IF EXISTS idx_files_page_id;
|
|
ALTER TABLE files DROP COLUMN IF EXISTS source_id;
|
|
ALTER TABLE files DROP COLUMN IF EXISTS page_id;
|
|
|
|
DROP INDEX IF EXISTS idx_oauth_clients_federated_read;
|
|
ALTER TABLE oauth_clients DROP COLUMN IF EXISTS source_id;
|
|
ALTER TABLE oauth_clients DROP COLUMN IF EXISTS federated_read;
|
|
|
|
-- v0.40.3.0 v90 + v91 column strips so applyForwardReferenceBootstrap
|
|
-- has work to do. Only strip pages columns + the trigger; sources
|
|
-- columns were already nuked by the earlier DROP TABLE IF EXISTS
|
|
-- sources CASCADE, and the bootstrap needsPagesBootstrap branch
|
|
-- recreates sources from schema-embedded.ts (which now includes the
|
|
-- CR columns inline). Same convention as the sources.archived note.
|
|
DROP TRIGGER IF EXISTS bump_page_generation_trg ON pages;
|
|
DROP FUNCTION IF EXISTS bump_page_generation_fn;
|
|
DROP INDEX IF EXISTS pages_generation_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS generation;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS contextual_retrieval_mode;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS corpus_generation;
|
|
`);
|
|
|
|
// Note: we don't strip sources.archived* here because they're inline in the
|
|
// sources CREATE TABLE definition (no separate ALTER TABLE), and the
|
|
// earlier `DROP TABLE IF EXISTS sources CASCADE` already nuked them.
|
|
// The bootstrap's needsPagesBootstrap branch recreates sources without the
|
|
// archive columns; the new needsSourcesArchive probe adds them.
|
|
|
|
// Run bootstrap in isolation (NOT initSchema). This is what we're testing.
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
|
|
// Assert every required forward-reference target now satisfies the
|
|
// schema-blob's expectations.
|
|
for (const ref of REQUIRED_BOOTSTRAP_COVERAGE) {
|
|
if (ref.kind === 'table') {
|
|
const { rows } = await db.query(
|
|
`SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = $1`,
|
|
[ref.name],
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
} else {
|
|
const { rows } = await db.query(
|
|
`SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
|
|
[ref.table, ref.column],
|
|
);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
test('after bootstrap, PGLITE_SCHEMA_SQL replays without crashing on missing forward references', async () => {
|
|
// End-to-end contract: bootstrap → SCHEMA_SQL must succeed even on a brain
|
|
// that lacks every forward-referenced target. This catches the case where
|
|
// REQUIRED_BOOTSTRAP_COVERAGE drifts behind PGLITE_SCHEMA_SQL — if the
|
|
// schema blob added a new index on a column the bootstrap doesn't create,
|
|
// the SCHEMA_SQL exec below would crash even though the per-target asserts
|
|
// above pass.
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
try {
|
|
await engine.initSchema();
|
|
const db = (engine as any).db;
|
|
|
|
await db.exec(`
|
|
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_source_slug_key;
|
|
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug);
|
|
DROP INDEX IF EXISTS idx_pages_source_id;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS source_id;
|
|
DROP TABLE IF EXISTS sources CASCADE;
|
|
DROP INDEX IF EXISTS idx_links_source;
|
|
DROP INDEX IF EXISTS idx_links_origin;
|
|
ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS link_source;
|
|
ALTER TABLE links DROP COLUMN IF EXISTS origin_page_id;
|
|
DROP INDEX IF EXISTS pages_deleted_at_purge_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS deleted_at;
|
|
|
|
DROP INDEX IF EXISTS idx_chunks_embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding_image;
|
|
ALTER TABLE content_chunks DROP COLUMN IF EXISTS modality;
|
|
|
|
DROP INDEX IF EXISTS pages_coalesce_date_idx;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS effective_date_source;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS import_filename;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS salience_touched_at;
|
|
ALTER TABLE pages DROP COLUMN IF EXISTS emotional_weight;
|
|
`);
|
|
|
|
// Bootstrap, then schema replay. Either step crashing fails the test.
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
await (engine as any).applyForwardReferenceBootstrap();
|
|
await db.exec(PGLITE_SCHEMA_SQL);
|
|
} finally {
|
|
await engine.disconnect();
|
|
}
|
|
}, 30000);
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// v0.28.5 — A2 structural prevention: auto-derive coverage from SQL.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// The hand-maintained REQUIRED_BOOTSTRAP_COVERAGE array is the contract
|
|
// that's failed 11 times across 6 schema versions: every release that
|
|
// added a column-with-index in the schema blob without a corresponding
|
|
// bootstrap addition has triggered a wedge incident.
|
|
//
|
|
// Codex outside-voice review of v0.28.5's plan caught a critical hole in
|
|
// the array-based approach: composite indexes like
|
|
// `idx_subagent_messages_provider ON subagent_messages (job_id, provider_id)`
|
|
// have a SECOND-column forward reference (`provider_id`) that a first-col-
|
|
// only extractor would miss entirely. v0.27 wedged exactly this way.
|
|
//
|
|
// This parser extracts every column referenced by a CREATE INDEX in
|
|
// PGLITE_SCHEMA_SQL — including composite-index second/third columns —
|
|
// and asserts each one is either in the baseline CREATE TABLE OR added
|
|
// by `applyForwardReferenceBootstrap`. Self-updating: any future
|
|
// CREATE INDEX in the schema blob is structurally covered the moment
|
|
// it's added, with no human required to remember to update an array.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse `CREATE TABLE [IF NOT EXISTS] <name> (<body>)` blocks.
|
|
* Returns a map from table name → set of column names declared in the body.
|
|
*
|
|
* Body parser is naive but sufficient for `pglite-schema.ts`: splits on
|
|
* commas at depth 0 (respecting nested parens for things like `vector(N)`,
|
|
* `numeric(p, s)`, `CHECK (col IN ('a', 'b'))`), skips constraint lines
|
|
* (CONSTRAINT/PRIMARY/UNIQUE/CHECK/FOREIGN), and grabs the first identifier
|
|
* of each remaining row as the column name.
|
|
*/
|
|
function parseBaseTableColumns(sql: string): Map<string, Set<string>> {
|
|
const result = new Map<string, Set<string>>();
|
|
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(sql)) !== null) {
|
|
const tableName = m[1].toLowerCase();
|
|
const bodyStart = m.index + m[0].length;
|
|
let depth = 1;
|
|
let i = bodyStart;
|
|
while (i < sql.length && depth > 0) {
|
|
const ch = sql[i];
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') depth--;
|
|
i++;
|
|
}
|
|
const body = sql.slice(bodyStart, i - 1);
|
|
|
|
const columns = new Set<string>();
|
|
// Split body on commas at depth 0.
|
|
let parenDepth = 0;
|
|
let start = 0;
|
|
const parts: string[] = [];
|
|
for (let j = 0; j < body.length; j++) {
|
|
const ch = body[j];
|
|
if (ch === '(') parenDepth++;
|
|
else if (ch === ')') parenDepth--;
|
|
else if (ch === ',' && parenDepth === 0) {
|
|
parts.push(body.slice(start, j));
|
|
start = j + 1;
|
|
}
|
|
}
|
|
parts.push(body.slice(start));
|
|
|
|
for (const partRaw of parts) {
|
|
// Strip SQL line comments (`-- ...` to end of line) and block
|
|
// comments (`/* ... */`) before identifying the column name.
|
|
// Without this, a column definition preceded by a comment inside
|
|
// the CREATE TABLE body is silently dropped (the comment is the
|
|
// "first identifier" and the parser bails out).
|
|
const stripped = partRaw
|
|
.replace(/--[^\n]*/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
const part = stripped.trim();
|
|
if (!part) continue;
|
|
// Skip constraint lines.
|
|
if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE)\b/i.test(part)) continue;
|
|
// First whitespace-separated token is the column name.
|
|
const colMatch = part.match(/^["`]?(\w+)["`]?/);
|
|
if (colMatch) columns.add(colMatch[1].toLowerCase());
|
|
}
|
|
result.set(tableName, columns);
|
|
}
|
|
|
|
// Also walk ALTER TABLE ... ADD COLUMN statements in the schema blob
|
|
// itself. Several columns (e.g. `pages.search_vector`) are added by an
|
|
// inline ALTER inside PGLITE_SCHEMA_SQL after the original CREATE TABLE.
|
|
// The schema-blob replay adds them in order, so they are NOT
|
|
// forward-references that bootstrap must provide — the schema blob
|
|
// itself self-heals on already-existing tables.
|
|
const alterRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\w+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/gi;
|
|
let am: RegExpExecArray | null;
|
|
while ((am = alterRe.exec(sql)) !== null) {
|
|
const tableName = am[1].toLowerCase();
|
|
const colName = am[2].toLowerCase();
|
|
if (!result.has(tableName)) result.set(tableName, new Set());
|
|
result.get(tableName)!.add(colName);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse `CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> [USING method] (<cols>)`.
|
|
* Returns every (table, column) pair referenced — including composite-index
|
|
* second/third columns. Function-call wrappers like `lower(col)` are unwrapped
|
|
* to their inner identifier; literal-only expressions like `(slug, NULLS LAST)`
|
|
* keep the bare column.
|
|
*
|
|
* Out of scope: WHERE-clause columns in partial indexes (rare in our schema;
|
|
* those columns are always also referenced in the index column list itself).
|
|
* Trigger function bodies are out of scope (they reference NEW.col / OLD.col
|
|
* which the existing test file's strip-list handles separately).
|
|
*/
|
|
function parseIndexColumnReferences(sql: string): Array<{ table: string; column: string }> {
|
|
const result: Array<{ table: string; column: string }> = [];
|
|
// Match CREATE INDEX up through the column-list paren group.
|
|
const re = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\w+\s+ON\s+(\w+)\s*(?:USING\s+\w+\s*)?\(/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(sql)) !== null) {
|
|
const table = m[1].toLowerCase();
|
|
const argsStart = m.index + m[0].length;
|
|
let depth = 1;
|
|
let i = argsStart;
|
|
while (i < sql.length && depth > 0) {
|
|
const ch = sql[i];
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') depth--;
|
|
i++;
|
|
}
|
|
const args = sql.slice(argsStart, i - 1);
|
|
|
|
// Split args on commas at depth 0.
|
|
let parenDepth = 0;
|
|
let start = 0;
|
|
const parts: string[] = [];
|
|
for (let j = 0; j < args.length; j++) {
|
|
const ch = args[j];
|
|
if (ch === '(') parenDepth++;
|
|
else if (ch === ')') parenDepth--;
|
|
else if (ch === ',' && parenDepth === 0) {
|
|
parts.push(args.slice(start, j));
|
|
start = j + 1;
|
|
}
|
|
}
|
|
parts.push(args.slice(start));
|
|
|
|
for (const partRaw of parts) {
|
|
// Strip ASC/DESC, NULLS FIRST/LAST modifiers.
|
|
const partClean = partRaw
|
|
.replace(/\s+(?:ASC|DESC)\s*$/i, '')
|
|
.replace(/\s+NULLS\s+(?:FIRST|LAST)\s*$/i, '')
|
|
.trim();
|
|
if (!partClean) continue;
|
|
// Two shapes to extract from:
|
|
// `col` — plain identifier
|
|
// `col vector_cosine_ops` — column followed by operator class (HNSW)
|
|
// `col COLLATE "C"` — column with collation
|
|
// `lower(col)` — function-wrapped
|
|
// For shapes 1-3, the column is the LEADING identifier. For shape 4,
|
|
// the column is the LAST identifier before a close paren.
|
|
let col: string | null = null;
|
|
if (partClean.includes('(')) {
|
|
// Function-wrapped: `lower(col)` → grab the last identifier inside.
|
|
const fnMatch = partClean.match(/(\w+)\s*\)\s*$/);
|
|
if (fnMatch) col = fnMatch[1];
|
|
} else {
|
|
// Plain or operator-class-suffixed: leading identifier wins.
|
|
const leadMatch = partClean.match(/^["`]?(\w+)["`]?/);
|
|
if (leadMatch) col = leadMatch[1];
|
|
}
|
|
if (col && !/^(true|false|null|asc|desc)$/i.test(col)) {
|
|
result.push({ table, column: col.toLowerCase() });
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
test('parseBaseTableColumns + parseIndexColumnReferences extract structural references', () => {
|
|
// Sanity checks for the parser helpers themselves. Runs in-process (no DB).
|
|
const fixture = `
|
|
CREATE TABLE IF NOT EXISTS pages (
|
|
id INTEGER PRIMARY KEY,
|
|
slug TEXT NOT NULL,
|
|
embedding vector(1536),
|
|
CONSTRAINT pages_slug_key UNIQUE (slug)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages (slug);
|
|
CREATE INDEX idx_pages_lower ON pages (lower(slug));
|
|
CREATE INDEX idx_pages_composite ON pages (slug, id DESC);
|
|
CREATE INDEX idx_pages_hnsw ON pages USING hnsw (embedding vector_cosine_ops);
|
|
`;
|
|
const baseCols = parseBaseTableColumns(fixture);
|
|
expect(baseCols.get('pages')).toBeDefined();
|
|
expect(baseCols.get('pages')!.has('id')).toBe(true);
|
|
expect(baseCols.get('pages')!.has('slug')).toBe(true);
|
|
expect(baseCols.get('pages')!.has('embedding')).toBe(true);
|
|
// Constraint lines must NOT leak as columns.
|
|
expect(baseCols.get('pages')!.has('constraint')).toBe(false);
|
|
|
|
const refs = parseIndexColumnReferences(fixture);
|
|
// Single-col index.
|
|
expect(refs).toContainEqual({ table: 'pages', column: 'slug' });
|
|
// Function-wrapped column.
|
|
expect(refs.some(r => r.table === 'pages' && r.column === 'slug')).toBe(true);
|
|
// Composite — BOTH columns must be captured (codex's case).
|
|
expect(refs).toContainEqual({ table: 'pages', column: 'id' });
|
|
// USING hnsw with operator class.
|
|
expect(refs).toContainEqual({ table: 'pages', column: 'embedding' });
|
|
});
|
|
|
|
test('parseIndexColumnReferences catches v0.27 composite second-column case', () => {
|
|
// The exact codex regression: `idx_subagent_messages_provider ON
|
|
// subagent_messages (job_id, provider_id)` has provider_id as the SECOND
|
|
// column. A first-col-only extractor would miss this — v0.27 wedged exactly
|
|
// because earlier patterns missed it.
|
|
const fixture = `
|
|
CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider
|
|
ON subagent_messages (job_id, provider_id);
|
|
`;
|
|
const refs = parseIndexColumnReferences(fixture);
|
|
expect(refs).toContainEqual({ table: 'subagent_messages', column: 'job_id' });
|
|
expect(refs).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
|
|
});
|
|
|
|
/**
|
|
* Parse `ALTER TABLE [IF EXISTS] [ONLY] <table> ADD COLUMN [IF NOT EXISTS] <col>`
|
|
* statements out of an arbitrary SQL string. Used to extract the (table, column)
|
|
* pairs that `applyForwardReferenceBootstrap` adds, so we can verify static
|
|
* coverage without running a DB.
|
|
*/
|
|
function parseAlterAddColumns(sql: string): Array<{ table: string; column: string }> {
|
|
const result: Array<{ table: string; column: string }> = [];
|
|
const re = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\w+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(sql)) !== null) {
|
|
result.push({ table: m[1].toLowerCase(), column: m[2].toLowerCase() });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
test('every CREATE INDEX column in PGLITE_SCHEMA_SQL is covered by CREATE TABLE or bootstrap (A2 static check)', async () => {
|
|
// The structural test that closes the 11-incident wedge class. Static
|
|
// contract: every column referenced by a CREATE INDEX in PGLITE_SCHEMA_SQL
|
|
// must be either (a) declared in the current CREATE TABLE body, or
|
|
// (b) added by `applyForwardReferenceBootstrap` in pglite-engine.ts.
|
|
//
|
|
// Codex outside-voice review caught the 11th wedge: composite-index second
|
|
// columns (`provider_id` in `(job_id, provider_id)`) are forward references
|
|
// that earlier extractors missed. This parser walks the full column list
|
|
// of every index — composite or not — and asserts each one is covered.
|
|
//
|
|
// Self-updating: when a future migration adds a CREATE INDEX in
|
|
// PGLITE_SCHEMA_SQL on a column that bootstrap doesn't yet provide, this
|
|
// test fails loud at PR time. No human required to update an array.
|
|
const { readFileSync } = await import('fs');
|
|
const { resolve: resolvePath } = await import('path');
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
|
|
const enginePath = resolvePath(process.cwd(), 'src/core/pglite-engine.ts');
|
|
const engineSrc = readFileSync(enginePath, 'utf-8');
|
|
|
|
const tableColumns = parseBaseTableColumns(PGLITE_SCHEMA_SQL);
|
|
const indexRefs = parseIndexColumnReferences(PGLITE_SCHEMA_SQL);
|
|
const bootstrapAdds = parseAlterAddColumns(engineSrc);
|
|
|
|
// Build the "covered" set: for each (table, column) pair, true iff it's in
|
|
// the table's CREATE TABLE columns OR added by an ALTER TABLE in the
|
|
// bootstrap function.
|
|
const covered = (table: string, column: string): boolean => {
|
|
const cols = tableColumns.get(table);
|
|
if (cols && cols.has(column)) return true;
|
|
return bootstrapAdds.some(a => a.table === table && a.column === column);
|
|
};
|
|
|
|
// Sanity checks: parser caught the codex case AND bootstrap provides it.
|
|
expect(indexRefs).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
|
|
expect(bootstrapAdds).toContainEqual({ table: 'subagent_messages', column: 'provider_id' });
|
|
expect(covered('subagent_messages', 'provider_id')).toBe(true);
|
|
|
|
// The actual contract: every index column reference must be covered.
|
|
const uncovered: Array<{ table: string; column: string }> = [];
|
|
for (const ref of indexRefs) {
|
|
if (!covered(ref.table, ref.column)) {
|
|
uncovered.push(ref);
|
|
}
|
|
}
|
|
|
|
if (uncovered.length > 0) {
|
|
const list = uncovered.map(u => ` ${u.table}.${u.column}`).join('\n');
|
|
throw new Error(
|
|
`PGLITE_SCHEMA_SQL has ${uncovered.length} CREATE INDEX column reference(s) ` +
|
|
`that are neither in the table's CREATE TABLE body nor added by ` +
|
|
`applyForwardReferenceBootstrap:\n${list}\n\n` +
|
|
`Fix: extend applyForwardReferenceBootstrap in src/core/pglite-engine.ts ` +
|
|
`(and the matching Postgres engine) with the missing ALTER TABLE ADD COLUMN.`,
|
|
);
|
|
}
|
|
}, 30000);
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// v0.36+ — MIGRATIONS introspection: catch the column-only forward-ref class.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// The CREATE INDEX parser above kills the column-with-index forward-ref class.
|
|
// v0.26.5 (v34) introduced a column-ONLY class: `sources.archived` +
|
|
// `sources.archived_at` + `sources.archive_expires_at` aren't indexed but
|
|
// `CREATE TABLE IF NOT EXISTS sources` is a no-op on pre-v34 brains. The
|
|
// schema-blob replay never adds the archive columns, so downstream visibility
|
|
// filters trip immediately.
|
|
//
|
|
// This test walks every `ALTER TABLE ... ADD COLUMN` in the MIGRATIONS array
|
|
// (our own structured code, not arbitrary Postgres DDL) and asserts every
|
|
// (table, column) pair is also added by `applyForwardReferenceBootstrap`.
|
|
// Future contributors who add a migration with ALTER TABLE ADD COLUMN AND
|
|
// forget to extend the bootstrap will see this test fail at PR time with a
|
|
// paste-ready `Add probe for <table>.<column>` message.
|
|
//
|
|
// Why regex-on-our-own-SQL is safe vs regex-on-prod-Postgres-DDL: every
|
|
// migration's SQL string is authored by us with consistent shape. The
|
|
// ALTER TABLE ADD COLUMN pattern is stable across all 60+ existing
|
|
// migrations. We control the input, not Postgres.
|
|
//
|
|
// Exemption mechanism: some migrations add columns that are intentionally
|
|
// not in the schema blob (one-off transition columns later dropped, etc.).
|
|
// Those go in the COLUMN_EXEMPTIONS set below with a brief rationale.
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
const COLUMN_EXEMPTIONS = new Set<string>([
|
|
// Schema-blob-not-yet-refreshed: each of these columns is added by a
|
|
// migration but NOT (yet) referenced by `PGLITE_SCHEMA_SQL` (neither in a
|
|
// CREATE TABLE body nor in any CREATE INDEX). Bootstrap doesn't need to
|
|
// add them because there's no forward reference for the schema blob's
|
|
// replay to trip on. The migration handles every upgrade path correctly:
|
|
// - fresh install: schema blob replays, then migration adds the column.
|
|
// - pre-existing brain missing the column: migration adds it via ALTER.
|
|
// - pre-existing brain already on this column: ALTER ... IF NOT EXISTS no-ops.
|
|
// If a future migration adds a CREATE INDEX that references one of these
|
|
// columns, the existing v0.28.5 CREATE-INDEX parser will catch it and
|
|
// force a bootstrap probe (and the exemption should be removed).
|
|
//
|
|
// Refreshing PGLITE_SCHEMA_SQL is a separate concern handled by
|
|
// `bun run build:schema` from src/schema.sql; not gated by this test.
|
|
'minion_jobs.quiet_hours',
|
|
'minion_jobs.stagger_key',
|
|
'sources.chunker_version',
|
|
'access_tokens.permissions',
|
|
'takes.resolved_quality',
|
|
'pages.emotional_weight_recomputed_at',
|
|
'facts.notability',
|
|
'facts.row_num',
|
|
'facts.source_markdown_slug',
|
|
'pages.chunker_version',
|
|
'pages.source_path',
|
|
'content_chunks.edges_backfilled_at',
|
|
'query_cache.knobs_hash',
|
|
// v0.40.3.0 (migration v90, renumbered from v0.40.3.0 v81 on master merge)
|
|
// — query_cache is migration-only (added in v55), not in PGLITE_SCHEMA_SQL.
|
|
// The v90 ALTER TABLE query_cache ADD COLUMN page_generations runs after
|
|
// v55 in the migration sequence, so fresh installs get it correctly. No
|
|
// forward-reference exists for PGLITE_SCHEMA_SQL to trip on because
|
|
// query_cache isn't in the schema blob to begin with. Same exemption
|
|
// rationale as knobs_hash.
|
|
'query_cache.page_generations',
|
|
// v0.40.3.0 (migration v91) — same exemption rationale: query_cache is
|
|
// migration-only; max_generation_at_store is added by v91 ALTER and never
|
|
// forward-referenced by PGLITE_SCHEMA_SQL.
|
|
'query_cache.max_generation_at_store',
|
|
// v0.35.6 (migration v67) — typed-claim columns + facts_typed_claim_idx
|
|
// partial index are co-defined in the same migration, so the schema-blob
|
|
// forward-reference path isn't tripped. Bootstrap is only required when an
|
|
// index in PGLITE_SCHEMA_SQL references a column added by a later migration.
|
|
'facts.claim_metric',
|
|
'facts.claim_value',
|
|
'facts.claim_unit',
|
|
'facts.claim_period',
|
|
// v0.40.2.0 (migration v89) — event_type column. Same precedent as
|
|
// facts.claim_metric et al: no forward-reference index in
|
|
// PGLITE_SCHEMA_SQL, no downstream filter breaks on old brains
|
|
// (existing callers — founder-scorecard, eval-trajectory,
|
|
// gbrain think trajectory injection — all defensively skip
|
|
// NULL-metric rows in per-metric math, so event_type=NULL on old
|
|
// brains is invisible to them). Migration is column-only, no FK,
|
|
// no index — bootstrap probe would be pure overhead.
|
|
'facts.event_type',
|
|
// v0.39.1.0 (migration v88) — schema-pack provenance per-source captured as
|
|
// inline canonical closure snapshot on every eval_candidates row. NULL by
|
|
// default; no index in PGLITE_SCHEMA_SQL references it. Migration handles
|
|
// both fresh installs and pre-existing brains via ADD COLUMN IF NOT EXISTS.
|
|
// Schema-pack codegen (scripts/generate-gbrain-base.ts) consumes the value
|
|
// only via the eval-replay CLI, not via SQL filters that would force a
|
|
// bootstrap probe.
|
|
'eval_candidates.schema_pack_per_source',
|
|
// v0.41 (migration v94) — minions cathedral budget columns. Same precedent
|
|
// as facts.claim_metric and friends: column-only additions on `minion_jobs`,
|
|
// no forward-reference index in PGLITE_SCHEMA_SQL (the partial indexes
|
|
// `minion_jobs_budget_owner_idx` + `minion_jobs_budget_root_owner_idx`
|
|
// live INSIDE the same v93 migration, not in the schema blob), and
|
|
// downstream callers explicitly handle NULL via the Eng D10 NULL-bypass
|
|
// branch in budget-tracker (jobs without `budget_owner_job_id` skip
|
|
// reservation entirely). Old brains pre-v93 silently get NULL on these
|
|
// columns; the budget enforcement path treats NULL as "no budget."
|
|
'minion_jobs.budget_remaining_cents',
|
|
'minion_jobs.budget_owner_job_id',
|
|
'minion_jobs.budget_root_owner_id',
|
|
]);
|
|
|
|
test('every ALTER TABLE ADD COLUMN in MIGRATIONS is covered by applyForwardReferenceBootstrap (column-only class)', async () => {
|
|
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
|
|
const { readFileSync } = await import('fs');
|
|
const { resolve: resolvePath } = await import('path');
|
|
const { PGLITE_SCHEMA_SQL } = await import('../src/core/pglite-schema.ts');
|
|
|
|
const enginePath = resolvePath(process.cwd(), 'src/core/pglite-engine.ts');
|
|
const engineSrc = readFileSync(enginePath, 'utf-8');
|
|
const bootstrapAdds = parseAlterAddColumns(engineSrc);
|
|
|
|
// Bootstrap's own CREATE TABLE statements (e.g. needsPagesBootstrap inlines
|
|
// `archived BOOLEAN ...` inside the CREATE TABLE sources block). Those
|
|
// count as covered without a separate ALTER TABLE ADD COLUMN.
|
|
const bootstrapCreateTableCols = parseBaseTableColumns(engineSrc);
|
|
|
|
// PGLITE_SCHEMA_SQL's CREATE TABLE definitions. The schema blob defines
|
|
// every modern table inline; columns added by migrations are typically
|
|
// ALSO updated in the schema blob so fresh installs get them natively.
|
|
// The bootstrap is only needed when: (a) the table existed before the
|
|
// migration ran (so CREATE TABLE IF NOT EXISTS is a no-op on old brains)
|
|
// AND (b) the column has a forward-reference index OR a downstream filter
|
|
// that breaks on old brains. Schema-blob coverage handles the fresh case.
|
|
const schemaCreateTableCols = parseBaseTableColumns(PGLITE_SCHEMA_SQL);
|
|
|
|
const migrationAdds = extractAddedColumnsFromMigrations();
|
|
|
|
const covered = (table: string, column: string): boolean => {
|
|
if (COLUMN_EXEMPTIONS.has(`${table}.${column}`)) return true;
|
|
if (bootstrapAdds.some(a => a.table === table && a.column === column)) return true;
|
|
const bootstrapCols = bootstrapCreateTableCols.get(table);
|
|
if (bootstrapCols && bootstrapCols.has(column)) return true;
|
|
const schemaCols = schemaCreateTableCols.get(table);
|
|
if (schemaCols && schemaCols.has(column)) return true;
|
|
return false;
|
|
};
|
|
|
|
const uncovered: typeof migrationAdds = [];
|
|
for (const ref of migrationAdds) {
|
|
if (!covered(ref.table, ref.column)) {
|
|
uncovered.push(ref);
|
|
}
|
|
}
|
|
|
|
if (uncovered.length > 0) {
|
|
const list = uncovered
|
|
.map(u => ` ${u.table}.${u.column}`)
|
|
.join('\n');
|
|
throw new Error(
|
|
`MIGRATIONS file (src/core/migrate.ts) adds ${uncovered.length} (table, column) pair(s) that ` +
|
|
`applyForwardReferenceBootstrap does NOT cover:\n${list}\n\n` +
|
|
`Fix one of:\n` +
|
|
` 1. Add a probe + ALTER TABLE ADD COLUMN in applyForwardReferenceBootstrap ` +
|
|
`(src/core/pglite-engine.ts AND src/core/postgres-engine.ts), OR\n` +
|
|
` 2. If the column is intentionally not in the schema blob ` +
|
|
`(transitional / handler-only / later-dropped), add the (table, column) ` +
|
|
`to COLUMN_EXEMPTIONS in test/schema-bootstrap-coverage.test.ts with a ` +
|
|
`brief rationale comment.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('extractAddedColumnsFromMigrations sanity-checks against known migration column additions', async () => {
|
|
// Lightweight sanity test that the helper extracts the columns we expect
|
|
// for a few well-known v34 / v60 / v61 migrations. Catches regex
|
|
// regressions in the helper itself.
|
|
const { extractAddedColumnsFromMigrations } = await import('./helpers/extract-added-columns.ts');
|
|
const refs = extractAddedColumnsFromMigrations();
|
|
const has = (table: string, column: string) =>
|
|
refs.some(r => r.table === table && r.column === column);
|
|
// v34 sources.archived* (the codex C1 case)
|
|
expect(has('sources', 'archived')).toBe(true);
|
|
expect(has('sources', 'archived_at')).toBe(true);
|
|
expect(has('sources', 'archive_expires_at')).toBe(true);
|
|
// v60+v61 oauth_clients.*
|
|
expect(has('oauth_clients', 'source_id')).toBe(true);
|
|
expect(has('oauth_clients', 'federated_read')).toBe(true);
|
|
// v18 files.*
|
|
expect(has('files', 'source_id')).toBe(true);
|
|
expect(has('files', 'page_id')).toBe(true);
|
|
});
|
|
|
|
test('extractAlterAddColumnsFromSql handles representative migration SQL shapes', async () => {
|
|
const { __internal } = await import('./helpers/extract-added-columns.ts');
|
|
const fn = __internal.extractAlterAddColumnsFromSql;
|
|
|
|
// Standard shape (with IF NOT EXISTS)
|
|
expect(fn('ALTER TABLE sources ADD COLUMN IF NOT EXISTS archived BOOLEAN')).toEqual([
|
|
{ table: 'sources', column: 'archived' },
|
|
]);
|
|
// No IF NOT EXISTS (older migrations)
|
|
expect(fn('ALTER TABLE pages ADD COLUMN deleted_at TIMESTAMPTZ;')).toEqual([
|
|
{ table: 'pages', column: 'deleted_at' },
|
|
]);
|
|
// Multi-statement, mixed
|
|
expect(fn(`
|
|
CREATE INDEX foo ON bar(x);
|
|
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS source_id TEXT REFERENCES sources(id);
|
|
ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS federated_read TEXT[] NOT NULL DEFAULT '{}';
|
|
UPDATE oauth_clients SET source_id = 'default';
|
|
`)).toEqual([
|
|
{ table: 'oauth_clients', column: 'source_id' },
|
|
{ table: 'oauth_clients', column: 'federated_read' },
|
|
]);
|
|
// Quoted identifiers
|
|
expect(fn('ALTER TABLE "pages" ADD COLUMN "effective_date" TIMESTAMPTZ')).toEqual([
|
|
{ table: 'pages', column: 'effective_date' },
|
|
]);
|
|
// ALTER TABLE IF EXISTS / ONLY variants
|
|
expect(fn('ALTER TABLE IF EXISTS ONLY content_chunks ADD COLUMN language TEXT')).toEqual([
|
|
{ table: 'content_chunks', column: 'language' },
|
|
]);
|
|
});
|
|
|
|
test('planted-bug: simulated unprovided column produces a clear failure message', async () => {
|
|
// Negative case — regression guard. If the contract test silently passes
|
|
// on uncovered columns, the gate is fake. This test plants a fake column
|
|
// in a fake SQL string and verifies the helper extracts it (proving the
|
|
// gate would catch it in the real contract test).
|
|
const { __internal } = await import('./helpers/extract-added-columns.ts');
|
|
const fn = __internal.extractAlterAddColumnsFromSql;
|
|
const planted = fn('ALTER TABLE pages ADD COLUMN IF NOT EXISTS planted_test_col TEXT');
|
|
expect(planted).toEqual([{ table: 'pages', column: 'planted_test_col' }]);
|
|
});
|