mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
v0.42.41.0 fix: triage wave — 6 data-loss/availability fixes + 9 community PRs (#2128)
* fix(oauth): default omitted authorize scope to client's full grant When a client omits `scope` on /authorize, the authorize() grant computed `(params.scopes || []).filter(...)` → the empty set. That empty grant was written to oauth_codes and propagated into the access AND refresh tokens, so every request failed `insufficient_scope` even though the client was registered with e.g. `read write`. Because refresh inherits the stored grant, it never self-healed — reconnecting just minted another empty-scoped token. Some MCP connectors (observed with Claude Desktop) omit `scope` on /authorize, so they hit this on every connection. Fix: when no scope is requested, default to the client's full registered scope (RFC 6749 §3.3 permits a server default). This mirrors exchangeClientCredentials, which already does `requestedScope ? ... : allowedScopes`. The result is still clamped to the allowed set, so an explicit over-broad request cannot escalate. Adds test/oauth-authorize-scope-default.test.ts covering: omitted/empty → inherits full grant; explicit subset honored; clamp preserved (over-broad and disallowed-only requests cannot escalate or trigger inheritance). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): skip Python venv/ in the code walker collectSyncableFiles (first-sync walker) and the incremental PRUNE_DIR_NAMES set skipped node_modules but not Python venv/. On a Python repo the walker descended into venv/ (thousands of files); the resulting slug collisions crashed putPage's INSERT ... ON CONFLICT ... RETURNING with "undefined is not an object (evaluating 'row.deleted_at')". Add `venv` alongside node_modules in both the import.ts inline skip and PRUNE_DIR_NAMES. venv is the Python equivalent of node_modules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gateway): carry asymmetric input_type across the AI SDK to the wire body (#1400) dimsProviderOptions() threads input_type ('query' | 'document') into providerOptions.openaiCompatible for asymmetric models (ZE zembed-1, Voyage v3+), but the AI SDK's openai-compatible adapter validates providerOptions against a fixed schema and silently drops the field before building the HTTP body. Every embedQuery() was therefore encoded document-side: the ZE shim's hard default fired ('document'), Voyage and local openai-compat servers got no input_type at all, and asymmetric retrieval silently collapsed toward surface-token overlap — while the providerOptions-level contract test stayed green. Fix: an AsyncLocalStorage (same pattern as __budgetStore) populated in embedSubBatch() only when providerOptions actually threads an input_type, read at body-rewrite time by the fetch shims: - zeroEntropyCompatFetch: recovers the threaded value; document default preserved for ingest paths. - voyageCompatFetch: opt-in like the dims.ts Voyage branch — inject only when threaded; the field stays off the wire otherwise. - NEW openAICompatAsymmetricFetch: fallthrough default for every other openai-compatible recipe (llama-server, litellm, ollama, ...) — the canonical local/proxy paths for asymmetric models. Strict pass-through when nothing was threaded, so symmetric deployments see zero wire change; recipes with their own compat fetch (azure) keep it via the compat.fetch ?? precedence. KNOBS_HASH_VERSION bumped 10→11: cached query_cache rows were keyed on document-side query vectors; pre-fix rows must not be served to post-fix lookups (same convention as the v=3 embedding-provider bump). One-time global cold-miss on upgrade; refills within cache.ttl_seconds. Tests: test/embed-input-type-wire.test.ts runs the REAL SDK transport with a mocked global fetch and asserts on the outbound body — the only layer where this regression is observable. Covers ZE hosted, llama-server, litellm, ollama (query + document sides) and pins the pass-through for non-asymmetric models and Voyage's opt-in shape. 4 of the original 7 assertions fail on master, proving the pin. One structural pin in test/ai/zeroentropy-compat-fetch.test.ts updated to the new line shape (same semantic); KEY_FILES.md gateway.ts entry updated to the new truth. Supersedes #1400 (closed unmerged) — same ALS mechanism, extended to Voyage + all openai-compatible recipes. Credit to @billy-armstrong for the original diagnosis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sync): honor .gitignore in code walk; prune vendor/dist/build collectSyncableFiles (the full-sync / dry-run enumerator) reimplemented its own directory skip list inline (node_modules || ops), bypassing the canonical pruneDir gate and ignoring .gitignore entirely. On a Laravel/PHP repo this descended into vendor/ (~50k Composer files), storage/, and public/build/, trying to import 52k dependency/build files and flooding the index with library internals (a 35-min sync that never finished, killed by the watchdog at 3%). - collectSyncableFiles now enumerates via `git ls-files --cached --others --exclude-standard` when dir is a git work tree, so the walk honors .gitignore (tracked + untracked-not-ignored). Falls back to the FS walk for non-git dirs. EroLab: 52164 -> 1028 files. - The FS fallback now prunes through the canonical pruneDir() instead of a drifted inline list, so the two skip lists can't diverge again. - PRUNE_DIR_NAMES gains vendor/dist/build (dependency + build-output trees). Addresses #1483 (.gbrainignore), #1159 (--respect-gitignore), and the maintainer's #1942 vendor/dist/build prune. Walker regression suites (sync-walker-symlink, brain-writer-walk-prune, sync, sync-walker-submodule) green: 90 pass. * fix(config): ignore DATABASE_URL auto-loaded from cwd .env (#427) Bun merges .env files from the process cwd into process.env before any user code runs. loadConfig() prefers env DATABASE_URL over ~/.gbrain/config.json, so any gbrain invocation from inside a web-app checkout silently retargets the brain at that app's database — reads go to the wrong DB and apply-migrations can write gbrain's schema into a production app database (#427). effectiveEnvDatabaseUrl() re-parses the .env files Bun auto-loads from cwd and treats a DATABASE_URL whose value matches one of them as file-origin: ignored, with a one-time stderr notice. GBRAIN_DATABASE_URL and genuinely exported DATABASE_URLs are honored unchanged, so the operator escape hatch and the e2e suite's env-provided URL keep working. Applied at loadConfig, getDbUrlSource (doctor parity), init --non-interactive, and migrate --to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): arm the disconnect hard-deadline at teardown entry, not before the op body The 10s force-exit timer in the shared-op dispatch was armed BEFORE the try block, so any op whose handler ran past 10s wall-clock was killed mid-flight with process.exit(0) and zero stdout. On a slow Postgres pooler (6-10s per fresh connection) a healthy `gbrain search` was force-exited every time — an empty 'success' indistinguishable from no results. The v0.42.20.0 exitCode honor can't help: a mid-op kill fires before any error path sets exitCode. Move the arming into the finally (teardown entry), matching the fall-through owner-disconnect site later in main(): the timer still bounds a hung drain/disconnect (the C13 contract) but can no longer kill a slow-but-progressing op. Verified on a transaction-pooler Supabase brain: search went from 0 bytes/exit 0 at 10s to real results at ~21s. * fix(import): stamp source_id on extracted call-graph edges importCodeFile built CodeEdgeInput rows without source_id, so every edge landed NULL. getCallersOf/getCalleesOf filter `AND source_id = <scoped>` whenever a worktree pin or --source is in play — NULL never matches, so scoped call-graph queries silently returned 0 rows on multi-source brains even though the edges existed (2,122 edges, 26 targeting the probed symbol, count 0 returned). One-line fix: carry the sourceId already in scope into the edge input. Existing NULL rows backfill with: UPDATE code_edges_symbol e SET source_id = p.source_id FROM content_chunks c JOIN pages p ON p.id = c.page_id WHERE c.id = e.from_chunk_id AND e.source_id IS NULL; (same for code_edges_chunk). Verified: code-callers returns 21 callers where it returned 0. * docs(migrations): NULL embeddings BEFORE the column-type alter The Postgres recipe ordered ALTER COLUMN TYPE vector(N) before the UPDATE that clears stale embeddings. pgvector refuses to cast existing vectors across dimensions ('expected 1024 dimensions, not 1536'), so the recipe as written aborts the transaction on any brain that has embeddings — which is every brain doing this migration. Swap the steps: NULLs cast fine. * fix: honor legacy token source grants in oauth * fix(cli): bound read-scope op handlers at 180s wallclock (pre-landing review) With the hard-deadline timer correctly scoped to teardown, a genuinely wedged read handler (hung pooler connection mid-query) would hang the CLI forever — the #1633 zombie class the old pre-try timer accidentally bounded at 10s. Reads now get a generous withTimeout (180s default, far above any healthy slow-pooler run; --timeout=Ns overrides; exit 124 with the teardown finally still draining + disconnecting). Writes/admin stay unbounded: a long import/embed must never be killed by a default. * fix(import): stamp unscoped edges 'default', matching the pages-table default Review catch: 'sourceId ?? null' fixed the scoped path but left the unscoped one (reindex --code without --source, importCodeFile callers without opts.sourceId) stranding edges at NULL while their pages land under the schema default (pages.source_id DEFAULT 'default') — so getCallersOf(sym, { sourceId: 'default' }) missed them. Same bug, other door. Fallback is now 'default'. * fix(core): runtime dim-migration recipe NULLs embeddings before the alter Review catch: the doc fix corrected docs/embedding-migrations.md, but embeddingMismatchMessage still PRINTED the broken order — ALTER before UPDATE ... SET embedding = NULL — and linked to the now-contradicting doc. pgvector refuses to cast existing vectors across dimensions, so the printed recipe aborted on any brain that has embeddings. Swap the steps and say why inline. * feat(migrate): v116 — backfill NULL edge source_id + index from_symbol_qualified 1. Backfill: edges written before the stamping fix sit at source_id=NULL and stay invisible to scoped call-graph queries until repaired. Derive each edge's source from its own from_chunk's page (pages.source_id is NOT NULL DEFAULT 'default'). Same SQL verified live on a 2,122-edge production brain. 2. Indexes: getCalleesOf filters both edge tables on from_symbol_qualified, which had no index — every callee lookup was a seq scan, amplified per-BFS-node by the recursive code walk. With NULL edges repaired, scoped walks actually expand, so the latent cost becomes real. Mirrored into src/schema.sql; schema-embedded.ts regenerated. * docs(migrations): align the rationale list with the corrected recipe order The 'Why we don't do this automatically' list still said alter-then-wipe; reorder to wipe-then-alter and replace the fragile 'step 3' numeric cross-reference with a name-based one. * test: regression coverage for edge source_id stamping, timer placement, recipe order - import-code-edges-source-id: scoped import stamps edges + scoped getCallersOf/getCalleesOf match (verified failing pre-fix), plus the unscoped-import case asserting 'default' stamping. - cli-force-exit-teardown-arming: structural pin — the hard-deadline timer arms inside the finally (teardown entry), never before the op body; daemon guard, unref, clearTimeout intact. - embedding-dim-check: recipe order pinned — UPDATE precedes ALTER so the printed SQL can't drift from docs/embedding-migrations.md again. * fix(cli): hard-exit after teardown on wallclock timeout; bound makeContext too Adversarial review, two findings on the new timeout path: 1. On timeout the finally drained, disconnected, then CLEARED the hard-deadline timer — removing the only backstop while the abandoned handler (withTimeout races, it does not cancel) can hold ref'd sockets/SDK timers that keep Bun's loop alive: 'timed out' printed, process immortal — the zombie class this branch exists to kill, resurrected through its own fix. The finally now exits explicitly after teardown completes on the timeout path. 2. makeContext does DB I/O (resolveSourceId) for EVERY op and sat outside any bound — a pooler wedge at context build hung reads, writes, and admin alike. It now shares the same wallclock bound. * fix(import): normalize edge source once — closes the '' door and the unscoped chunk fan-out Adversarial review: txOpts used truthiness while the edge stamp used nullish — sourceId:'' put pages under 'default' but stamped edges '', FK-violating against sources(id) and silently dropping the file's whole call graph in the best-effort catch. The unscoped getChunks could also fan out to same-slug chunks from another source. One normalized edgeSourceId (sourceId || 'default') now drives both the chunk lookup and the stamp. * fix(engine): default edge source_id to 'default' at the insert layer (both engines) Adversarial review: addCodeEdges still wrote e.source_id ?? null, so any future caller that forgets the field reintroduces invisible NULL edges the day after the v116 backfill runs. A NULL source_id is invisible to every scoped call-graph query; default to the schema-default source the way the pages table does. Applied to both engines (parity). * fix(core): facts alter recipe NULLs embeddings before cross-dimension alters Adversarial review: buildFactsAlterRecipe shipped the same defect class this branch fixes for content_chunks 350 lines up — a cross-dimension ALTER ... USING cast that pgvector refuses while rows hold old-width vectors. Dimension changes now wipe first (the facts pipeline re-embeds on next write); same-dim type swaps (halfvec <-> vector) keep the lossless cast and PRESERVE data. Both behaviors pinned by tests. * v0.42.39.0 chore: version bump + CHANGELOG + TODOS Marks the v0.42.20.0 'decouple the op-dispatch force-exit timer' follow-up complete — this branch ships exactly that decoupling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(postgres-engine): atomic JSONB merge in updateSourceConfig — eliminate lost-update race ## Problem `updateSourceConfig` used a read-then-write pattern: read the current `config` row, normalize it in JavaScript, then write the merged result back with `SET config = <normalized> || <patch>`. Under concurrent callers (two background autopilot/cycle paths patching different keys simultaneously), both callers can read the same stale row. The later `SET config = ...` then clobbers the earlier patch, silently dropping whatever keys the first caller wrote. Reproduced at 21/25 lost-update events under real Postgres with parallel callers. ## Fix Fold the normalization and merge into a single atomic `UPDATE … SET config = CASE … END || patch` statement. Because the `SET` expression evaluates against the row-locked latest version of `config`, there is no snapshot window between the read and the write. Concurrent callers now converge correctly (50/50 clean in reproduction test). The `CASE` also normalizes historical bad JSONB shapes inline: - `object` — used as-is - `string` — double-encoded config; inner text parsed with the SQL `IS JSON` guard (Postgres 16+) so unparseable strings fall back to `{}` instead of raising `invalid input syntax for type json` - `array` — array of patch objects aggregated into a flat object via `jsonb_object_agg` - anything else — falls back to `{}` `pglite-engine.updateSourceConfig` already used an atomic `||` merge; this change brings postgres-engine to parity. ## Test Added two assertions to `test/list-all-sources.test.ts`: 1. JSONB string holding non-JSON text normalizes to `{}` (no cast throw) 2. JSONB string holding double-encoded valid JSON is parsed then merged * fix(doctor): five correctness fixes — stale locks, content sanity, graph coverage, exit code, gateway guard ## 1. Stale lock break hints cover gbrain-cycle: keys The doctor stale-lock report only recognized `gbrain-sync:` lock prefixes; everything else fell back to `gbrain sync --break-lock`, which is wrong for dream/autopilot cycle locks. A `gbrain-cycle:<source>` or `gbrain-cycle` lock now suggests `gbrain dream --break-lock [--source <name>]`, and unknown lock shapes fall back to `gbrain doctor` instead of a misleading sync command. ## 2. content_sanity_audit_recent counts reject and quarantine as hard failures v0.42 renamed the hard disposition path: rejected pages emit a `reject` event and quarantined junk pages emit `quarantine`; `hard_block` is now only the pre-v0.42 legacy alias. The status check only counted `hard_block`, so fresh `reject` / `quarantine` events from the new path cleared as `ok` whenever fewer than 10 events existed. The check now sums all three for the hard count, and `soft_block + flag` for the soft count. ## 3. graph_coverage excludes test fixture entity pages from the denominator Brains seeded with code sources (e.g. a sync of the gbrain repo itself) could accumulate test fixture pages typed as `entity` / `person`. Including these in the entity-count denominator diluted coverage and produced spurious warnings ("Entity link coverage 0%, timeline 0%") on knowledge-only brains with no real entity pages. The check now queries a per-entity stats CTE that excludes `tools/gbrain/test/*` slugs and the `templates/new-person` stub, with an additional guard for the all-fixture case (`eligibleEntityCount = 0`). ## 4. process.exitCode instead of process.exit at doctor main exit point `process.exit(hasFail ? 1 : 0)` was a hard kill that prevented cleanup handlers (Bun unload events, open DB connections) from running. Using `process.exitCode = hasFail ? 1 : 0` defers the actual termination until the end of the event loop, allowing cleanup to complete. ## 5. checkSubagentCapability exported for test seams + gateway loop guard The function was private, making it untestable in isolation. It is now exported. Additionally, users running gbrain with a non-Anthropic chat model via `agent.use_gateway_loop=true` no longer receive a spurious warning that `ANTHROPIC_API_KEY` is missing — subagents route via the gateway loop in that configuration and do not need the key directly. ## Tests Doctor test suite: 77 pass, 0 fail (no regressions). * fix(engine): deleteFactsForPage excludeSourcePrefixes (#1928) + reconnect() parity (#2034) Engine-layer API for two cycle/availability fixes that share these files: - deleteFactsForPage gains optional excludeSourcePrefixes so the fence reconcile can protect non-fence facts (e.g. cli: conversation facts). - reconnect(ctx?) is now a first-class BrainEngine method on both engines (PostgresEngine already had it; PGLite gains config capture + reconnect) so callers stop using disconnect()+bare connect(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cycle): stop extract_facts from wiping conversation facts (#1928) The fence reconcile delete-then-reinsert wiped cli:-origin facts (no fence to recreate them); a failed-sync full walk turned it brain-wide (1829 rows, 0 reinserted, status ok). Now: exclude cli: rows from the wipe, do NOT inherit the failed-sync->full-walk fallback for this destructive phase, and warn on net-negative reconcile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(autopilot,supervisor): reconnect() instead of disconnect()+bare connect() (#2034) The autopilot health-probe recovery called connect() with no args after disconnect(), losing the startup config (database_url undefined -> FATAL restart-loop on every DB blip) and opening a null-pool window. Both call sites now use engine.reconnect(), which restores the captured config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(write-through): mirror to the assigned source's local_path, never the global repo (#2018) put_page write-through resolved the disk target from the global sync.repo_path, so a default-source page (local_path NULL) got written into an unrelated federated source's working tree. Now it uses the assigned source's own local_path; NULL local_path skips (no leak); the global path is used only as a sole-source fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pglite-lock): heartbeat + steal-grace so live holders are never stolen (#2058) A live holder's lock was force-removed after 5min age alone, letting a second process share the single-writer data dir -> WAL corruption. The lock now heartbeats while held; a holder is reaped only when its PID is dead OR its heartbeat went stale past the steal grace. Pairs PID liveness with heartbeat age to also defeat PID reuse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrate,doctor): self-heal idx_timeline_dedup drift (#2038) A migration renumbered during a merge (v102) could be recorded-as-applied without its DDL running, leaving the 3-column index so every timeline write failed the 4-column ON CONFLICT. runMigrations now always runs a shape-keyed drift repair (dedupe-then-rebuild) even when no migration is pending, and doctor surfaces the drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(timeline): un-silence the swallowed batch catch; pin Date-batch round-trip (#2057) The meetings extractor's bare catch {} hid a brain-wide timeline-write failure (0 entries, no error). It now counts + surfaces batch errors. Adds a Date-bearing batch regression test proving the #1861 jsonb_to_recordset refactor already fixed the original ::text[] cast failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v0.42.41.0) Triage fix wave: 6 authored critical fixes (#1928 facts wipe, #2018 write-through leak, #2034 reconnect loop, #2058 WAL lock, #2038 timeline migration drift, #2057 timeline silent-empty) + community PRs #2064 #2052 #2020 #2033 #2074 #2075 #2009 #2072 #2073. TODOS: deferred #1994 #1963 #2050. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address adversarial review findings (#1928, #2058, #2038, #2057) Codex as-built review of the authored fixes surfaced 4 real issues: - #2058: add a pid+acquired_at ownership token. A stale holder reaped + replaced past the grace must NOT let its resumed heartbeat refresh, nor releaseLock remove, the NEW owner's lock (re-opened the concurrent-writer hole). Heartbeat and release now verify the on-disk lock is still ours. + regression test. - #1928: the destructive-full-walk guard keyed off phases.includes('sync'), which wrongly suppressed a legitimate full reconcile when sync was SKIPPED (no engine / no brainDir). Key off a syncAttempted flag set only when sync actually ran. - #2038: dedupe keeps MIN(id) not MIN(ctid) — deterministic and consistent with the existing v-migration lower-id rule. - #2057: the extract CLI caller now surfaces batch_errors (stderr + exit 1) instead of printing a clean success over failed inserts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(key-files): sync reference to v0.42.41.0 triage-wave behavior Update KEY_FILES.md to current-state truth for the shipped fixes (no release-history clauses, per the reference-doc discipline): - write-through.ts (#2018): resolves the disk target from the assigned source's own local_path; sole-source falls back to sync.repo_path, multi-source skips with source_has_no_local_path rather than leak. - engine.ts (#2034): reconnect() is now a REQUIRED lifecycle method on both engines; config-restoring, never disconnect()+bare connect(). - migrate.ts (#2073): document v116 edge source_id backfill + callee index, and the always-run (version-counter-blind) timeline dedup self-heal. - new entry for timeline-dedup-repair.ts (#2038) + the timeline_dedup_index doctor check. - new entry for pglite-lock.ts (#2058): heartbeat + steal-grace (GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS) so a live holder is never stolen. - extract-facts.ts (#1928): cli:-fact protection, no failed-sync full-walk inheritance, net_fact_deletion warn floor. bun run build:llms re-run (KEY_FILES is link-only so bundles unchanged); freshness + current-state guards green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(write-through): preserve nested multi-source layout; narrow #2018 leak guard The first #2018 fix skipped any no-local_path source on a multi-source brain, which broke the legitimate nested layout (a source without its own tree nests under the host repo at .sources/<id>/ — pinned by put-page-write-through.test). Narrow the guard: a no-local_path source nests under sync.repo_path as before; only SKIP when sync.repo_path is literally another source's own local_path (the actual leak — writing there pollutes that sibling's repo). Caught by the sharded suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: satisfy test-isolation guard for the new lock/reconnect tests CI `verify` flagged 3 intra-process isolation violations in the tests added this wave (the parallel runner shares one process per shard): - pglite-lock.test.ts: the GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS mutation now goes through withEnv() instead of a raw process.env write (R1). - pglite-reconnect: renamed to *.serial.test.ts — it creates per-test engines to exercise the connect/reconnect lifecycle, which doesn't fit the shared beforeAll-engine model (R3/R4). verify is now 30/30; both files green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pglite): reconnect() is a no-op for in-memory engines (#2034) CI serial-tests + test(5) caught two in-branch regressions from the #2034 PGLite reconnect(): - worker/queue claim-error recovery + their renewLock e2e test assume PGLite reconnect is absent/no-op (queue.ts documents it). Making it a real disconnect+reopen wiped an in-memory engine's state mid-job. reconnect() now no-ops for in-memory (no database_path) — file-backed still re-opens the dir (state persists on disk). Restores the documented worker assumption. - connection-resilience 'Supervisor still has the 3-strikes-then-reconnect path' pinned the removed unsafe-cast text; updated to assert the direct this.engine.reconnect() call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: quarantine embed-input-type-wire to serial lane (CI test(5) leak) #2033's embed-input-type-wire.test.ts configures a 1280-dim embedding gateway; the active dimension survived into engine-find-trajectory when CI's 10-way hash-disjoint sharding co-located them (this branch's added files reshuffled the assignment), failing 7 trajectory tests with 'expected 1280 dimensions, not 1536'. resetGateway() in afterEach clears the gateway but the dimension still leaked. It mutates global gateway/embedding state, so it belongs in the serial lane (own bun process, true isolation) by the repo's own definition. Root-caused by reproducing the exact failing pair locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Austin Arnett <austin@sdsconsultinggroup.org> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Dave MacDonald <djmacdonald@ucdavis.edu> Co-authored-by: pabloglzg <186649799+pabloglzg@users.noreply.github.com> Co-authored-by: Alex P. <12667893+aphaiboon@users.noreply.github.com> Co-authored-by: Garry Tan <bo.m.liu@gmail.com> Co-authored-by: jbarol <barol.j@gmail.com> Co-authored-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com> Co-authored-by: PAI <pai@scaffolde.ai>
This commit is contained in:
co-authored by
Austin Arnett
Claude Opus 4.8
Dave MacDonald
pabloglzg
Alex P.
Garry Tan
jbarol
maxpetrusenkoagent
PAI
parent
ecd6ae8772
commit
7c27fa129b
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.42.41.0] - 2026-06-11
|
||||
|
||||
**A correctness-and-reliability wave: your conversation facts survive a cycle, write-through stops polluting other repos, autopilot rides out a DB blip instead of crash-looping, and concurrent PGLite processes stop corrupting each other.** A triage of open reports surfaced six bugs with no fix yet plus a batch of community PRs; this ships them together, each with a regression test.
|
||||
|
||||
The headline is data durability. The `extract_facts` cycle phase reconciles a page's facts from its `## Facts` fence by deleting then reinserting — but conversation facts (written by `extract-conversation-facts`) live on pages that have no fence, so a cycle could delete them and reinsert nothing. A failed sync made it worse by escalating the phase to a full-brain walk. Both are fixed: the reconcile now protects non-fence (`cli:`-origin) facts, the destructive phase no longer inherits the failed-sync full-walk, and a reconcile that removes far more than it adds now reports `warn` instead of a silent `ok`.
|
||||
|
||||
Three more silent failures, each found in production and now self-healing or loud:
|
||||
- **Timeline writes that silently stopped.** A migration renumbered during a merge could be recorded as applied without its index change ever running, so every timeline insert failed its `ON CONFLICT`. `gbrain` now repairs the index shape on every migrate pass (dedupe-then-rebuild, even when nothing is pending), `gbrain doctor` reports the drift, and the meetings extractor no longer swallows batch errors.
|
||||
- **Autopilot crash-loop on transient DB errors.** The health-probe recovery called `connect()` without its config, so every reconnect threw and the process exited on any blip. It now uses `reconnect()`, which restores the captured config; `reconnect()` is a first-class method on both engines.
|
||||
- **WAL corruption from concurrent PGLite processes.** A live, working process holding the data-dir lock could have it stolen after five minutes. The lock now heartbeats while held and is only reclaimed from a dead or genuinely stalled holder.
|
||||
|
||||
### Added
|
||||
- **`timeline_dedup_index` doctor check + always-run repair (#2038).** Detects and heals a stale `idx_timeline_dedup` shape; `gbrain apply-migrations --force-schema` triggers the repair on demand. Reported by @jbarol.
|
||||
- **`BrainEngine.reconnect()` on both engines (#2034).** Config-restoring reconnect, replacing the `disconnect()`+bare-`connect()` pattern.
|
||||
|
||||
### Fixed
|
||||
- **Conversation facts survive a fence reconcile (#1928).** The cycle's per-page wipe now excludes non-fence (`cli:`) facts, the destructive phase no longer full-walks on a failed sync, and net-negative reconciles surface as `warn`.
|
||||
- **`put_page` write-through no longer leaks into an unrelated source's repo (#2018).** It mirrors to the assigned source's own `local_path`; a source without one is skipped rather than written into the global repo path.
|
||||
- **Autopilot survives transient DB errors instead of crash-looping (#2034).**
|
||||
- **Concurrent PGLite processes no longer corrupt the WAL (#2058).** Heartbeat + steal-grace replaces the age-only stale-lock check.
|
||||
- **Timeline migration drift self-heals; the extractor stops swallowing errors (#2038, #2057).** A Date-typed batch date round-trips correctly (verified by test).
|
||||
- **A cwd `.env` `DATABASE_URL` no longer silently retargets the brain (#2064, closes #427).** Reported by @bomliu.
|
||||
- **`gbrain sync --strategy code` honors `.gitignore` and skips `vendor`/`dist`/`build`/`venv` (#2052, #2020).** Reported by @aphaiboon and @dMac716.
|
||||
- **Asymmetric embedding `input_type` reaches the wire across every openai-compatible recipe (#2033, supersedes #1400).** Query vectors are no longer document-typed. By @pabloglzg; original diagnosis by @billy-armstrong.
|
||||
- **`updateSourceConfig` JSONB merge is atomic (#2074).** Eliminates a concurrent-writer lost-update race. Reported by @pai-scaffolde.
|
||||
- **`gbrain doctor` correctness pass (#2075):** stale-lock hints, content sanity, graph coverage, exit code, gateway guard. Reported by @pai-scaffolde.
|
||||
- **`gbrain search` returns results instead of exiting 0 empty on slow poolers; scoped `code-callers`/`code-callees` find their edges (#2073).** Migration v116 backfills NULL edge `source_id` and indexes `from_symbol_qualified`. Reported by @jbarol.
|
||||
- **OAuth scope handling (#2009, #2072):** an omitted authorize scope now defaults to the client's registered grant (clamped to it, so no widening) instead of an empty grant that never self-heals; legacy token source grants are honored through a single shared scope parser. By @austinrarnett and @maxpetrusenkoagent.
|
||||
|
||||
### To take advantage of v0.42.41.0
|
||||
|
||||
`gbrain upgrade`. The timeline-index repair and migration v116 run automatically on the next migrate pass — if `gbrain doctor` flagged a timeline or call-graph problem before, re-run it after upgrade. No config changes required; the facts, write-through, autopilot, and lock fixes apply on restart.
|
||||
|
||||
## [0.42.40.0] - 2026-06-09
|
||||
|
||||
**`gbrain extract --stale` no longer aborts partway through a brain that contains emoji or other non-BMP characters.** On a large brain, link/timeline extraction could die with `invalid input syntax for type json` and commit nothing — and because the staleness bookmark only advances on a clean finish, every retry re-hit the same point and extraction stayed wedged. The cause: the link-context excerpt was sliced by raw UTF-16 index, so a window boundary landing inside an emoji's surrogate pair left an unpaired surrogate half in the text, which Postgres rejects when the batch is serialized to JSONB — taking down the whole batch, not just the one row. (PGLite is more permissive here, so this primarily bit the managed-Postgres engine.)
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# TODOS
|
||||
|
||||
## gbrain triage wave follow-ups (filed v0.42.41.0)
|
||||
|
||||
Deferred from the v0.42.41.0 fix wave (eng-reviewed as separate scope, not hotfixes).
|
||||
See plan + GSTACK REVIEW REPORT at
|
||||
`~/.claude/plans/system-instruction-you-are-working-zany-thacker.md`.
|
||||
|
||||
- [ ] **P1 — supervisor: retry-with-backoff instead of hard stop on transient DB outages (#1994).**
|
||||
`max_crashes_exceeded` gives up permanently; a transient pooler blip that trips the
|
||||
counter wedges the supervisor until manual restart. **Why:** the #2034 reconnect fix
|
||||
makes the engine recover, but the supervisor still hard-stops. **Where:**
|
||||
`src/core/minions/supervisor.ts` crash-count loop — add exponential backoff with a
|
||||
much higher (or no) permanent-give-up threshold for recoverable errors.
|
||||
- [ ] **P2 — PGLite `reindex-frontmatter` / backfill statement_timeout boost (#1963).**
|
||||
Community RCA: `SET LOCAL statement_timeout` is gated on `engine.kind === 'postgres'`,
|
||||
so PGLite inherits the 30s session default and trips on non-trivial batches; the CLI
|
||||
then swallows the error and exits 0. **Where:** `src/core/backfill-effective-date.ts`
|
||||
(boost on PGLite too, or per-row updates) + the cli.ts catch that hides it.
|
||||
- [ ] **P2 — autopilot drain-worker concurrency self-deadlock (#2050).** Drain-worker
|
||||
runs at concurrency=1, so any cycle phase that spawns a subagent (patterns, synthesize)
|
||||
deadlocks waiting on a worker slot it can't get. **Where:** autopilot drain-worker
|
||||
dispatch — raise concurrency or exempt subagent-spawning phases.
|
||||
- [ ] **P3 — name-keyed migration ledger (#2038 structural follow-up).** The always-run
|
||||
index drift probe heals the one known case; the general fix is keying applied-migration
|
||||
tracking by stable name rather than version integer so a renumber can't strand a
|
||||
migration as recorded-but-not-executed. **Where:** `src/core/migrate.ts` ledger.
|
||||
|
||||
## gbrain#1981 Retrieval Reflex follow-ups (v0.43+)
|
||||
|
||||
Filed from the #1981 ship (v0.42.39.0). Deliberately scoped OUT — the v1 extractor
|
||||
@@ -1800,11 +1826,6 @@ Three items deferred:
|
||||
self-heals via stale-reclaim). The common sync SUCCESS path already drains via
|
||||
handleCliOnly's finally. Convert for graceful drain on sync error exits.
|
||||
|
||||
- [ ] **(v0.42.20.0 follow-up) Decouple the op-dispatch force-exit timer** so it
|
||||
wraps `engine.disconnect()` only (it's armed before the handler today, doubling
|
||||
as a blanket handler watchdog) and fix its misleading "engine.disconnect() did
|
||||
not return…" message that fires even when the handler (not disconnect) was slow.
|
||||
|
||||
- [ ] **(v0.42.20.0 follow-up) Gateway idle-timeout (vs absolute) for streaming
|
||||
chat.** `withDefaultTimeout` uses an absolute `AbortSignal.timeout`; a streaming
|
||||
generation actively producing tokens past the chat default (300s) would abort.
|
||||
@@ -3574,6 +3595,18 @@ keeping both skills' triggers intact for chaining.
|
||||
|
||||
## Completed
|
||||
|
||||
### ~~(v0.42.20.0 follow-up) Decouple the op-dispatch force-exit timer~~
|
||||
**Completed:** v0.42.39.0 (2026-06-10)
|
||||
|
||||
The timer now arms at teardown entry (inside the op-dispatch finally, before
|
||||
drain + disconnect) so it bounds ONLY disconnect — no longer doubling as a
|
||||
blanket handler watchdog that killed slow-but-healthy ops at 10s with exit 0
|
||||
and empty stdout. Its "engine.disconnect() did not return…" message is now
|
||||
accurate by construction (it can only fire during teardown). Read-scope
|
||||
handlers + context build got their own explicit wallclock bound (180s default,
|
||||
`--timeout=Ns`, exit 124, hard-exit after teardown) in the same wave. Pinned by
|
||||
`test/cli-force-exit-teardown-arming.test.ts`.
|
||||
|
||||
### ~~Checks 5 + 6 for check-resolvable~~
|
||||
**Completed:** v0.19.0 (2026-04-22)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -43,8 +43,8 @@ genuinely has to change.
|
||||
Switching dimensions requires:
|
||||
|
||||
1. Dropping the HNSW vector index (pgvector won't survive an `ALTER COLUMN TYPE`).
|
||||
2. Altering the column type (Postgres only — PGLite cannot do this).
|
||||
3. Wiping every existing embedding (the old vectors are unusable in the new space).
|
||||
2. Wiping every existing embedding (the old vectors are unusable in the new space — and pgvector refuses to cast them across dimensions, so this must happen before the alter).
|
||||
3. Altering the column type (Postgres only — PGLite cannot do this).
|
||||
4. Re-embedding the entire corpus (can take hours on a 50K-page brain and costs $1-100 in API calls depending on model).
|
||||
5. Conditionally recreating the index (HNSW supports up to 2000 dimensions per pgvector; above that you must use exact scans).
|
||||
|
||||
@@ -115,12 +115,17 @@ BEGIN;
|
||||
-- 1. Drop the HNSW index. It can't survive the column type change.
|
||||
DROP INDEX IF EXISTS idx_chunks_embedding;
|
||||
|
||||
-- 2. Alter the column type.
|
||||
ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(<NEW_DIMS>);
|
||||
|
||||
-- 3. Clear stale embeddings so they don't survive into the new space.
|
||||
-- 2. Clear stale embeddings FIRST. This must happen BEFORE the column
|
||||
-- alter: pgvector refuses to cast existing vectors across dimensions
|
||||
-- ("expected <NEW_DIMS> dimensions, not <OLD_DIMS>"), so altering a
|
||||
-- column that still holds old-width vectors aborts the transaction.
|
||||
-- NULLs cast fine. (The old vectors are unusable in the new space
|
||||
-- anyway — this is the wipe step from the rationale above.)
|
||||
UPDATE content_chunks SET embedding = NULL, embedded_at = NULL;
|
||||
|
||||
-- 3. Alter the column type (all rows are NULL now, so the cast succeeds).
|
||||
ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(<NEW_DIMS>);
|
||||
|
||||
-- 4. Recreate the HNSW index ONLY IF dims <= 2000. Above that, leave it
|
||||
-- indexless and rely on exact scans (gbrain searchVector handles this
|
||||
-- automatically — search just gets slower, not broken).
|
||||
|
||||
+1
-1
@@ -143,5 +143,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.42.40.0"
|
||||
"version": "0.42.41.0"
|
||||
}
|
||||
|
||||
+102
-23
@@ -360,30 +360,83 @@ async function main() {
|
||||
//
|
||||
// Defense-in-depth (adversarial-review C13): `engine.disconnect()` itself
|
||||
// can hang on PGLite (db.close() or releaseLock racing OS-level FS state).
|
||||
// Install an unref'd setTimeout hard-exit fallback BEFORE entering the
|
||||
// try/catch/finally so a hung disconnect cannot defeat the force-exit
|
||||
// contract. Daemons (`serve`) are excluded so they stay alive.
|
||||
// The unref'd hard-exit fallback is armed inside the `finally` below, so it
|
||||
// bounds ONLY the teardown phase (drain + disconnect) — the same placement
|
||||
// as the fall-through owner-disconnect site later in this file. It used to
|
||||
// be armed HERE, before the try, which silently killed any op whose BODY ran
|
||||
// past the deadline: on a slow Postgres pooler (6-10s per fresh connection)
|
||||
// a healthy `gbrain search` was force-exited mid-handler with code 0 and
|
||||
// ZERO stdout — an empty "success" indistinguishable from no results. The
|
||||
// exitCode honor (v0.42.20.0) can't help there: a mid-op kill fires before
|
||||
// any error path sets exitCode. Op-body wallclock bounds are the read-scope
|
||||
// withTimeout wrap inside the try below, not this teardown backstop.
|
||||
// Daemons (`serve`) are excluded so they stay alive.
|
||||
const DISCONNECT_HARD_DEADLINE_MS = 10_000;
|
||||
// Wallclock bound for READ-scope op handlers. With the hard-deadline timer
|
||||
// correctly scoped to teardown, a genuinely WEDGED read handler (hung pooler
|
||||
// connection mid-query) would otherwise hang the CLI forever — the #1633
|
||||
// zombie class the old (buggy) pre-try timer accidentally bounded at 10s.
|
||||
// 180s sits far above any healthy slow-pooler run (6-10s/connection);
|
||||
// --timeout=Ns overrides. Writes/admin stay unbounded: a long import/embed
|
||||
// must never be killed by a default deadline.
|
||||
const READ_OP_TIMEOUT_MS = 180_000;
|
||||
let forceExitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (shouldForceExitAfterMain()) {
|
||||
forceExitTimer = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] engine.disconnect() did not return within ${DISCONNECT_HARD_DEADLINE_MS}ms — force-exiting`,
|
||||
);
|
||||
// v0.42.20.0 (codex): honor an exit code an errored op already set —
|
||||
// a bare process.exit(0) here would mask a failed op as success if the
|
||||
// drain/disconnect then hangs.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, DISCONNECT_HARD_DEADLINE_MS);
|
||||
// unref so the timer itself doesn't keep the event loop alive — only
|
||||
// the actual pending work (PGLite WASM handle) does. Without unref,
|
||||
// we'd block a clean exit by 10s on every successful CLI run.
|
||||
forceExitTimer.unref?.();
|
||||
}
|
||||
// Set when a wallclock bound fired. The abandoned (timed-out but still
|
||||
// running) handler can hold ref'd sockets/timers that keep Bun's event loop
|
||||
// alive after main() returns — so the finally must hard-exit after teardown
|
||||
// on this path, or the timeout print is followed by an immortal process:
|
||||
// the same zombie class, resurrected through the timeout door (adversarial
|
||||
// review finding).
|
||||
let wallclockTimedOut = false;
|
||||
|
||||
try {
|
||||
const ctx = await makeContext(engine, params);
|
||||
const rawResult = await op.handler(ctx, params);
|
||||
const { withTimeout, OperationTimeoutError } = await import('./core/timeout.ts');
|
||||
const wallclockMs = getCliOptions().timeoutMs ?? READ_OP_TIMEOUT_MS;
|
||||
const onWallclockTimeout = (e: InstanceType<typeof OperationTimeoutError>) => {
|
||||
const hint = getCliOptions().timeoutMs
|
||||
? ''
|
||||
: ` (default ${e.ms}ms; pass --timeout=Ns to override)`;
|
||||
console.error(`${e.label} timed out${hint}.`);
|
||||
process.exitCode = 124;
|
||||
wallclockTimedOut = true;
|
||||
};
|
||||
|
||||
// Context build does DB I/O (resolveSourceId) and runs for EVERY op —
|
||||
// a wedged pooler connection here would otherwise hang reads, writes,
|
||||
// and admin alike with no bound at all (adversarial review finding).
|
||||
let ctx: Awaited<ReturnType<typeof makeContext>>;
|
||||
try {
|
||||
ctx = await withTimeout(
|
||||
makeContext(engine, params),
|
||||
wallclockMs,
|
||||
`gbrain ${command}: context`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof OperationTimeoutError) {
|
||||
onWallclockTimeout(e);
|
||||
return; // the finally below still drains + disconnects, then exits
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
let rawResult: unknown;
|
||||
if (op.scope === 'read') {
|
||||
try {
|
||||
rawResult = await withTimeout(
|
||||
op.handler(ctx, params),
|
||||
wallclockMs,
|
||||
`gbrain ${command}`,
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof OperationTimeoutError) {
|
||||
onWallclockTimeout(e);
|
||||
return; // the finally below still drains + disconnects, then exits
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
rawResult = await op.handler(ctx, params);
|
||||
}
|
||||
// ENG-2 (renderer parity by data shape): JSON-round-trip the local-engine
|
||||
// path's return value so renderers see the same shape they'd see on the
|
||||
// routed path. Date → ISO string; bigint → string (postgres.js shape);
|
||||
@@ -396,7 +449,8 @@ async function main() {
|
||||
// STILL runs (drains every background-work sink + disconnects). A bare
|
||||
// process.exit(1) here would skip the finally → skip the drain + disconnect
|
||||
// (leaves facts/cache/eval-capture writes racing teardown). The finally's
|
||||
// drain bounds teardown; the outer hard-deadline timer bounds a hung one.
|
||||
// drain bounds teardown; the hard-deadline timer armed at teardown entry
|
||||
// bounds a hung one.
|
||||
if (e instanceof OperationError) {
|
||||
console.error(`Error [${e.code}]: ${e.message}`);
|
||||
if (e.suggestion) console.error(` Fix: ${e.suggestion}`);
|
||||
@@ -412,11 +466,36 @@ async function main() {
|
||||
// DB logIngest gets the freshest live-engine window. 1s per-sink timeout:
|
||||
// read paths with no pending work pay the ~0ms fast path; capture/import
|
||||
// that DO enqueue pay up to 1s (+ facts shutdown grace) while in-flight
|
||||
// Haiku finishes. The unref'd hard-deadline timer above is the backstop if
|
||||
// disconnect or a lingering socket keeps Bun's loop alive.
|
||||
// Haiku finishes. The unref'd hard-deadline timer armed here is the
|
||||
// backstop if disconnect or a lingering socket keeps Bun's loop alive —
|
||||
// armed at teardown entry (NOT before the op body; see the C13 comment
|
||||
// above) so a slow-but-progressing op handler is never killed mid-flight.
|
||||
if (shouldForceExitAfterMain()) {
|
||||
forceExitTimer = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] engine.disconnect() did not return within ${DISCONNECT_HARD_DEADLINE_MS}ms — force-exiting`,
|
||||
);
|
||||
// v0.42.20.0 (codex): honor an exit code an errored op already set —
|
||||
// a bare process.exit(0) here would mask a failed op as success if the
|
||||
// drain/disconnect then hangs.
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, DISCONNECT_HARD_DEADLINE_MS);
|
||||
// unref so the timer itself doesn't keep the event loop alive — only
|
||||
// the actual pending work (PGLite WASM handle) does. Without unref,
|
||||
// we'd block a clean exit by 10s on every successful CLI run.
|
||||
forceExitTimer.unref?.();
|
||||
}
|
||||
await drainAllBackgroundWorkForCliExit({ timeoutMs: 1000 });
|
||||
await engine.disconnect();
|
||||
if (forceExitTimer) clearTimeout(forceExitTimer);
|
||||
// Wallclock-timeout path: teardown is done, but the ABANDONED handler
|
||||
// (withTimeout races, it does not cancel) can still hold ref'd sockets /
|
||||
// SDK retry timers that keep Bun's event loop alive indefinitely. With
|
||||
// the hard-deadline timer just cleared, nothing else bounds that — exit
|
||||
// explicitly. Safe: drain + disconnect completed on the lines above.
|
||||
if (wallclockTimedOut) {
|
||||
process.exit(process.exitCode ?? 124);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -529,8 +529,13 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
autopilotReconnectFails = 0; // reset on success
|
||||
} catch (probeErr) {
|
||||
try {
|
||||
await engine.disconnect();
|
||||
await (engine as any).connect?.();
|
||||
// #2034: use reconnect() — it restores the config captured at connect()
|
||||
// and avoids the null-connection window. The previous
|
||||
// `disconnect()` + bare `connect()` lost the config (throwing
|
||||
// `database_url undefined` on every retry → FATAL restart-loop on any
|
||||
// transient DB blip) AND tore down the pool postgres.js can otherwise
|
||||
// self-heal.
|
||||
await engine.reconnect({ error: probeErr });
|
||||
autopilotReconnectFails = 0;
|
||||
} catch (e) {
|
||||
logError('reconnect', e);
|
||||
|
||||
+94
-11
@@ -510,6 +510,33 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
|
||||
checks.push({ name: 'schema_version', status: 'warn', message: 'Could not check schema version' });
|
||||
}
|
||||
|
||||
// 2b. #2038: idx_timeline_dedup shape. A renumbered-during-merge migration
|
||||
// (v102) can be recorded-as-applied without its DDL running, leaving the
|
||||
// 3-column index in place — every timeline write then fails the 4-column
|
||||
// ON CONFLICT. The version counter can't see this, so check the index SHAPE.
|
||||
try {
|
||||
const { checkTimelineDedupIndex } = await import('../core/timeline-dedup-repair.ts');
|
||||
const idx = await checkTimelineDedupIndex(engine);
|
||||
if (!idx.tablePresent || !idx.needsRepair) {
|
||||
checks.push({
|
||||
name: 'timeline_dedup_index',
|
||||
status: 'ok',
|
||||
message: idx.tablePresent ? 'idx_timeline_dedup has the 4-column shape' : 'no timeline_entries table yet',
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
name: 'timeline_dedup_index',
|
||||
status: 'fail',
|
||||
message:
|
||||
`idx_timeline_dedup is ${idx.indexPresent ? `(${idx.columns.join(', ')})` : 'absent'}, ` +
|
||||
`expected (page_id, date, summary, source) — timeline writes are failing (#2038). ` +
|
||||
`Run \`gbrain apply-migrations --force-schema\` to heal it.`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
checks.push({ name: 'timeline_dedup_index', status: 'warn', message: 'Could not check idx_timeline_dedup shape' });
|
||||
}
|
||||
|
||||
// 3. Brain score
|
||||
try {
|
||||
const health = await engine.getHealth();
|
||||
@@ -2385,8 +2412,14 @@ export async function checkStaleLocks(
|
||||
}
|
||||
const lines = stale.slice(0, 10).map(s => {
|
||||
const ageH = Math.floor(s.age_ms / 3600_000);
|
||||
const source = s.id.startsWith('gbrain-sync:') ? s.id.slice('gbrain-sync:'.length) : null;
|
||||
const breakHint = source ? `gbrain sync --break-lock --source ${source}` : `gbrain sync --break-lock`;
|
||||
let breakHint = 'gbrain doctor';
|
||||
if (s.id.startsWith('gbrain-sync:')) {
|
||||
breakHint = `gbrain sync --break-lock --source ${s.id.slice('gbrain-sync:'.length)}`;
|
||||
} else if (s.id.startsWith('gbrain-cycle:')) {
|
||||
breakHint = `gbrain dream --break-lock --source ${s.id.slice('gbrain-cycle:'.length)}`;
|
||||
} else if (s.id === 'gbrain-cycle') {
|
||||
breakHint = 'gbrain dream --break-lock';
|
||||
}
|
||||
return ` ${s.id} (pid ${s.holder_pid} on ${s.holder_host}, age ${ageH}h) → ${breakHint}`;
|
||||
});
|
||||
const tail = stale.length > 10 ? ` ... and ${stale.length - 10} more.` : null;
|
||||
@@ -2614,7 +2647,7 @@ async function checkEmbeddingEnvOverride(engine: BrainEngine): Promise<Check> {
|
||||
};
|
||||
}
|
||||
|
||||
async function checkSubagentCapability(engine: BrainEngine): Promise<Check> {
|
||||
export async function checkSubagentCapability(engine: BrainEngine): Promise<Check> {
|
||||
try {
|
||||
const { classifyCapabilities } = await import('../core/ai/capabilities.ts');
|
||||
const tierSubagent = await engine.getConfig('models.tier.subagent');
|
||||
@@ -2675,8 +2708,11 @@ async function checkSubagentCapability(engine: BrainEngine): Promise<Check> {
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const cfg = loadConfig();
|
||||
const chatModel = cfg?.chat_model;
|
||||
const gatewayLoopRaw = await engine.getConfig('agent.use_gateway_loop').catch(() => null);
|
||||
const gatewayLoopEnabled = typeof gatewayLoopRaw === 'string'
|
||||
&& ['true', '1', 'yes', 'on'].includes(gatewayLoopRaw.trim().toLowerCase());
|
||||
const { isAnthropicProvider } = await import('../core/model-config.ts');
|
||||
if (chatModel && !isAnthropicProvider(chatModel) && !process.env.ANTHROPIC_API_KEY) {
|
||||
if (chatModel && !isAnthropicProvider(chatModel) && !process.env.ANTHROPIC_API_KEY && !gatewayLoopEnabled) {
|
||||
return {
|
||||
name: 'subagent_capability',
|
||||
status: 'warn',
|
||||
@@ -5610,8 +5646,29 @@ export async function buildChecks(
|
||||
"SELECT COUNT(*)::int AS count FROM pages WHERE type IN ('entity', 'person', 'company', 'organization')",
|
||||
))[0]?.count ?? 0;
|
||||
|
||||
const linkPct = ((health.link_coverage ?? 0) * 100).toFixed(0);
|
||||
const timelinePct = ((health.timeline_coverage ?? 0) * 100).toFixed(0);
|
||||
// Compute coverage against eligible entities only — exclude test fixtures
|
||||
// (`tools/gbrain/test/*`) and template stubs (`templates/new-person`) so
|
||||
// that brains seeded only with code sources don't get spurious warnings
|
||||
// about missing link/timeline coverage on pages that are test fixtures, not
|
||||
// real knowledge entities.
|
||||
const eligibleStats = (await engine.executeRaw<{ entities: number; linked_from: number; timeline: number }>(
|
||||
`WITH eligible AS (
|
||||
SELECT id FROM pages
|
||||
WHERE type IN ('entity','person','company','organization')
|
||||
AND slug NOT LIKE 'tools/gbrain/test/%'
|
||||
AND slug <> 'templates/new-person'
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*)::int FROM eligible) AS entities,
|
||||
(SELECT count(DISTINCT from_page_id)::int FROM links WHERE from_page_id IN (SELECT id FROM eligible)) AS linked_from,
|
||||
(SELECT count(DISTINCT page_id)::int FROM timeline_entries WHERE page_id IN (SELECT id FROM eligible)) AS timeline`,
|
||||
))[0] ?? { entities: entityCount, linked_from: 0, timeline: 0 };
|
||||
|
||||
const eligibleEntityCount = Number(eligibleStats.entities ?? entityCount);
|
||||
const linkCoverage = eligibleEntityCount > 0 ? Number(eligibleStats.linked_from ?? 0) / eligibleEntityCount : 0;
|
||||
const timelineCoverage = eligibleEntityCount > 0 ? Number(eligibleStats.timeline ?? 0) / eligibleEntityCount : 0;
|
||||
const linkPct = (linkCoverage * 100).toFixed(0);
|
||||
const timelinePct = (timelineCoverage * 100).toFixed(0);
|
||||
if (entityCount === 0) {
|
||||
// Markdown-only / journal / wiki brain — no entity pages to compute
|
||||
// coverage against. Coverage formula is structurally inapplicable.
|
||||
@@ -5620,13 +5677,19 @@ export async function buildChecks(
|
||||
status: 'ok',
|
||||
message: 'No entity pages — graph_coverage not applicable (markdown-only brain)',
|
||||
});
|
||||
} else if ((health.link_coverage ?? 0) >= 0.5 && (health.timeline_coverage ?? 0) >= 0.5) {
|
||||
} else if (eligibleEntityCount === 0) {
|
||||
checks.push({
|
||||
name: 'graph_coverage',
|
||||
status: 'ok',
|
||||
message: `Only code/test fixture entity pages found (${entityCount}); graph_coverage not applicable`,
|
||||
});
|
||||
} else if (linkCoverage >= 0.5 && timelineCoverage >= 0.5) {
|
||||
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%` });
|
||||
} else {
|
||||
checks.push({
|
||||
name: 'graph_coverage',
|
||||
status: 'warn',
|
||||
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}% (${entityCount} entity pages). Run: gbrain extract all`,
|
||||
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6162,12 +6225,29 @@ export async function buildChecks(
|
||||
.slice(0, 3)
|
||||
.map(([s, n]) => `${s}=${n}`)
|
||||
.join(', ');
|
||||
// Audit events are evidence, not automatically breakage. A large code
|
||||
// source can legitimately emit many WARN events (oversize/markup-heavy)
|
||||
// while remaining searchable and intentionally flagged. Fail on hard
|
||||
// dispositions (content actually blocked or hidden); warn on soft
|
||||
// dispositions or volume. This keeps doctor from treating expected
|
||||
// code-corpus telemetry as an unhealthy brain.
|
||||
//
|
||||
// v0.42 renamed the hard path: a rejected page emits `reject` and a
|
||||
// quarantined (hidden) junk page emits `quarantine`; `hard_block` is now
|
||||
// only the pre-v0.42 legacy alias. Counting `hard_block` alone let fresh
|
||||
// junk-ingest evidence (`reject`/`quarantine`) clear as `ok` whenever
|
||||
// fewer than 10 events landed. `flag` is a warn disposition (still
|
||||
// searchable, agent warned on retrieval), so it joins `soft_block`.
|
||||
const hardBlocked =
|
||||
summary.by_type.hard_block + summary.by_type.reject + summary.by_type.quarantine;
|
||||
const softBlocked = summary.by_type.soft_block + summary.by_type.flag;
|
||||
const status: 'ok' | 'warn' | 'fail' =
|
||||
events.length >= 100 ? 'fail' : events.length >= 10 ? 'warn' : 'ok';
|
||||
hardBlocked > 0 ? 'fail' :
|
||||
(softBlocked > 0 || events.length >= 10) ? 'warn' : 'ok';
|
||||
checks.push({
|
||||
name: 'content_sanity_audit_recent',
|
||||
status,
|
||||
message: `${events.length} events (hard=${summary.by_type.hard_block} soft=${summary.by_type.soft_block} warn=${summary.by_type.warn})${topPatterns ? ', patterns: ' + topPatterns : ''}${topSources ? ', sources: ' + topSources : ''}. (Local audit only — multi-host operators set GBRAIN_AUDIT_DIR.)`,
|
||||
message: `${events.length} events (hard=${hardBlocked} [hard_block=${summary.by_type.hard_block} reject=${summary.by_type.reject} quarantine=${summary.by_type.quarantine}] soft=${softBlocked} [soft_block=${summary.by_type.soft_block} flag=${summary.by_type.flag}] warn=${summary.by_type.warn})${topPatterns ? ', patterns: ' + topPatterns : ''}${topSources ? ', sources: ' + topSources : ''}. (Local audit only — multi-host operators set GBRAIN_AUDIT_DIR.)`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -7145,7 +7225,10 @@ export async function runDoctor(
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
process.exit(hasFail ? 1 : 0);
|
||||
// Use process.exitCode instead of process.exit() so cleanup handlers
|
||||
// (e.g. Bun unload events, open database connections) still run before
|
||||
// the process terminates. process.exit() is a hard kill that bypasses them.
|
||||
process.exitCode = hasFail ? 1 : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -855,6 +855,17 @@ Status (v0.42):
|
||||
if (!jsonMode) {
|
||||
console.log(`Timeline from meetings: ${r.entries_created} entries on ${r.entities_touched} entity pages from ${r.meetings_scanned} meetings`);
|
||||
}
|
||||
// #2057 (codex): batch failures are no longer swallowed silently — make
|
||||
// them visible at the command surface (and non-zero exit) instead of
|
||||
// printing a clean "N entries" success over failed inserts.
|
||||
if (r.batch_errors > 0) {
|
||||
console.error(
|
||||
`[extract timeline] ${r.batch_errors} batch(es) failed to insert` +
|
||||
(r.first_batch_error ? ` (first error: ${r.first_batch_error})` : '') +
|
||||
` — timeline is incomplete.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else if (byMention || ner) {
|
||||
// v0.41.18.0 (T7): combined --by-mention + --ner walk shares one
|
||||
// gazetteer; saves an entire pass on big brains. When only one
|
||||
|
||||
+64
-5
@@ -11,6 +11,7 @@ import {
|
||||
isCodeFilePath,
|
||||
isMarkdownFilePath,
|
||||
isImageFilePath as isImageFilePathFromSync,
|
||||
pruneDir,
|
||||
type SyncStrategy,
|
||||
} from '../core/sync.ts';
|
||||
import { sortNewestFirst } from '../core/sort-newest-first.ts';
|
||||
@@ -512,6 +513,51 @@ function isCollectibleForWalker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git-aware fast path for `collectSyncableFiles`. Returns the strategy-filtered
|
||||
* list of syncable files when `dir` is inside a git work tree (paths absolute,
|
||||
* sorted), or `null` when `dir` is not a git repo / git is unavailable — in
|
||||
* which case the caller falls back to the recursive FS walk.
|
||||
*
|
||||
* Honors `.gitignore` (the whole point): `git ls-files --cached --others
|
||||
* --exclude-standard` lists tracked + untracked-not-ignored files, so vendored
|
||||
* / build / generated trees never reach the importer. `-z` (NUL-delimited)
|
||||
* survives paths with spaces/newlines. Each path is lstat-checked to preserve
|
||||
* the walker's no-symlink policy and to drop submodule gitlinks (which surface
|
||||
* as a single non-regular entry).
|
||||
*/
|
||||
function gitListSyncableFiles(
|
||||
dir: string,
|
||||
strategy: SyncStrategy,
|
||||
multimodalOn: boolean,
|
||||
): string[] | null {
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
'git',
|
||||
['-C', dir, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'],
|
||||
{ encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
} catch {
|
||||
return null; // not a git work tree, or git not on PATH → FS-walk fallback
|
||||
}
|
||||
const files: string[] = [];
|
||||
for (const rel of stdout.split('\0')) {
|
||||
if (!rel) continue;
|
||||
if (!isCollectibleForWalker(rel, strategy, multimodalOn)) continue;
|
||||
const full = join(dir, rel);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(full);
|
||||
} catch {
|
||||
continue; // ls-files raced a deletion, or unreadable
|
||||
}
|
||||
if (st.isSymbolicLink() || !st.isFile()) continue;
|
||||
files.push(full);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.31.2 (codex C4 + C5 + C8): unified walker with five hardenings:
|
||||
*
|
||||
@@ -532,6 +578,19 @@ function isCollectibleForWalker(
|
||||
export function collectSyncableFiles(dir: string, opts: CollectOpts = {}): string[] {
|
||||
const strategy: SyncStrategy = opts.strategy ?? 'markdown';
|
||||
const multimodalOn = process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true';
|
||||
|
||||
// v0.42.x (#1159 --respect-gitignore / #1483 .gbrainignore): when `dir` is a
|
||||
// git work tree, enumerate via `git ls-files` so the walk honors
|
||||
// `.gitignore`. Pre-fix the recursive FS walk below descended into every
|
||||
// git-ignored tree — `vendor/` (PHP Composer), `storage/`, `public/build/`,
|
||||
// etc. — so a Laravel/PHP repo's `--strategy code` sync tried to import ~50k
|
||||
// dependency/build files (and bloated DB + embedding cost on any repo with
|
||||
// vendored data/fixtures). `--cached --others --exclude-standard` = tracked
|
||||
// PLUS untracked-not-ignored, so uncommitted source is still indexed. Non-git
|
||||
// dirs (or git unavailable) fall through to the FS walk below.
|
||||
const gitFiles = gitListSyncableFiles(dir, strategy, multimodalOn);
|
||||
if (gitFiles) return gitFiles;
|
||||
|
||||
const maxDepth = resolveMaxWalkDepth();
|
||||
const visitedInodes = new Map<string, true>();
|
||||
const files: string[] = [];
|
||||
@@ -548,11 +607,11 @@ export function collectSyncableFiles(dir: string, opts: CollectOpts = {}): strin
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
// Skip hidden dirs (.git, .claude, .raw, etc.) and `node_modules`/`ops`.
|
||||
// Same set the legacy walkers honored, surfaced once at the top of
|
||||
// every iteration.
|
||||
if (entry.startsWith('.')) continue;
|
||||
if (entry === 'node_modules' || entry === 'ops') continue;
|
||||
// Descent-time prune through the canonical gate (single source of truth
|
||||
// in core/sync.ts) instead of a hand-maintained inline list that drifted
|
||||
// from it. Skips hidden dirs (`.git`, `.raw`, etc.), `node_modules`,
|
||||
// `vendor`, `dist`, `build`, `venv` (#2020), `ops`, and git submodules.
|
||||
if (!pruneDir(entry, d)) continue;
|
||||
|
||||
const full = join(d, entry);
|
||||
let stat;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { homedir } from 'os';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
import { saveConfig, loadConfig, loadConfigFileOnly, toEngineConfig, gbrainPath, configPath, isThinClient, type GBrainConfig } from '../core/config.ts';
|
||||
import { saveConfig, loadConfig, loadConfigFileOnly, toEngineConfig, gbrainPath, configPath, isThinClient, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
|
||||
import { createEngine } from '../core/engine-factory.ts';
|
||||
import { discoverOAuth, mintClientCredentialsToken, smokeTestMcp } from '../core/remote-mcp-probe.ts';
|
||||
import { runInitEmbedCheck } from '../core/init-embed-check.ts';
|
||||
@@ -133,7 +133,11 @@ export async function runInit(args: string[]) {
|
||||
if (manualUrl) {
|
||||
databaseUrl = manualUrl;
|
||||
} else if (isNonInteractive) {
|
||||
const envUrl = process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL;
|
||||
// effectiveEnvDatabaseUrl applies the #427 guard: a DATABASE_URL that Bun
|
||||
// auto-loaded from a .env in cwd must not seed a brain config — that is
|
||||
// the "init inside a web-app checkout writes the app's DB into
|
||||
// ~/.gbrain/config.json" failure mode.
|
||||
const envUrl = effectiveEnvDatabaseUrl();
|
||||
if (envUrl) {
|
||||
databaseUrl = envUrl;
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { createEngine } from '../core/engine-factory.ts';
|
||||
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, type GBrainConfig } from '../core/config.ts';
|
||||
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import type { EngineConfig } from '../core/types.ts';
|
||||
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
|
||||
@@ -91,7 +91,8 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
|
||||
// Build target config
|
||||
const targetConfig: EngineConfig = { engine: opts.targetEngine };
|
||||
if (opts.targetEngine === 'postgres') {
|
||||
targetConfig.database_url = opts.targetUrl || process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL;
|
||||
// #427 guard: don't let a cwd-.env DATABASE_URL become a migration target.
|
||||
targetConfig.database_url = opts.targetUrl || effectiveEnvDatabaseUrl();
|
||||
if (!targetConfig.database_url) {
|
||||
console.error('Target is Supabase but no connection string provided. Use: --url <connection_string>');
|
||||
process.exit(1);
|
||||
|
||||
+102
-7
@@ -797,6 +797,26 @@ export function isAvailable(touchpoint: TouchpointKind, modelOverride?: string):
|
||||
|
||||
// ---- Embedding ----
|
||||
|
||||
/**
|
||||
* Carries the asymmetric-embedding `input_type` ('query' | 'document')
|
||||
* across the AI SDK boundary, from embedSubBatch() to the per-recipe fetch
|
||||
* shims below (#1400).
|
||||
*
|
||||
* Why this exists: `dimsProviderOptions()` correctly emits `input_type`
|
||||
* into `providerOptions.openaiCompatible` for asymmetric models (ZE
|
||||
* zembed-1, Voyage v3+), but the AI SDK's openai-compatible adapter
|
||||
* validates providerOptions against a fixed schema (`dimensions`, `user`)
|
||||
* and silently DROPS every other field before building the wire body. By
|
||||
* the time a compat shim sees the request, the threaded 'query' is gone —
|
||||
* so every embedQuery() was encoded document-side and asymmetric retrieval
|
||||
* silently collapsed to surface-token overlap.
|
||||
*
|
||||
* embedSubBatch() populates the store only when providerOptions actually
|
||||
* threads an input_type; shims that need a default (ZE) apply their own.
|
||||
* Same module-level ALS pattern as `__budgetStore` below.
|
||||
*/
|
||||
const __embedInputTypeStore = new AsyncLocalStorage<'query' | 'document'>();
|
||||
|
||||
/**
|
||||
* Voyage AI compatibility shim. Voyage's `/v1/embeddings` endpoint is OpenAI-shaped
|
||||
* but diverges on two parameters:
|
||||
@@ -835,6 +855,15 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit)
|
||||
if (typeof dims === 'number') parsed.output_dimension = dims;
|
||||
mutated = true;
|
||||
}
|
||||
// Recover the SDK-stripped input_type (#1400) — opt-in, mirroring
|
||||
// the dims.ts Voyage branch: inject only when a caller actually
|
||||
// threaded one; when absent, the field stays off the wire
|
||||
// (pre-v0.35.0.0 behavior preserved).
|
||||
const threadedInputType = __embedInputTypeStore.getStore();
|
||||
if (threadedInputType !== undefined && parsed.input_type === undefined) {
|
||||
parsed.input_type = threadedInputType;
|
||||
mutated = true;
|
||||
}
|
||||
if (mutated) {
|
||||
const newBody = JSON.stringify(parsed);
|
||||
// Drop Content-Length so fetch recomputes from the new body.
|
||||
@@ -1005,10 +1034,13 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI
|
||||
parsed.encoding_format = 'float';
|
||||
mutated = true;
|
||||
}
|
||||
// Default input_type when caller didn't thread one (document-side
|
||||
// embedding is the correct default for ingest paths).
|
||||
// Recover the SDK-stripped input_type (#1400): the threaded value
|
||||
// arrives via __embedInputTypeStore because the openai-compatible
|
||||
// adapter drops it from providerOptions before building the body.
|
||||
// Document-side default preserved for paths that didn't thread one
|
||||
// (ingest).
|
||||
if (parsed.input_type === undefined) {
|
||||
parsed.input_type = 'document';
|
||||
parsed.input_type = __embedInputTypeStore.getStore() ?? 'document';
|
||||
mutated = true;
|
||||
}
|
||||
if (mutated) {
|
||||
@@ -1096,6 +1128,56 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI
|
||||
}
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
/**
|
||||
* Generic asymmetric-embedding shim for openai-compatible recipes that
|
||||
* ship no compat fetch of their own (llama-server, litellm, ollama, ...).
|
||||
* Those deployments are the canonical local/proxy paths for serving
|
||||
* asymmetric models (e.g. a zembed-1 behind llama.cpp, vLLM, or an
|
||||
* OpenAI-compatible proxy), and they need the same `input_type` signal the
|
||||
* hosted ZE endpoint gets — the serving layer can't apply query-side vs
|
||||
* document-side encoding without it.
|
||||
*
|
||||
* Strictly opt-in at the wire level: when no input_type was threaded (the
|
||||
* model isn't asymmetric — dims.ts threads it by model id, never for
|
||||
* Azure/DashScope/Zhipu-style fixed models — or the caller didn't ask),
|
||||
* the request passes through byte-identical. When one WAS threaded
|
||||
* (recovered from __embedInputTypeStore — see #1400), inject it into the
|
||||
* JSON body; OpenAI-compatible servers that don't understand the field
|
||||
* ignore unknown body keys (llama-server does), and asymmetric-aware
|
||||
* servers/proxies read it. URL + response shape are untouched — these
|
||||
* endpoints are already OpenAI-shaped. Recipes with their own compat
|
||||
* fetch (voyage, zeroentropyai, azure) never reach this shim: the
|
||||
* `compat.fetch ??` precedence in instantiateEmbedding wins.
|
||||
*/
|
||||
const openAICompatAsymmetricFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const threadedInputType = __embedInputTypeStore.getStore();
|
||||
if (threadedInputType === undefined) return fetch(input as any, init);
|
||||
// Inject only on the string-URL + string-body shape the AI SDK actually
|
||||
// sends. A Request-shaped input may carry its body on the Request object
|
||||
// itself, where a rebuild would silently drop it — pass it through
|
||||
// untouched instead (unreachable via the SDK today; preserves the
|
||||
// byte-identical contract if it ever becomes reachable).
|
||||
if (typeof input !== 'string' && !(input instanceof URL)) {
|
||||
return fetch(input as any, init);
|
||||
}
|
||||
let baseInit: RequestInit = init ?? {};
|
||||
if (baseInit.body && typeof baseInit.body === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(baseInit.body);
|
||||
if (parsed && typeof parsed === 'object' && parsed.input_type === undefined) {
|
||||
parsed.input_type = threadedInputType;
|
||||
// Drop Content-Length so fetch recomputes from the new body.
|
||||
const headers = new Headers(baseInit.headers ?? {});
|
||||
headers.delete('content-length');
|
||||
baseInit = { ...baseInit, body: JSON.stringify(parsed), headers };
|
||||
}
|
||||
} catch {
|
||||
// Body wasn't JSON — pass through untouched.
|
||||
}
|
||||
}
|
||||
return fetch(typeof input === 'string' ? input : input.toString(), baseInit);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
|
||||
const { parsed, recipe } = resolveRecipe(modelStr);
|
||||
assertTouchpoint(recipe, 'embedding', parsed.modelId, getExtendedModelsForProvider(parsed.providerId));
|
||||
@@ -1149,15 +1231,19 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
|
||||
// wrapper via resolveOpenAICompatConfig. Azure recipes ship their own
|
||||
// fetch (api-version splice); voyage doesn't — use voyageCompatFetch.
|
||||
// ZeroEntropy needs zeroEntropyCompatFetch (URL path + body input_type
|
||||
// + response shape rewrite + OOM caps). Same per-recipe-id branch
|
||||
// pattern as voyage so adding a third compat shim is one more case.
|
||||
// + response shape rewrite + OOM caps). Every other openai-compat
|
||||
// recipe (llama-server, litellm, ollama, ...) falls through to
|
||||
// openAICompatAsymmetricFetch so the threaded input_type reaches
|
||||
// asymmetric models there too (#1400) — a strict pass-through when no
|
||||
// input_type was threaded, so symmetric deployments see zero wire
|
||||
// change.
|
||||
const fetchWrapper =
|
||||
compat.fetch ??
|
||||
(recipe.id === 'voyage'
|
||||
? voyageCompatFetch
|
||||
: recipe.id === 'zeroentropyai'
|
||||
? zeroEntropyCompatFetch
|
||||
: undefined);
|
||||
: openAICompatAsymmetricFetch);
|
||||
const client = createOpenAICompatible({
|
||||
name: recipe.id,
|
||||
baseURL: compat.baseURL,
|
||||
@@ -1472,7 +1558,7 @@ async function embedSubBatch(
|
||||
opts?: EmbedOpts,
|
||||
): Promise<Float32Array[]> {
|
||||
try {
|
||||
const result = await _embedTransport({
|
||||
const callTransport = () => _embedTransport({
|
||||
model,
|
||||
values: texts,
|
||||
providerOptions: providerOpts,
|
||||
@@ -1483,6 +1569,15 @@ async function embedSubBatch(
|
||||
abortSignal: withDefaultTimeout(opts?.abortSignal, AI_EMBED_TIMEOUT_MS),
|
||||
...(opts?.maxRetries !== undefined && { maxRetries: opts.maxRetries }),
|
||||
});
|
||||
// Carry the threaded input_type across the SDK boundary via
|
||||
// __embedInputTypeStore (the adapter strips it from providerOptions —
|
||||
// see the store's doc comment). Populated only when dimsProviderOptions
|
||||
// actually emitted one, so non-asymmetric paths run store-empty and the
|
||||
// fetch shims leave their wire bodies untouched.
|
||||
const threadedInputType = providerOpts?.openaiCompatible?.input_type;
|
||||
const result = await (threadedInputType === 'query' || threadedInputType === 'document'
|
||||
? __embedInputTypeStore.run(threadedInputType, callTransport)
|
||||
: callTransport());
|
||||
|
||||
if (!Array.isArray(result.embeddings) || result.embeddings.length !== texts.length) {
|
||||
throw new AIConfigError(
|
||||
|
||||
+98
-3
@@ -389,6 +389,96 @@ export function loadConfigFileOnly(): GBrainConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #427 guard — DATABASE_URL hijack via Bun's cwd .env auto-load.
|
||||
*
|
||||
* Bun merges `.env` files from the process cwd into process.env before any
|
||||
* user code runs. For a globally-installed tool that is a footgun: running
|
||||
* gbrain inside any checkout whose `.env` defines DATABASE_URL (Next.js,
|
||||
* Hono, Supabase, most web apps) silently retargets the brain at that app's
|
||||
* database. Reads hit the wrong DB; `apply-migrations` can write gbrain's
|
||||
* schema — including its DDL event trigger — into a production app database
|
||||
* (see the v0.42.8 report on #427).
|
||||
*
|
||||
* Bun gives no way to ask which vars came from a .env file (the merge
|
||||
* happens before module load), so we re-parse the .env files Bun auto-loads
|
||||
* from cwd and treat DATABASE_URL as "not operator-provided" when its value
|
||||
* matches one of them. Deliberate overrides still work two ways:
|
||||
* - GBRAIN_DATABASE_URL: namespaced to this tool, never auto-ignored;
|
||||
* - exporting DATABASE_URL in the shell: exported vars win over .env in
|
||||
* Bun, and a deliberate export that happens to EQUAL the cwd .env value
|
||||
* would have selected the same database anyway — ignoring it changes
|
||||
* the outcome only by honoring the brain config, which is the safe
|
||||
* reading of ambiguous intent.
|
||||
*
|
||||
* The file list is a superset of Bun's auto-load set across NODE_ENV values
|
||||
* so the guard doesn't depend on replicating Bun's exact selection logic.
|
||||
*/
|
||||
const CWD_DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.production', '.env.test'];
|
||||
|
||||
/**
|
||||
* All values assigned to `key` across the .env files in `dir`. Collecting
|
||||
* every assignment (rather than emulating override order) keeps the guard
|
||||
* independent of dotenv precedence rules — a match against ANY assignment
|
||||
* means the value is file-origin. Exported for tests.
|
||||
*/
|
||||
export function dotenvValuesForKey(key: string, dir: string = process.cwd()): Set<string> {
|
||||
const values = new Set<string>();
|
||||
const assignment = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
|
||||
for (const name of CWD_DOTENV_FILES) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(join(dir, name), 'utf-8');
|
||||
} catch {
|
||||
continue; // missing/unreadable file — nothing to guard against
|
||||
}
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.match(assignment);
|
||||
if (!m || m[1] !== key) continue;
|
||||
let v = m[2].trim();
|
||||
if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
|
||||
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)) {
|
||||
v = v.slice(1, -1);
|
||||
} else {
|
||||
const hash = v.indexOf(' #');
|
||||
if (hash !== -1) v = v.slice(0, hash).trim();
|
||||
}
|
||||
if (v) values.add(v);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
let warnedCwdEnvDbUrlIgnored = false;
|
||||
|
||||
/**
|
||||
* The env-provided DB URL gbrain should honor, with the #427 guard applied:
|
||||
* a DATABASE_URL whose value matches an assignment in a cwd .env file is
|
||||
* treated as belonging to the project in cwd, not to gbrain, and ignored
|
||||
* with a one-time stderr notice. GBRAIN_DATABASE_URL is always honored.
|
||||
* `dir` is injectable for tests; callers use the default.
|
||||
*/
|
||||
export function effectiveEnvDatabaseUrl(dir: string = process.cwd()): string | undefined {
|
||||
if (process.env.GBRAIN_DATABASE_URL) return process.env.GBRAIN_DATABASE_URL;
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) return undefined;
|
||||
if (dotenvValuesForKey('DATABASE_URL', dir).has(url)) {
|
||||
if (!warnedCwdEnvDbUrlIgnored) {
|
||||
warnedCwdEnvDbUrlIgnored = true;
|
||||
console.warn(
|
||||
'[config] Ignoring DATABASE_URL auto-loaded by Bun from a .env file in the current ' +
|
||||
'directory — it belongs to the project here, not to gbrain. Using the engine from ' +
|
||||
'~/.gbrain/config.json instead. To point gbrain at that database deliberately, set ' +
|
||||
'GBRAIN_DATABASE_URL.',
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function loadConfig(): GBrainConfig | null {
|
||||
let fileConfig: GBrainConfig | null = null;
|
||||
try {
|
||||
@@ -397,8 +487,8 @@ export function loadConfig(): GBrainConfig | null {
|
||||
fileConfig = migrateLegacyEmbeddingConfig(parsed) as unknown as GBrainConfig;
|
||||
} catch { /* no config file */ }
|
||||
|
||||
// Try env vars
|
||||
const dbUrl = process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL;
|
||||
// Try env vars (cwd-.env-origin DATABASE_URL excluded — see #427 guard above)
|
||||
const dbUrl = effectiveEnvDatabaseUrl();
|
||||
|
||||
if (!fileConfig && !dbUrl) return null;
|
||||
|
||||
@@ -933,7 +1023,12 @@ export function gbrainPath(...segments: string[]): string {
|
||||
*/
|
||||
export function getDbUrlSource(): DbUrlSource {
|
||||
if (process.env.GBRAIN_DATABASE_URL) return 'env:GBRAIN_DATABASE_URL';
|
||||
if (process.env.DATABASE_URL) return 'env:DATABASE_URL';
|
||||
// Same #427 guard as loadConfig: a DATABASE_URL that Bun auto-loaded from
|
||||
// a cwd .env file is not an operator-provided source. Keeping this in
|
||||
// lockstep with loadConfig matters because doctor uses this to tell the
|
||||
// user where the URL came from — reporting env:DATABASE_URL while
|
||||
// loadConfig ignores it would send them debugging the wrong layer.
|
||||
if (effectiveEnvDatabaseUrl()) return 'env:DATABASE_URL';
|
||||
if (!existsSync(configPath())) return null;
|
||||
try {
|
||||
const raw = readFileSync(configPath(), 'utf-8');
|
||||
|
||||
+36
-1
@@ -1033,6 +1033,21 @@ async function runPhaseExtractFacts(
|
||||
|| result.phantomsSkippedDrift)
|
||||
? `, ${result.phantomsRedirected} phantom(s) redirected (${result.phantomsAmbiguous} ambiguous, ${result.phantomsSkippedDrift} drift-skipped)`
|
||||
: '';
|
||||
// #1928: a reconcile that deletes far more facts than it reinserts is the
|
||||
// signature of the conversation-facts wipe (factsDeleted 1829, inserted 0
|
||||
// read as a no-op "ok" before this guard). Surface net deletion above a
|
||||
// floor as `warn` so the daily report and doctor make it visible instead
|
||||
// of it reading like a clean run.
|
||||
const NET_DELETION_WARN_FLOOR = 50;
|
||||
const netDeleted = result.factsDeleted - result.factsInserted;
|
||||
const netDeletionWarn = netDeleted >= NET_DELETION_WARN_FLOOR;
|
||||
if (netDeletionWarn) {
|
||||
result.warnings.push(
|
||||
`net_fact_deletion: reconcile removed ${result.factsDeleted} fact(s) and ` +
|
||||
`reinserted ${result.factsInserted} (net -${netDeleted}). If unexpected, a ` +
|
||||
`destructive full walk may have wiped non-fence facts (#1928).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
phase: 'extract_facts',
|
||||
status: result.warnings.length > 0 ? 'warn' : 'ok',
|
||||
@@ -1574,6 +1589,11 @@ export async function runCycle(
|
||||
// and which slugs synthesize wrote so recompute_emotional_weight can
|
||||
// pick up the union of (sync ∪ synthesize) for v0.29 incremental mode.
|
||||
let syncPagesAffected: string[] | undefined;
|
||||
// #1928 (codex): true ONLY when the sync phase actually RAN its work (not
|
||||
// when it was skipped for no-engine / no-brainDir). The destructive
|
||||
// extract_facts guard keys off this so a SKIPPED sync still allows a
|
||||
// legitimate full reconcile — only a sync that ran and failed suppresses it.
|
||||
let syncAttempted = false;
|
||||
let synthesizeWrittenSlugs: string[] | undefined;
|
||||
if (phases.includes('sync')) {
|
||||
checkAborted(opts.signal);
|
||||
@@ -1589,6 +1609,7 @@ export async function runCycle(
|
||||
phaseResults.push(skipNoBrainDir('sync'));
|
||||
} else {
|
||||
progress.start('cycle.sync');
|
||||
syncAttempted = true; // sync ran its work; undefined pagesAffected now means failure
|
||||
const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, brainDir, dryRun, pull, phases.includes('extract')));
|
||||
result.duration_ms = duration_ms;
|
||||
// Capture changed slugs for incremental extract.
|
||||
@@ -1688,8 +1709,22 @@ export async function runCycle(
|
||||
// the sources table doesn't recognize this brainDir (pre-multi-
|
||||
// source installs).
|
||||
const xfSourceId = cycleSourceId ?? 'default';
|
||||
// #1928: extract_facts is DESTRUCTIVE (wipe-and-reinsert per page). It
|
||||
// must NOT inherit the "sync failed ⇒ undefined ⇒ full walk" fallback
|
||||
// that's safe for link/timeline extract. When the sync phase RAN but
|
||||
// failed, syncPagesAffected is undefined (a successful no-op sync
|
||||
// returns []). In that case pass [] (no-op) so a lock-contention or
|
||||
// transient sync failure can't escalate into a brain-wide fact wipe.
|
||||
// undefined still reaches here (intended full reconcile) when the sync
|
||||
// phase was absent OR skipped (no engine / no brainDir — extract_facts
|
||||
// supports no-brainDir DB reconciliation). Only a sync that actually
|
||||
// RAN and came back with undefined pagesAffected is a real failure
|
||||
// (#1928, codex: keying off phases.includes('sync') wrongly suppressed
|
||||
// the skipped-sync full reconcile).
|
||||
const syncRanButFailed = syncAttempted && syncPagesAffected === undefined;
|
||||
const xfSlugs = syncRanButFailed ? [] : syncPagesAffected;
|
||||
const { result, duration_ms } = await timePhase(() =>
|
||||
runPhaseExtractFacts(engine, brainDir, xfSourceId, dryRun, syncPagesAffected, opts.signal));
|
||||
runPhaseExtractFacts(engine, brainDir, xfSourceId, dryRun, xfSlugs, opts.signal));
|
||||
result.duration_ms = duration_ms;
|
||||
phaseResults.push(result);
|
||||
progress.finish();
|
||||
|
||||
@@ -222,10 +222,15 @@ export async function runExtractFacts(
|
||||
|
||||
if (opts.dryRun) continue;
|
||||
|
||||
// Wipe-and-reinsert per page. The deleteFactsForPage call targets
|
||||
// source_markdown_slug = slug only, so NULL-source_markdown_slug
|
||||
// legacy rows survive (the partial-UNIQUE-index keyspace).
|
||||
const deleted = await engine.deleteFactsForPage(slug, sourceId);
|
||||
// Wipe-and-reinsert per page. The delete targets source_markdown_slug =
|
||||
// slug only, so NULL-source_markdown_slug legacy rows survive (the
|
||||
// partial-UNIQUE-index keyspace). #1928: `cli:`-origin facts (conversation
|
||||
// facts from extract-conversation-facts) are NOT fence-owned — the page
|
||||
// carries no `## Facts` fence to recreate them — so they MUST survive this
|
||||
// reconcile. Exclude them from the wipe.
|
||||
const deleted = await engine.deleteFactsForPage(slug, sourceId, {
|
||||
excludeSourcePrefixes: ['cli:'],
|
||||
});
|
||||
result.factsDeleted += deleted.deleted;
|
||||
|
||||
if (parsed.facts.length === 0) continue;
|
||||
|
||||
@@ -173,6 +173,7 @@ export const META_CHECK_NAMES: ReadonlySet<string> = new Set([
|
||||
'schema_pack_source_drift',
|
||||
'schema_version',
|
||||
'slug_fallback_audit',
|
||||
'timeline_dedup_index',
|
||||
'upgrade_errors',
|
||||
]);
|
||||
|
||||
|
||||
@@ -217,8 +217,10 @@ export function embeddingMismatchMessage(opts: EmbeddingMismatchOpts): string {
|
||||
``,
|
||||
` BEGIN;`,
|
||||
` DROP INDEX IF EXISTS idx_chunks_embedding;`,
|
||||
` ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(${requestedDims});`,
|
||||
` -- NULL embeddings BEFORE the alter: pgvector refuses to cast existing`,
|
||||
` -- vectors across dimensions and aborts the transaction. NULLs cast fine.`,
|
||||
` UPDATE content_chunks SET embedding = NULL, embedded_at = NULL;`,
|
||||
` ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(${requestedDims});`,
|
||||
` ${reindexLine.split('\n').join('\n ')}`,
|
||||
` COMMIT;`,
|
||||
``,
|
||||
@@ -573,11 +575,25 @@ export function buildFactsAlterRecipe(
|
||||
): string {
|
||||
const opclass = columnType === 'halfvec' ? 'halfvec_cosine_ops' : 'vector_cosine_ops';
|
||||
const targetType = columnType === 'halfvec' ? `halfvec(${configuredDims})` : `vector(${configuredDims})`;
|
||||
const dimsChanged = columnDims !== configuredDims;
|
||||
return [
|
||||
`-- ALTER ${columnType}(${columnDims}) → ${columnType}(${configuredDims}) on indexed column.`,
|
||||
`-- HOLD a maintenance window: this rewrites every row's embedding.`,
|
||||
`-- Coordinate with any active extract-conversation-facts backfill.`,
|
||||
`DROP INDEX IF EXISTS idx_facts_embedding_hnsw;`,
|
||||
// Same-dim type swaps (halfvec <-> vector) cast row data losslessly and
|
||||
// MUST keep it. Cross-dimension changes are different: pgvector refuses
|
||||
// to cast existing vectors across dimensions ("expected N dimensions,
|
||||
// not M") and aborts the transaction — and the old-space vectors are
|
||||
// unusable at the new width anyway. NULL them first; the facts pipeline
|
||||
// re-embeds on the next write.
|
||||
...(dimsChanged
|
||||
? [
|
||||
`-- Dimension change: NULL embeddings BEFORE the alter — pgvector`,
|
||||
`-- refuses cross-dimension casts and aborts the transaction.`,
|
||||
`UPDATE facts SET embedding = NULL;`,
|
||||
]
|
||||
: []),
|
||||
`ALTER TABLE facts ALTER COLUMN embedding TYPE ${targetType}`,
|
||||
` USING embedding::${targetType};`,
|
||||
`CREATE INDEX idx_facts_embedding_hnsw`,
|
||||
|
||||
+23
-1
@@ -650,6 +650,15 @@ export interface BrainEngine {
|
||||
// Lifecycle
|
||||
connect(config: EngineConfig): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
/**
|
||||
* Recover a dropped connection using the config captured at the last
|
||||
* `connect()`. Callers (autopilot health probe, batchRetry) MUST use this
|
||||
* instead of `disconnect()` + bare `connect()`: the latter loses the config
|
||||
* (#2034 — a bare `connect()` with no args throws `database_url undefined`
|
||||
* forever) AND opens a null-connection window. Implemented on BOTH engines
|
||||
* for parity so the call is never a silent no-op.
|
||||
*/
|
||||
reconnect(ctx?: { error?: unknown }): Promise<void>;
|
||||
initSchema(): Promise<void>;
|
||||
transaction<T>(fn: (engine: BrainEngine) => Promise<T>): Promise<T>;
|
||||
/**
|
||||
@@ -1678,7 +1687,20 @@ export interface BrainEngine {
|
||||
* until the v0_32_2 migration backfills them. Cycle-phase callers in
|
||||
* commit 7 add the empty-fence-guard as a belt-and-suspenders check.
|
||||
*/
|
||||
deleteFactsForPage(slug: string, source_id: string): Promise<{ deleted: number }>;
|
||||
/**
|
||||
* #1928: `excludeSourcePrefixes` protects facts whose `source` matches any
|
||||
* given prefix (e.g. `['cli:']` for conversation facts written by
|
||||
* `extract-conversation-facts`) from a fence reconcile. Those rows are NOT
|
||||
* fence-owned — a destructive wipe-and-reinsert pass would delete them and
|
||||
* never recreate them (the page has no `## Facts` fence). Omitted ⇒ legacy
|
||||
* behavior (delete every fact on the page coordinate). NULL/empty `source`
|
||||
* rows are always deletable (fence default).
|
||||
*/
|
||||
deleteFactsForPage(
|
||||
slug: string,
|
||||
source_id: string,
|
||||
opts?: { excludeSourcePrefixes?: string[] },
|
||||
): Promise<{ deleted: number }>;
|
||||
|
||||
/**
|
||||
* Mark a fact expired. Never DELETE. Returns true iff a row was updated.
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface ExtractTimelineFromMeetingsResult {
|
||||
entries_created: number;
|
||||
/** Distinct entity pages that received at least one new timeline entry. */
|
||||
entities_touched: number;
|
||||
/**
|
||||
* #2057: batches that failed to insert. Previously swallowed by a bare
|
||||
* `catch {}`, which let a brain-wide timeline-write failure read as a clean
|
||||
* "0 entries" run. Non-zero here means inserts are failing — surfaced on
|
||||
* stderr too.
|
||||
*/
|
||||
batch_errors: number;
|
||||
/** First batch-insert error message, when batch_errors > 0. */
|
||||
first_batch_error?: string;
|
||||
}
|
||||
|
||||
interface MeetingRow {
|
||||
@@ -71,7 +80,7 @@ export async function extractTimelineFromMeetings(
|
||||
);
|
||||
|
||||
if (meetings.length === 0) {
|
||||
return { meetings_scanned: 0, entries_created: 0, entities_touched: 0 };
|
||||
return { meetings_scanned: 0, entries_created: 0, entities_touched: 0, batch_errors: 0 };
|
||||
}
|
||||
|
||||
// 2. Fetch all 'attended' edges (one round-trip, scoped to the loaded
|
||||
@@ -106,14 +115,23 @@ export async function extractTimelineFromMeetings(
|
||||
let entriesCreated = 0;
|
||||
const entitiesTouched = new Set<string>();
|
||||
let meetingsScanned = 0;
|
||||
let batchErrors = 0;
|
||||
let firstBatchError: string | undefined;
|
||||
|
||||
async function flush() {
|
||||
if (batch.length === 0) return;
|
||||
if (!dryRun) {
|
||||
try {
|
||||
entriesCreated += await engine.addTimelineEntriesBatch(batch);
|
||||
} catch {
|
||||
// batch error — drop; per-meeting progress continues
|
||||
} catch (e) {
|
||||
// #2057: do NOT swallow. A bare `catch {}` here hid a brain-wide
|
||||
// timeline-write failure (the run reported 0 entries with no error).
|
||||
// Count + surface it on stderr; the per-meeting loop still continues so
|
||||
// one bad batch isn't fatal to the rest.
|
||||
batchErrors += 1;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (!firstBatchError) firstBatchError = msg;
|
||||
console.error(`[extract timeline] batch insert failed (${batch.length} row(s)): ${msg}`);
|
||||
}
|
||||
} else {
|
||||
entriesCreated += batch.length;
|
||||
@@ -182,5 +200,7 @@ export async function extractTimelineFromMeetings(
|
||||
meetings_scanned: meetingsScanned,
|
||||
entries_created: entriesCreated,
|
||||
entities_touched: entitiesTouched.size,
|
||||
batch_errors: batchErrors,
|
||||
first_batch_error: firstBatchError,
|
||||
};
|
||||
}
|
||||
|
||||
+20
-1
@@ -1193,7 +1193,15 @@ export async function importCodeFile(
|
||||
// chunk IDs are stable.
|
||||
if (extractedEdges.length > 0 && chunks.length > 0) {
|
||||
try {
|
||||
const persistedChunks = await engine.getChunks(slug, sourceId ? { sourceId } : undefined);
|
||||
// Normalize ONCE: '' and undefined both mean the schema-default source
|
||||
// (pages.source_id DEFAULT 'default'). Using the normalized value for
|
||||
// BOTH the chunk lookup and the edge stamp keeps them in lockstep —
|
||||
// an unscoped getChunks here could fan out to same-slug chunks from
|
||||
// another source, and a '' stamp would FK-violate against sources(id)
|
||||
// and silently drop the file's whole call graph in the best-effort
|
||||
// catch below (adversarial review findings).
|
||||
const edgeSourceId = sourceId || 'default';
|
||||
const persistedChunks = await engine.getChunks(slug, { sourceId: edgeSourceId });
|
||||
const byIndex = new Map<number, { id?: number; symbol_name_qualified?: string | null; start_line?: number | null; end_line?: number | null }>();
|
||||
for (const pc of persistedChunks) {
|
||||
byIndex.set(pc.chunk_index, pc);
|
||||
@@ -1231,6 +1239,17 @@ export async function importCodeFile(
|
||||
from_symbol_qualified: from.symbol_name_qualified,
|
||||
to_symbol_qualified: e.toSymbol,
|
||||
edge_type: e.edgeType,
|
||||
// Stamp the source: getCallersOf/getCalleesOf add
|
||||
// `AND source_id = <scoped>` whenever a worktree pin / --source is
|
||||
// in play, and a NULL here never matches that filter — so every
|
||||
// scoped call-graph query silently returned 0 rows on
|
||||
// multi-source brains even though the edges existed. The fallback
|
||||
// is 'default', NOT null: an unscoped import lands its pages under
|
||||
// the schema default (pages.source_id DEFAULT 'default'), so a
|
||||
// NULL-stamped edge would be invisible to the matching scoped
|
||||
// query getCallersOf(sym, { sourceId: 'default' }) — the same bug
|
||||
// through the other door.
|
||||
source_id: edgeSourceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Derive a legacy bearer token's source scope from its stored
|
||||
* `access_tokens.permissions.source_id` grant.
|
||||
*
|
||||
* ARRAY = federated read grant, exposed through `allowedSources` with the
|
||||
* first granted source as the scalar write floor. STRING = scalar source.
|
||||
* Missing, empty, or garbage values fail closed to the historical `default`
|
||||
* floor and NEVER widen to all sources.
|
||||
*/
|
||||
export function parseLegacyTokenScope(rawSource: unknown): { sourceId: string; allowedSources?: string[] } {
|
||||
if (Array.isArray(rawSource)) {
|
||||
const allowedSources = (rawSource as unknown[]).filter(s => typeof s === 'string' && s.length > 0) as string[];
|
||||
if (allowedSources.length > 0) {
|
||||
return { sourceId: allowedSources[0], allowedSources };
|
||||
}
|
||||
return { sourceId: 'default' };
|
||||
}
|
||||
if (typeof rawSource === 'string' && rawSource.length > 0) {
|
||||
return { sourceId: rawSource };
|
||||
}
|
||||
return { sourceId: 'default' };
|
||||
}
|
||||
@@ -5186,6 +5186,52 @@ export const MIGRATIONS: Migration[] = [
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 116,
|
||||
name: 'code_edges_source_backfill_and_callee_index',
|
||||
// Repair + index pass for the call graph:
|
||||
//
|
||||
// 1. BACKFILL: importCodeFile built CodeEdgeInput rows without source_id,
|
||||
// so every extracted edge landed NULL. getCallersOf/getCalleesOf add
|
||||
// `AND source_id = <scoped>` whenever a worktree pin / --source is in
|
||||
// play — NULL never matches, so scoped call-graph queries silently
|
||||
// returned 0 rows on multi-source brains even though the edges
|
||||
// existed. The write path now stamps `sourceId ?? 'default'`; this
|
||||
// backfill repairs rows written before the fix by deriving each
|
||||
// edge's source from its own from_chunk's page (pages.source_id is
|
||||
// NOT NULL DEFAULT 'default', so COALESCE is belt-and-braces only).
|
||||
//
|
||||
// 2. INDEXES: getCalleesOf filters BOTH edge tables on
|
||||
// from_symbol_qualified, which had no index anywhere — every callee
|
||||
// lookup was a sequential scan, amplified per-BFS-node by the
|
||||
// recursive code walk (one getCalleesOf per frontier node, up to
|
||||
// maxNodes). With NULL edges repaired, scoped walks actually expand,
|
||||
// so the latent seq-scan cost becomes real. Plain CREATE INDEX (not
|
||||
// CONCURRENTLY): edge tables are modest (mirrors the v58 resolver
|
||||
// index). Keep in sync with src/schema.sql.
|
||||
idempotent: true,
|
||||
sql: `
|
||||
UPDATE code_edges_symbol e
|
||||
SET source_id = COALESCE(p.source_id, 'default')
|
||||
FROM content_chunks c
|
||||
JOIN pages p ON p.id = c.page_id
|
||||
WHERE c.id = e.from_chunk_id
|
||||
AND e.source_id IS NULL;
|
||||
|
||||
UPDATE code_edges_chunk e
|
||||
SET source_id = COALESCE(p.source_id, 'default')
|
||||
FROM content_chunks c
|
||||
JOIN pages p ON p.id = c.page_id
|
||||
WHERE c.id = e.from_chunk_id
|
||||
AND e.source_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from_symbol
|
||||
ON code_edges_symbol (from_symbol_qualified);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_from_symbol
|
||||
ON code_edges_chunk (from_symbol_qualified);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
@@ -5524,6 +5570,26 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
|
||||
const sorted = [...MIGRATIONS].sort((a, b) => a.version - b.version);
|
||||
|
||||
const pending = sorted.filter(m => m.version > current);
|
||||
|
||||
// #2038: schema-drift self-heal. A migration renumbered during a master
|
||||
// merge (v102 timeline dedup, originally v99) can be recorded-as-applied
|
||||
// without its DDL ever running — the version counter can't see it. Repair
|
||||
// the known drift on EVERY pass, including when nothing is pending (the
|
||||
// affected brains are stamped AHEAD of the missing migration, so they never
|
||||
// reach the loop below). Best-effort + idempotent: a no-op on a healthy
|
||||
// index; `doctor` surfaces it independently if this ever fails.
|
||||
try {
|
||||
const { repairTimelineDedupIndex } = await import('./timeline-dedup-repair.ts');
|
||||
const r = await repairTimelineDedupIndex(engine);
|
||||
if (r.repaired) {
|
||||
console.error(
|
||||
`[migrate] healed idx_timeline_dedup drift (#2038): ${r.before.join(',') || '(absent)'} ` +
|
||||
`→ page_id,date,summary,source` +
|
||||
(r.collapsedDuplicates > 0 ? ` (collapsed ${r.collapsedDuplicates} duplicate row(s))` : ''),
|
||||
);
|
||||
}
|
||||
} catch { /* best-effort; doctor reports the drift if this couldn't run */ }
|
||||
|
||||
if (pending.length === 0) {
|
||||
return { applied: 0, current };
|
||||
}
|
||||
|
||||
@@ -1023,16 +1023,17 @@ export class MinionSupervisor {
|
||||
error: errMsg,
|
||||
queue: this.opts.queue,
|
||||
});
|
||||
// Attempt to reconnect the engine if it supports it
|
||||
// Attempt to reconnect the engine. #2034: reconnect() is now a
|
||||
// first-class BrainEngine method on both engines (it restores the
|
||||
// config captured at connect()), so call it directly — the old
|
||||
// feature-detection cast is no longer needed.
|
||||
try {
|
||||
if ('reconnect' in this.engine && typeof (this.engine as Record<string, unknown>).reconnect === 'function') {
|
||||
await (this.engine as unknown as { reconnect(): Promise<void> }).reconnect();
|
||||
this.consecutiveHealthFailures = 0;
|
||||
this.emit('health_warn', {
|
||||
reason: 'db_reconnected',
|
||||
queue: this.opts.queue,
|
||||
});
|
||||
}
|
||||
await this.engine.reconnect({ error: errMsg });
|
||||
this.consecutiveHealthFailures = 0;
|
||||
this.emit('health_warn', {
|
||||
reason: 'db_reconnected',
|
||||
queue: this.opts.queue,
|
||||
});
|
||||
} catch (reconnErr) {
|
||||
this.emit('health_error', {
|
||||
error: `reconnect failed: ${reconnErr instanceof Error ? reconnErr.message : String(reconnErr)}`,
|
||||
|
||||
+55
-18
@@ -21,10 +21,12 @@ import type {
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
|
||||
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import type { AuthInfo as SdkAuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts';
|
||||
import { hasScope, assertAllowedScopes, parseScopeString, InvalidScopeError } from './scope.ts';
|
||||
import type { AuthInfo as CoreAuthInfo } from './operations.ts';
|
||||
import { parseLegacyTokenScope } from './legacy-token-scope.ts';
|
||||
import type { SqlQuery, SqlValue } from './sql-query.ts';
|
||||
export type { SqlQuery, SqlValue };
|
||||
|
||||
@@ -389,10 +391,18 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// as a fully-admin access token. Mirrors the filter pattern already used
|
||||
// by exchangeClientCredentials (this file) and exchangeRefreshToken's F3
|
||||
// subset enforcement (RFC 6749 §6) so all three grant entry points clamp
|
||||
// consistently. Empty/omitted requested scope inherits the empty-stored
|
||||
// shape (existing behavior; not a security boundary).
|
||||
// consistently. When the client requests NO scope, RFC 6749 §3.3 lets the
|
||||
// server fall back to a default — we default to the client's full
|
||||
// registered scope (matching exchangeClientCredentials, which already does
|
||||
// `requestedScope ? ... : allowedScopes`). Previously an omitted request
|
||||
// granted the empty set, which then propagated into the access+refresh
|
||||
// tokens and never self-healed: every op failed `insufficient_scope` even
|
||||
// though the client was registered with `read write`. Clients that omit
|
||||
// `scope` on /authorize (e.g. some MCP connectors) hit this. Still clamped
|
||||
// to the allowed set, so an explicit over-broad request can't escalate.
|
||||
const allowedScopes = parseScopeString(client.scope);
|
||||
const grantedScopes = (params.scopes || []).filter(s => hasScope(allowedScopes, s));
|
||||
const requestedScopes = (params.scopes && params.scopes.length) ? params.scopes : allowedScopes;
|
||||
const grantedScopes = requestedScopes.filter(s => hasScope(allowedScopes, s));
|
||||
|
||||
await this.sql`
|
||||
INSERT INTO oauth_codes (code_hash, client_id, scopes, code_challenge,
|
||||
@@ -539,7 +549,7 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// Token Verification
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
async verifyAccessToken(token: string): Promise<SdkAuthInfo> {
|
||||
const tokenHash = hashToken(token);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -629,14 +639,29 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// operations.ts prefers this array over scalar sourceId when set
|
||||
// and non-empty.
|
||||
allowedSources,
|
||||
} as AuthInfo;
|
||||
} as CoreAuthInfo as SdkAuthInfo;
|
||||
}
|
||||
|
||||
// Fallback: legacy access_tokens table (backward compat)
|
||||
const legacyRows = await this.sql`
|
||||
SELECT name FROM access_tokens
|
||||
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
|
||||
`;
|
||||
// Fallback: legacy access_tokens table (backward compat). Modern legacy
|
||||
// rows may carry permissions.source_id from the pre-OAuth bearer-token
|
||||
// path; OAuth transport must preserve that same source grant instead of
|
||||
// pinning every legacy token to `default`.
|
||||
let legacyRows: Record<string, unknown>[];
|
||||
try {
|
||||
legacyRows = await this.sql`
|
||||
SELECT name, permissions FROM access_tokens
|
||||
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
|
||||
`;
|
||||
} catch (err) {
|
||||
if (isUndefinedColumnError(err, 'permissions')) {
|
||||
legacyRows = await this.sql`
|
||||
SELECT name FROM access_tokens
|
||||
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
|
||||
`;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyRows.length > 0) {
|
||||
// Legacy tokens get full admin access (grandfather in).
|
||||
@@ -646,19 +671,31 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
UPDATE access_tokens SET last_used_at = now() WHERE token_hash = ${tokenHash}
|
||||
`;
|
||||
const name = legacyRows[0].name as string;
|
||||
const permissionsRaw = legacyRows[0].permissions;
|
||||
let permissions: unknown = permissionsRaw;
|
||||
if (typeof permissionsRaw === 'string') {
|
||||
try {
|
||||
permissions = JSON.parse(permissionsRaw);
|
||||
} catch {
|
||||
permissions = undefined;
|
||||
}
|
||||
}
|
||||
const sourceGrant = permissions && typeof permissions === 'object'
|
||||
? (permissions as Record<string, unknown>).source_id
|
||||
: undefined;
|
||||
const { sourceId, allowedSources } = parseLegacyTokenScope(sourceGrant);
|
||||
return {
|
||||
token,
|
||||
clientId: name,
|
||||
clientName: name,
|
||||
scopes: ['read', 'write', 'admin'],
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 365 * 24 * 3600, // Legacy tokens never expire — set 1yr future
|
||||
// v0.34.1 (#861, D13): legacy bearer tokens default to 'default'
|
||||
// source — matches the pre-v0.34 effective behavior where the
|
||||
// serve-http transport fell back to GBRAIN_SOURCE/'default' for
|
||||
// any caller without explicit scope. Operators who want a
|
||||
// narrower scope for legacy tokens migrate to OAuth.
|
||||
sourceId: 'default',
|
||||
} as AuthInfo;
|
||||
// Legacy tokens without an explicit permissions.source_id grant keep
|
||||
// the historical 'default' source floor. Array grants become
|
||||
// allowedSources for federated reads, matching legacy HTTP transport.
|
||||
sourceId,
|
||||
allowedSources,
|
||||
} as CoreAuthInfo as SdkAuthInfo;
|
||||
}
|
||||
|
||||
throw new InvalidTokenError('Invalid token');
|
||||
|
||||
@@ -199,6 +199,9 @@ export class PGLiteEngine implements BrainEngine {
|
||||
readonly kind = 'pglite' as const;
|
||||
private _db: PGLiteDB | null = null;
|
||||
private _lock: LockHandle | null = null;
|
||||
// #2034: captured at connect() so reconnect() can restore the same data dir
|
||||
// after a drop, matching PostgresEngine's _savedConfig contract.
|
||||
private _savedConfig: EngineConfig | null = null;
|
||||
// Tier 3: when GBRAIN_PGLITE_SNAPSHOT loaded a post-initSchema state into
|
||||
// PGlite.create(loadDataDir), initSchema is a no-op (schema is already
|
||||
// present + migrations already applied). Saves ~1-3s per fresh test PGLite.
|
||||
@@ -211,6 +214,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
|
||||
// Lifecycle
|
||||
async connect(config: EngineConfig): Promise<void> {
|
||||
this._savedConfig = config; // #2034: remember for reconnect()
|
||||
const dataDir = config.database_path || undefined; // undefined = in-memory
|
||||
|
||||
// Acquire file lock to prevent concurrent PGLite access (crashes with Aborted())
|
||||
@@ -288,6 +292,27 @@ export class PGLiteEngine implements BrainEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2034: engine-parity reconnect. PGLite is single-writer in-process so it
|
||||
* doesn't suffer the pool-drop class PostgresEngine.reconnect() handles, but
|
||||
* the method MUST exist so callers (autopilot health probe, worker/queue
|
||||
* claim-error recovery) can call `engine.reconnect()` uniformly.
|
||||
*
|
||||
* IN-MEMORY (no `database_path`) is a NO-OP: there is no persistent backing,
|
||||
* the connection can't recoverably "drop" in-process, and a disconnect+reopen
|
||||
* would DISCARD all state. This matches the long-standing assumption the
|
||||
* worker/queue recovery paths are written against ("PGLite has no pooler
|
||||
* reaping so reconnect is absent" — src/core/minions/queue.ts). A FILE-backed
|
||||
* engine genuinely re-opens the same data dir (state persists on disk).
|
||||
*/
|
||||
async reconnect(_ctx?: { error?: unknown }): Promise<void> {
|
||||
if (!this._savedConfig) return; // never connected — nothing to restore
|
||||
if (!this._savedConfig.database_path) return; // in-memory — no-op, preserve state
|
||||
const config = this._savedConfig;
|
||||
await this.disconnect();
|
||||
await this.connect(config);
|
||||
}
|
||||
|
||||
async initSchema(): Promise<void> {
|
||||
// Tier 3: snapshot was loaded into PGlite — schema + migrations already
|
||||
// applied. Nothing to do. Returns immediately.
|
||||
@@ -3598,7 +3623,25 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return { inserted: ids.length, ids };
|
||||
}
|
||||
|
||||
async deleteFactsForPage(slug: string, source_id: string): Promise<{ deleted: number }> {
|
||||
async deleteFactsForPage(
|
||||
slug: string,
|
||||
source_id: string,
|
||||
opts?: { excludeSourcePrefixes?: string[] },
|
||||
): Promise<{ deleted: number }> {
|
||||
const prefixes = opts?.excludeSourcePrefixes;
|
||||
if (prefixes && prefixes.length > 0) {
|
||||
// #1928: keep rows whose `source` matches an excluded prefix (e.g.
|
||||
// `cli:` conversation facts). COALESCE so NULL/empty-source fence rows
|
||||
// stay deletable — only the explicitly-protected prefixes survive.
|
||||
const patterns = prefixes.map(p => `${p}%`);
|
||||
const result = await this.db.query(
|
||||
`DELETE FROM facts
|
||||
WHERE source_id = $1 AND source_markdown_slug = $2
|
||||
AND NOT (COALESCE(source, '') LIKE ANY($3::text[]))`,
|
||||
[source_id, slug, patterns],
|
||||
);
|
||||
return { deleted: result.affectedRows ?? 0 };
|
||||
}
|
||||
const result = await this.db.query(
|
||||
`DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug = $2`,
|
||||
[source_id, slug],
|
||||
@@ -4908,7 +4951,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
e.from_chunk_id, e.to_chunk_id, e.from_symbol_qualified,
|
||||
e.to_symbol_qualified, e.edge_type,
|
||||
JSON.stringify(e.edge_metadata ?? {}),
|
||||
e.source_id ?? null,
|
||||
e.source_id ?? 'default',
|
||||
);
|
||||
}
|
||||
const res = await this.db.query(
|
||||
@@ -4929,7 +4972,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
params.push(
|
||||
e.from_chunk_id, e.from_symbol_qualified, e.to_symbol_qualified, e.edge_type,
|
||||
JSON.stringify(e.edge_metadata ?? {}),
|
||||
e.source_id ?? null,
|
||||
e.source_id ?? 'default',
|
||||
);
|
||||
}
|
||||
const res = await this.db.query(
|
||||
|
||||
+100
-11
@@ -19,11 +19,72 @@ import { join } from 'path';
|
||||
|
||||
const LOCK_DIR_NAME = '.gbrain-lock';
|
||||
const LOCK_FILE = 'lock';
|
||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes — embed jobs can be long
|
||||
|
||||
// #2058: refresh the lock's `refreshed_at` while held so a long-running but
|
||||
// LIVE holder (embed jobs run for many minutes) is never mistaken for stale.
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
// #2058: a holder whose heartbeat refreshed within this window is ALIVE and is
|
||||
// NEVER stolen, regardless of how old the lock is. Only a holder that STOPPED
|
||||
// refreshing past this grace (hung, crashed without cleanup, or a PID since
|
||||
// reused by an unrelated process) is reaped. Pairing heartbeat-age with PID
|
||||
// liveness is what defeats both the WAL-corruption bug (stealing a live
|
||||
// writer) AND the PID-reuse false-positive (a recycled PID reading as "alive").
|
||||
// Env-overridable as an incident escape hatch, matching the sync-lock knobs.
|
||||
function stealGraceMs(): number {
|
||||
const env = parseInt(process.env.GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS ?? '', 10);
|
||||
return Number.isFinite(env) && env > 0 ? env * 1000 : 10 * 60 * 1000; // default 600s
|
||||
}
|
||||
|
||||
export interface LockHandle {
|
||||
lockDir: string;
|
||||
acquired: boolean;
|
||||
/**
|
||||
* #2058: heartbeat timer + lock-file path, set when a real (on-disk) lock is
|
||||
* held so `releaseLock` can stop refreshing. Absent for the in-memory engine
|
||||
* (no lock file, no concurrent access possible).
|
||||
*/
|
||||
heartbeat?: ReturnType<typeof setInterval>;
|
||||
lockPath?: string;
|
||||
/**
|
||||
* #2058 (codex): our ownership token (`<pid>:<acquired_at>`). If we stall
|
||||
* past the steal grace, another process can reap + re-acquire. When we
|
||||
* resume, the heartbeat and release MUST verify the on-disk lock is STILL
|
||||
* ours before touching it — otherwise a resumed stale holder would refresh
|
||||
* or delete the NEW owner's live lock, re-opening the concurrent-writer hole.
|
||||
*/
|
||||
ownerToken?: string;
|
||||
}
|
||||
|
||||
/** The on-disk lock identity, used to detect "we were reaped and replaced". */
|
||||
function tokenOf(lockData: { pid?: unknown; acquired_at?: unknown }): string {
|
||||
return `${lockData.pid}:${lockData.acquired_at}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2058: keep the held lock's `refreshed_at` current so a concurrent acquirer
|
||||
* can tell a live, working holder from a hung/dead one. Best-effort: if the
|
||||
* file is gone (we're being reaped) the write simply fails. `.unref()` so the
|
||||
* timer never keeps the process alive on its own. Ownership-checked: if the
|
||||
* on-disk lock is no longer ours (we were reaped past grace and replaced), stop
|
||||
* the heartbeat instead of clobbering the new owner's lock.
|
||||
*/
|
||||
function startHeartbeat(lockPath: string, ownerToken: string): ReturnType<typeof setInterval> {
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
if (tokenOf(raw) !== ownerToken) {
|
||||
// We were reaped and someone else owns it now — do NOT refresh their
|
||||
// lock. Stand down.
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
raw.refreshed_at = Date.now();
|
||||
writeFileSync(lockPath, JSON.stringify(raw), { mode: 0o644 });
|
||||
} catch { /* best-effort — file removed or transient FS error */ }
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
return timer;
|
||||
}
|
||||
|
||||
function getLockDir(dataDir: string | undefined): string {
|
||||
@@ -75,16 +136,23 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
|
||||
const lockPid = lockData.pid as number;
|
||||
const lockTime = lockData.acquired_at as number;
|
||||
|
||||
// Is the locking process still alive?
|
||||
if (!isProcessAlive(lockPid)) {
|
||||
// Stale lock — clean it up
|
||||
// #2058: classify by PID liveness AND heartbeat freshness. A holder
|
||||
// that is alive AND refreshed its heartbeat within the steal grace is
|
||||
// genuinely working (e.g. a multi-minute embed) and is NEVER reaped —
|
||||
// force-removing it here is what corrupted the single-writer WAL.
|
||||
const alive = isProcessAlive(lockPid);
|
||||
const lastRefresh = (lockData.refreshed_at as number | undefined) ?? lockTime;
|
||||
const sinceRefresh = Date.now() - lastRefresh;
|
||||
if (!alive) {
|
||||
// Holder process is gone — reap.
|
||||
try { rmSync(lockDir, { recursive: true, force: true }); } catch { /* race condition, try again */ }
|
||||
} else if (Date.now() - lockTime > STALE_THRESHOLD_MS) {
|
||||
// Lock held for too long — assume stale (e.g., process hung)
|
||||
// Still alive but probably stuck — force remove
|
||||
} else if (sinceRefresh > stealGraceMs()) {
|
||||
// PID is alive but the heartbeat stopped past the grace window:
|
||||
// either the holder hung, or this PID was reused by an unrelated
|
||||
// process (the real holder died and stopped refreshing). Reap.
|
||||
try { rmSync(lockDir, { recursive: true, force: true }); } catch { /* race condition */ }
|
||||
} else {
|
||||
// Lock is held by a live process — wait and retry
|
||||
// Live holder refreshing within grace — wait and retry.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
continue;
|
||||
}
|
||||
@@ -97,15 +165,19 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
|
||||
// Try to acquire lock (atomic mkdir)
|
||||
try {
|
||||
mkdirSync(lockDir, { recursive: false });
|
||||
// We got the lock — write our PID
|
||||
// We got the lock — write our PID. #2058: seed `refreshed_at` and start
|
||||
// the heartbeat so this holder reads as alive-and-working to others.
|
||||
const lockPath = join(lockDir, LOCK_FILE);
|
||||
const now = Date.now();
|
||||
writeFileSync(lockPath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
acquired_at: Date.now(),
|
||||
acquired_at: now,
|
||||
refreshed_at: now,
|
||||
command: process.argv.slice(1).join(' '),
|
||||
}), { mode: 0o644 });
|
||||
|
||||
return { lockDir, acquired: true };
|
||||
const ownerToken = tokenOf({ pid: process.pid, acquired_at: now });
|
||||
return { lockDir, acquired: true, lockPath, ownerToken, heartbeat: startHeartbeat(lockPath, ownerToken) };
|
||||
} catch (e: unknown) {
|
||||
// mkdir failed — someone else grabbed it between our check and mkdir
|
||||
// This is fine, we'll retry
|
||||
@@ -138,8 +210,25 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
|
||||
* Release a previously acquired lock.
|
||||
*/
|
||||
export async function releaseLock(lock: LockHandle): Promise<void> {
|
||||
// #2058: stop the heartbeat first so it can't recreate/rewrite the lock file
|
||||
// after we remove it.
|
||||
if (lock.heartbeat) {
|
||||
clearInterval(lock.heartbeat);
|
||||
lock.heartbeat = undefined;
|
||||
}
|
||||
if (!lock.lockDir || !lock.acquired) return;
|
||||
|
||||
// #2058 (codex): only remove the lock if it is STILL ours. If we were reaped
|
||||
// past the grace and another process re-acquired, removing its live lock
|
||||
// would let a third process in alongside it — the corruption this fix exists
|
||||
// to prevent. Unreadable/absent lock falls through to a best-effort remove.
|
||||
if (lock.ownerToken) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(join(lock.lockDir, LOCK_FILE), 'utf-8'));
|
||||
if (tokenOf(raw) !== lock.ownerToken) return; // someone else owns it now
|
||||
} catch { /* unreadable/gone — fall through to best-effort cleanup */ }
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(lock.lockDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
|
||||
@@ -1276,11 +1276,28 @@ export class PostgresEngine implements BrainEngine {
|
||||
}
|
||||
|
||||
async updateSourceConfig(sourceId: string, patch: Record<string, unknown>): Promise<boolean> {
|
||||
// v0.38: atomic JSONB merge. `||` is the Postgres concat operator —
|
||||
// for jsonb, right-side keys overwrite left-side; nested object keys
|
||||
// are NOT deep-merged (use jsonb_set for nested paths). The patch
|
||||
// shape this autopilot wave uses is flat (`last_full_cycle_at`,
|
||||
// `archive_*`, etc.) so concat is sufficient. Idempotent on re-run.
|
||||
// Atomic single-statement merge. The previous read-then-write form dropped
|
||||
// concurrent updates: two callers patching different keys could both read
|
||||
// the same old config and the later `SET config = ...` clobbered the
|
||||
// earlier patch. These keys are written by background cycle/autopilot
|
||||
// paths, so the merge must happen inside the UPDATE (parity with
|
||||
// pglite-engine.updateSourceConfig, which already uses JSONB `||`).
|
||||
//
|
||||
// The CASE normalizes historical bad shapes inline (so `config` is re-read
|
||||
// against the row-locked latest version — a CTE/subquery snapshot would
|
||||
// reintroduce the lost-update race under READ COMMITTED): older code paths
|
||||
// could store config as a JSONB string (double-encoded) or as a JSONB array
|
||||
// of patch objects. We coerce those to a flat object before the `||` merge
|
||||
// so doctor and source routing keep getting flat keys.
|
||||
//
|
||||
// String branch guard: a JSONB string whose inner text is NOT itself valid
|
||||
// JSON (one of the historical bad shapes this path repairs) would make the
|
||||
// bare `::jsonb` cast raise `invalid input syntax for type json`, failing
|
||||
// the whole UPDATE. Postgres has no `try_cast`, so we gate the cast with
|
||||
// the SQL `IS JSON` predicate (Postgres 16+): parseable inner text is
|
||||
// double-encoded config and gets parsed; unparseable text falls back to `{}`.
|
||||
// The guard keeps the merge a single atomic statement (no extra round-trip,
|
||||
// no lost-update race).
|
||||
//
|
||||
// MUST use sql.json(patch) inside the template tag — postgres-js's
|
||||
// positional executeRaw + `$1::jsonb` cast DOUBLE-ENCODES the
|
||||
@@ -1294,7 +1311,25 @@ export class PostgresEngine implements BrainEngine {
|
||||
const sql = this.sql;
|
||||
const result = await sql`
|
||||
UPDATE sources
|
||||
SET config = COALESCE(config, '{}'::jsonb) || ${sql.json(patch as Parameters<typeof sql.json>[0])}
|
||||
SET config =
|
||||
CASE
|
||||
WHEN jsonb_typeof(config) = 'object' THEN config
|
||||
WHEN jsonb_typeof(config) = 'string'
|
||||
THEN CASE
|
||||
WHEN (config #>> '{}') IS JSON
|
||||
THEN COALESCE(NULLIF((config #>> '{}'), '')::jsonb, '{}'::jsonb)
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
WHEN jsonb_typeof(config) = 'array'
|
||||
THEN COALESCE(
|
||||
(SELECT jsonb_object_agg(kv.key, kv.value)
|
||||
FROM jsonb_array_elements(config) elem,
|
||||
jsonb_each(elem) kv),
|
||||
'{}'::jsonb
|
||||
)
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
|| ${sql.json(patch as Parameters<typeof sql.json>[0])}
|
||||
WHERE id = ${sourceId}
|
||||
`;
|
||||
return (result.count ?? 0) > 0;
|
||||
@@ -3745,8 +3780,26 @@ export class PostgresEngine implements BrainEngine {
|
||||
return { inserted: ids.length, ids };
|
||||
}
|
||||
|
||||
async deleteFactsForPage(slug: string, source_id: string): Promise<{ deleted: number }> {
|
||||
async deleteFactsForPage(
|
||||
slug: string,
|
||||
source_id: string,
|
||||
opts?: { excludeSourcePrefixes?: string[] },
|
||||
): Promise<{ deleted: number }> {
|
||||
const sql = this.sql;
|
||||
const prefixes = opts?.excludeSourcePrefixes;
|
||||
if (prefixes && prefixes.length > 0) {
|
||||
// #1928: keep rows whose `source` matches an excluded prefix (e.g.
|
||||
// `cli:` conversation facts). COALESCE so NULL/empty-source fence rows
|
||||
// stay deletable — only the explicitly-protected prefixes survive.
|
||||
const patterns = prefixes.map(p => `${p}%`);
|
||||
const result = await sql`
|
||||
DELETE FROM facts
|
||||
WHERE source_id = ${source_id}
|
||||
AND source_markdown_slug = ${slug}
|
||||
AND NOT (COALESCE(source, '') LIKE ANY(${patterns}))
|
||||
`;
|
||||
return { deleted: result.count ?? 0 };
|
||||
}
|
||||
const result = await sql`
|
||||
DELETE FROM facts WHERE source_id = ${source_id} AND source_markdown_slug = ${slug}
|
||||
`;
|
||||
@@ -5119,7 +5172,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const toQual = resolved.map(e => e.to_symbol_qualified);
|
||||
const edgeTypes = resolved.map(e => e.edge_type);
|
||||
const metas = resolved.map(e => JSON.stringify(e.edge_metadata ?? {}));
|
||||
const sources = resolved.map(e => e.source_id ?? null);
|
||||
const sources = resolved.map(e => e.source_id ?? 'default');
|
||||
const res = await sql`
|
||||
INSERT INTO code_edges_chunk (from_chunk_id, to_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, edge_metadata, source_id)
|
||||
SELECT * FROM unnest(
|
||||
@@ -5139,7 +5192,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const toQual = unresolved.map(e => e.to_symbol_qualified);
|
||||
const edgeTypes = unresolved.map(e => e.edge_type);
|
||||
const metas = unresolved.map(e => JSON.stringify(e.edge_metadata ?? {}));
|
||||
const sources = unresolved.map(e => e.source_id ?? null);
|
||||
const sources = unresolved.map(e => e.source_id ?? 'default');
|
||||
const res = await sql`
|
||||
INSERT INTO code_edges_symbol (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, edge_metadata, source_id)
|
||||
SELECT * FROM unnest(
|
||||
|
||||
+41
-17
@@ -390,6 +390,11 @@ CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to
|
||||
ON code_edges_chunk(to_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to_symbol
|
||||
ON code_edges_chunk(to_symbol_qualified, edge_type);
|
||||
-- getCalleesOf filters on from_symbol_qualified; without this index every
|
||||
-- callee lookup is a sequential scan, amplified per-BFS-node by the
|
||||
-- recursive code walk. Mirrors migration v116.
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_from_symbol
|
||||
ON code_edges_chunk(from_symbol_qualified);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS code_edges_symbol (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -407,16 +412,32 @@ CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from
|
||||
ON code_edges_symbol(from_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to
|
||||
ON code_edges_symbol(to_symbol_qualified, edge_type);
|
||||
-- getCalleesOf companion to idx_code_edges_chunk_from_symbol above.
|
||||
-- Mirrors migration v116.
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from_symbol
|
||||
ON code_edges_symbol(from_symbol_qualified);
|
||||
|
||||
-- ============================================================
|
||||
-- links: cross-references between pages
|
||||
-- ============================================================
|
||||
-- Provenance model (v0.13, extended issue #972):
|
||||
-- link_source — 'markdown' | 'frontmatter' | 'manual' | 'wikilink-resolved' | NULL
|
||||
-- 'wikilink-resolved' is the opt-in
|
||||
-- (link_resolution.global_basename) basename-match
|
||||
-- provenance — see issue #972 / migration v113.
|
||||
-- (NULL = legacy row written before v0.13; unknown source)
|
||||
-- Provenance model (v0.13; opened to kebab provenance in v114 / issue #1941):
|
||||
-- link_source — open kebab-case provenance tag, NOT a closed allowlist.
|
||||
-- Format gate (CHECK): ^[a-z][a-z0-9]*(-[a-z0-9]+)*\$ and
|
||||
-- char_length <= 64. NULL = legacy row (pre-v0.13).
|
||||
-- Reconciliation-managed built-ins written internally:
|
||||
-- 'markdown' — body markdown links
|
||||
-- 'frontmatter' — YAML frontmatter edges (see origin_*)
|
||||
-- 'mentions' — auto-linked body-text mentions
|
||||
-- 'wikilink-resolved'— opt-in global-basename [[name]] (#972)
|
||||
-- User/tool-facing:
|
||||
-- 'manual' — hand- or tool-created edges (the
|
||||
-- add_link op default + CLI link-add)
|
||||
-- '<your-tag>' — external derivers, e.g. 'citation-graph',
|
||||
-- stamp their own kebab tag (no migration).
|
||||
-- The add_link OP forbids callers from passing the four
|
||||
-- managed built-ins (they imply reconciliation semantics a
|
||||
-- hand-created row can't honor); the DB CHECK still admits
|
||||
-- them because internal writers use them. See operations.ts.
|
||||
-- origin_page_id — for link_source='frontmatter', the page whose YAML
|
||||
-- frontmatter created this edge; scopes reconciliation
|
||||
-- origin_field — the frontmatter field name (e.g. 'key_people')
|
||||
@@ -424,21 +445,21 @@ CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to
|
||||
-- The unique constraint includes link_source + origin_page_id so a manual edge
|
||||
-- and a frontmatter-derived edge with the same (from, to, type) tuple coexist.
|
||||
-- Reconciliation on put_page filters by (link_source='frontmatter' AND
|
||||
-- origin_page_id = written_page) — never touches other pages' edges.
|
||||
-- origin_page_id = written_page) — never touches other pages' edges. (This is
|
||||
-- exactly why a CLI-forged 'frontmatter' row with NULL origin would be a phantom
|
||||
-- edge reconciliation never cleans — hence the op-layer guard.)
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
link_type TEXT NOT NULL DEFAULT '',
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
-- v114 (#1941): link_source is an open kebab-case provenance, not a closed
|
||||
-- allowlist — external derivers (e.g. 'citation-graph') stamp their own tag
|
||||
-- without a gbrain migration. Format gate only: lowercase kebab, <=64 chars.
|
||||
-- The reconciliation-managed built-ins ('markdown','frontmatter','mentions',
|
||||
-- 'wikilink-resolved') still satisfy this and are written internally; the
|
||||
-- add_link op forbids CALLERS from forging them (see operations.ts). 'manual'
|
||||
-- is the user-facing default for hand/tool-created edges.
|
||||
link_source TEXT CHECK (link_source IS NULL OR (link_source ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' AND char_length(link_source) <= 64)),
|
||||
-- v0.41.18.0: 'mentions' added for auto-linked body-text mentions
|
||||
-- (gbrain extract links --by-mention). Filtered OUT of backlink-count
|
||||
-- for search ranking; only counts toward orphan-ratio + graph traversal.
|
||||
-- v0.40.8.2 (#972): 'wikilink-resolved' added for opt-in global-basename
|
||||
-- wikilink resolution (bare [[name]] resolved by slug tail).
|
||||
link_source TEXT CHECK (link_source IS NULL OR (link_source ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*\$' AND char_length(link_source) <= 64)),
|
||||
-- v0.41.18.0: nullable link_kind distinguishes "plain body mention" from
|
||||
-- "verb-pattern-derived typed link" within link_source='mentions'.
|
||||
-- Codex finding #12 design: keep link_source stable; add link_kind
|
||||
@@ -675,8 +696,11 @@ CREATE TABLE IF NOT EXISTS op_checkpoints (
|
||||
CREATE INDEX IF NOT EXISTS op_checkpoints_updated_at_idx
|
||||
ON op_checkpoints (updated_at);
|
||||
|
||||
-- #1794: append-only delta storage (one row per completed path). FK cascade
|
||||
-- drops children with the parent. Mirrors migration v115 + src/schema.sql.
|
||||
-- #1794: append-only delta storage. One row per completed path; sync's
|
||||
-- appendCompleted INSERTs only the delta instead of rewriting the whole
|
||||
-- completed_keys JSONB array each flush (O(N^2) -> O(delta)). FK cascade drops
|
||||
-- children with the parent (clearOpCheckpoint + 7-day purge). PK prefix
|
||||
-- (op,fingerprint) serves all reads; no separate index. Mirrors migration v115.
|
||||
CREATE TABLE IF NOT EXISTS op_checkpoint_paths (
|
||||
op TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
|
||||
+10
-1
@@ -738,7 +738,16 @@ export function attributeKnob<K extends keyof ModeBundle>(
|
||||
// to take effect immediately (one-time global cache cold-miss on upgrade; refills
|
||||
// within cache.ttl_seconds). Same cache-key-contamination convention as the
|
||||
// autocut / title_boost / graph_signals bumps above.
|
||||
export const KNOBS_HASH_VERSION = 10;
|
||||
//
|
||||
// bump 10→11 (#1400 input_type fix): asymmetric embedding models (zembed-1
|
||||
// hosted or local, Voyage v3+) had their query-side input_type stripped by
|
||||
// the AI SDK before the wire, so every cached row's key embedding AND result
|
||||
// set were computed with document-side query vectors. The fix changes what
|
||||
// embedQuery() produces for those providers; pre-fix rows must not be served
|
||||
// to post-fix lookups. Same one-time global cold-miss pattern as the bumps
|
||||
// above (the hash is global, not per-provider); refills within
|
||||
// cache.ttl_seconds (3600s default).
|
||||
export const KNOBS_HASH_VERSION = 11;
|
||||
|
||||
/**
|
||||
* v0.36 (D8 / CDX-2) — second-arg context for the cache key. The
|
||||
|
||||
@@ -241,6 +241,19 @@ function matchesAnyGlob(path: string, patterns?: string[]): boolean {
|
||||
*/
|
||||
const PRUNE_DIR_NAMES = new Set<string>([
|
||||
'node_modules',
|
||||
// Dependency / build-output trees that are git-ignored on virtually every
|
||||
// repo and never contain hand-authored source worth indexing. `vendor`
|
||||
// (PHP Composer / Go / Ruby bundle), `dist` + `build` (compiled output).
|
||||
// Closes the silent-pollution bug where a Laravel/PHP repo's full code sync
|
||||
// walked ~50k `vendor/` files (#1483 / #1159 / maintainer #1942).
|
||||
'vendor',
|
||||
'dist',
|
||||
'build',
|
||||
// Python venv: vendored dependency tree, the `node_modules` analogue (#2020).
|
||||
// Like `node_modules` it lacks a leading dot so isSyncable's dot-prefix
|
||||
// exclusion misses it; explicit entry keeps incremental sync consistent
|
||||
// with the first-sync walker in commands/import.ts.
|
||||
'venv',
|
||||
'.raw',
|
||||
'ops',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* #2038 — idx_timeline_dedup schema-drift self-heal.
|
||||
*
|
||||
* Migration v102 (`timeline_entries_source_in_dedup`) widens the dedup index
|
||||
* from (page_id, date, summary) to (page_id, date, summary, source). It was
|
||||
* renumbered from v99 during a master merge, so a brain that ran the OLD v99
|
||||
* variant has its version counter stamped PAST v102 while the index stayed
|
||||
* 3-column. `runMigrations` then can't see the drift (it early-returns when no
|
||||
* version is pending), and every `addTimelineEntry(esBatch)` fails with
|
||||
* "no unique or exclusion constraint matching the ON CONFLICT specification"
|
||||
* because both insert sites infer on the 4-column tuple — timeline writes
|
||||
* silently break brain-wide.
|
||||
*
|
||||
* The version counter can't detect this, so the repair is keyed off the actual
|
||||
* index SHAPE and runs on every migrate pass (including the no-pending path).
|
||||
* Idempotent: a no-op when the index is already 4-column.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
|
||||
const INDEX_NAME = 'idx_timeline_dedup';
|
||||
const EXPECTED_COLUMNS = ['page_id', 'date', 'summary', 'source'];
|
||||
|
||||
export interface TimelineDedupStatus {
|
||||
/** The timeline_entries table exists (nothing to repair if not). */
|
||||
tablePresent: boolean;
|
||||
/** The index exists. */
|
||||
indexPresent: boolean;
|
||||
/** Indexed columns in order (empty when the index is absent). */
|
||||
columns: string[];
|
||||
/** Index exists in the wrong (pre-v102) shape — needs a rebuild. */
|
||||
needsRepair: boolean;
|
||||
}
|
||||
|
||||
/** Parse the column list out of a pg_indexes `indexdef` string. */
|
||||
function parseIndexColumns(indexdef: string): string[] {
|
||||
const open = indexdef.lastIndexOf('(');
|
||||
const close = indexdef.lastIndexOf(')');
|
||||
if (open < 0 || close < 0 || close < open) return [];
|
||||
return indexdef
|
||||
.slice(open + 1, close)
|
||||
.split(',')
|
||||
.map(c => c.trim().split(/\s+/)[0]) // drop any "col DESC"/opclass suffix
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function checkTimelineDedupIndex(engine: BrainEngine): Promise<TimelineDedupStatus> {
|
||||
const tbl = await engine.executeRaw<{ reg: string | null }>(
|
||||
`SELECT to_regclass('timeline_entries')::text AS reg`,
|
||||
);
|
||||
const tablePresent = !!tbl[0]?.reg;
|
||||
if (!tablePresent) {
|
||||
return { tablePresent: false, indexPresent: false, columns: [], needsRepair: false };
|
||||
}
|
||||
const rows = await engine.executeRaw<{ indexdef: string }>(
|
||||
`SELECT indexdef FROM pg_indexes WHERE indexname = $1`,
|
||||
[INDEX_NAME],
|
||||
);
|
||||
const indexPresent = rows.length > 0;
|
||||
const columns = indexPresent ? parseIndexColumns(rows[0].indexdef) : [];
|
||||
const correct =
|
||||
columns.length === EXPECTED_COLUMNS.length &&
|
||||
EXPECTED_COLUMNS.every((c, i) => columns[i] === c);
|
||||
// An ABSENT index is also "needs repair" — the migration that creates it was
|
||||
// skipped. (A fresh brain always has it, created by the migration chain.)
|
||||
return { tablePresent, indexPresent, columns, needsRepair: !correct };
|
||||
}
|
||||
|
||||
export interface TimelineDedupRepairResult {
|
||||
repaired: boolean;
|
||||
before: string[];
|
||||
collapsedDuplicates: number;
|
||||
reason: 'already_correct' | 'no_table' | 'rebuilt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal the index if it's missing the v102 4-column shape. Dedupes FIRST —
|
||||
* the loose 3-column index let rows differing only by `source` coexist, and
|
||||
* `CREATE UNIQUE INDEX` would throw on those collisions otherwise. Keeps the
|
||||
* earliest row (min ctid) of each 4-tuple group.
|
||||
*/
|
||||
export async function repairTimelineDedupIndex(engine: BrainEngine): Promise<TimelineDedupRepairResult> {
|
||||
const status = await checkTimelineDedupIndex(engine);
|
||||
if (!status.tablePresent) {
|
||||
return { repaired: false, before: [], collapsedDuplicates: 0, reason: 'no_table' };
|
||||
}
|
||||
if (!status.needsRepair) {
|
||||
return { repaired: false, before: status.columns, collapsedDuplicates: 0, reason: 'already_correct' };
|
||||
}
|
||||
|
||||
// Keep the lowest `id` per 4-tuple group — deterministic and consistent with
|
||||
// the existing v-migration dedup rule (`a.id > b.id`), unlike `ctid` which is
|
||||
// a physical tuple location that can preserve an arbitrary duplicate.
|
||||
const del = await engine.executeRaw<{ n: string }>(
|
||||
`WITH d AS (
|
||||
DELETE FROM timeline_entries t
|
||||
USING (
|
||||
SELECT page_id, date, summary, source, MIN(id) AS keep
|
||||
FROM timeline_entries
|
||||
GROUP BY page_id, date, summary, source
|
||||
HAVING COUNT(*) > 1
|
||||
) dup
|
||||
WHERE t.page_id = dup.page_id
|
||||
AND t.date = dup.date
|
||||
AND t.summary = dup.summary
|
||||
AND t.source IS NOT DISTINCT FROM dup.source
|
||||
AND t.id <> dup.keep
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*)::text AS n FROM d`,
|
||||
);
|
||||
const collapsedDuplicates = parseInt(del[0]?.n ?? '0', 10);
|
||||
|
||||
await engine.executeRaw(`DROP INDEX IF EXISTS ${INDEX_NAME}`);
|
||||
await engine.executeRaw(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME}
|
||||
ON timeline_entries(page_id, date, summary, source)`,
|
||||
);
|
||||
return { repaired: true, before: status.columns, collapsedDuplicates, reason: 'rebuilt' };
|
||||
}
|
||||
+48
-11
@@ -22,7 +22,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { dirname, join } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import { serializePageToMarkdown, resolvePageFilePath } from './markdown.ts';
|
||||
@@ -37,12 +37,16 @@ export interface WriteThroughResult {
|
||||
path?: string;
|
||||
/**
|
||||
* Non-error reasons the file was not written:
|
||||
* - no_repo_configured: `sync.repo_path` is unset (DB-only by design).
|
||||
* - repo_not_found: `sync.repo_path` set but missing / not a directory.
|
||||
* - no_repo_configured: the resolved target (source `local_path` or, for a
|
||||
* sole-source brain, `sync.repo_path`) is unset (DB-only by design).
|
||||
* - repo_not_found: target set but missing / not a directory.
|
||||
* - source_repo_belongs_to_other_source: the assigned source has no
|
||||
* `local_path`, and `sync.repo_path` is another source's own working tree
|
||||
* — #2018: writing here would pollute that sibling's repo, so we skip.
|
||||
* - page_not_found_after_write: the DB row isn't readable back (the caller's
|
||||
* DB write failed or targeted a different source).
|
||||
*/
|
||||
skipped?: 'no_repo_configured' | 'repo_not_found' | 'page_not_found_after_write';
|
||||
skipped?: 'no_repo_configured' | 'repo_not_found' | 'source_repo_belongs_to_other_source' | 'page_not_found_after_write';
|
||||
/** Set when the render/write/rename itself threw (EACCES, ENOTDIR, disk full). */
|
||||
error?: string;
|
||||
}
|
||||
@@ -67,12 +71,46 @@ export async function writePageThrough(
|
||||
): Promise<WriteThroughResult> {
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
try {
|
||||
const repoPath = await engine.getConfig('sync.repo_path');
|
||||
if (!repoPath) {
|
||||
return { written: false, skipped: 'no_repo_configured' };
|
||||
}
|
||||
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
||||
return { written: false, skipped: 'repo_not_found' };
|
||||
// #2018: pick the disk target so a page is NEVER written into a different
|
||||
// source's working tree. Two legitimate topologies, plus the leak guard:
|
||||
// 1. The assigned source has its OWN `local_path` (a separate working
|
||||
// tree) → write at that tree's root (matches how `scanOneSource` reads
|
||||
// it back; never nested under `.sources/`).
|
||||
// 2. No per-source `local_path` → nest under the host repo
|
||||
// (`sync.repo_path`): default at the root, non-default under
|
||||
// `.sources/<id>/` (the established multi-source layout).
|
||||
// 3. LEAK GUARD: if `sync.repo_path` is literally ANOTHER source's own
|
||||
// `local_path`, nesting this page there would pollute that sibling's
|
||||
// git repo (the reported bug). Skip instead.
|
||||
let filePath: string;
|
||||
const srcRows = await engine.executeRaw<{ local_path: string | null }>(
|
||||
`SELECT local_path FROM sources WHERE id = $1`,
|
||||
[sourceId],
|
||||
);
|
||||
const sourceLocalPath = srcRows[0]?.local_path ?? null;
|
||||
if (sourceLocalPath) {
|
||||
if (!existsSync(sourceLocalPath) || !statSync(sourceLocalPath).isDirectory()) {
|
||||
return { written: false, skipped: 'repo_not_found' };
|
||||
}
|
||||
filePath = join(sourceLocalPath, `${slug}.md`);
|
||||
} else {
|
||||
const repoPath = await engine.getConfig('sync.repo_path');
|
||||
if (!repoPath) {
|
||||
return { written: false, skipped: 'no_repo_configured' };
|
||||
}
|
||||
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
||||
return { written: false, skipped: 'repo_not_found' };
|
||||
}
|
||||
// Leak guard: refuse to write into a path that is some OTHER source's
|
||||
// own working tree (#2018).
|
||||
const collide = await engine.executeRaw<{ one: number }>(
|
||||
`SELECT 1 AS one FROM sources WHERE id <> $1 AND local_path = $2 LIMIT 1`,
|
||||
[sourceId, repoPath],
|
||||
);
|
||||
if (collide.length > 0) {
|
||||
return { written: false, skipped: 'source_repo_belongs_to_other_source' };
|
||||
}
|
||||
filePath = resolvePageFilePath(repoPath, slug, sourceId);
|
||||
}
|
||||
|
||||
const writtenPage = await engine.getPage(slug, { sourceId });
|
||||
@@ -85,7 +123,6 @@ export async function writePageThrough(
|
||||
frontmatterOverrides: opts.frontmatterOverrides,
|
||||
});
|
||||
|
||||
const filePath = resolvePageFilePath(repoPath, slug, sourceId);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
|
||||
// Atomic write: unique temp sibling + rename. Unique name (pid + random)
|
||||
|
||||
@@ -34,6 +34,8 @@ import { VERSION } from '../version.ts';
|
||||
import { dispatchToolCall } from './dispatch.ts';
|
||||
import { buildDefaultLimiters, type RateLimiter } from './rate-limit.ts';
|
||||
import { sqlQueryForEngine } from '../core/sql-query.ts';
|
||||
import { parseLegacyTokenScope } from '../core/legacy-token-scope.ts';
|
||||
export { parseLegacyTokenScope };
|
||||
|
||||
const DEFAULT_BODY_CAP = 1024 * 1024; // 1 MiB
|
||||
|
||||
@@ -84,28 +86,8 @@ interface AuthResult {
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1336: derive a legacy bearer token's source scope from its stored
|
||||
* `permissions.source_id`. An ARRAY value is a federated_read grant →
|
||||
* `allowedSources` (scoped reads across exactly those sources). A STRING value
|
||||
* scopes the scalar floor. Anything else → 'default' (preserves pre-v0.34
|
||||
* behavior). NEVER widened to "all": an empty/garbage value keeps the 'default'
|
||||
* floor and no federated grant.
|
||||
*/
|
||||
export function parseLegacyTokenScope(rawSource: unknown): { sourceId: string; allowedSources?: string[] } {
|
||||
if (Array.isArray(rawSource)) {
|
||||
const allowedSources = (rawSource as unknown[]).filter(s => typeof s === 'string' && s.length > 0) as string[];
|
||||
if (allowedSources.length > 0) {
|
||||
// Scalar floor: the first granted source (write authority); reads span the array.
|
||||
return { sourceId: allowedSources[0], allowedSources };
|
||||
}
|
||||
return { sourceId: 'default' };
|
||||
}
|
||||
if (typeof rawSource === 'string' && rawSource.length > 0) {
|
||||
return { sourceId: rawSource };
|
||||
}
|
||||
return { sourceId: 'default' };
|
||||
}
|
||||
/* Legacy token source-scope parsing lives in core/legacy-token-scope.ts and is
|
||||
* re-exported above so the legacy HTTP transport and OAuth provider cannot drift. */
|
||||
|
||||
/** Read up to `cap` bytes off req.body. Returns null if cap exceeded. */
|
||||
async function readBodyWithCap(req: Request, cap: number): Promise<string | null> {
|
||||
|
||||
@@ -386,6 +386,11 @@ CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to
|
||||
ON code_edges_chunk(to_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to_symbol
|
||||
ON code_edges_chunk(to_symbol_qualified, edge_type);
|
||||
-- getCalleesOf filters on from_symbol_qualified; without this index every
|
||||
-- callee lookup is a sequential scan, amplified per-BFS-node by the
|
||||
-- recursive code walk. Mirrors migration v116.
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_from_symbol
|
||||
ON code_edges_chunk(from_symbol_qualified);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS code_edges_symbol (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -403,6 +408,10 @@ CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from
|
||||
ON code_edges_symbol(from_chunk_id, edge_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to
|
||||
ON code_edges_symbol(to_symbol_qualified, edge_type);
|
||||
-- getCalleesOf companion to idx_code_edges_chunk_from_symbol above.
|
||||
-- Mirrors migration v116.
|
||||
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from_symbol
|
||||
ON code_edges_symbol(from_symbol_qualified);
|
||||
|
||||
-- ============================================================
|
||||
-- links: cross-references between pages
|
||||
|
||||
@@ -53,13 +53,15 @@ describe('zeroEntropyCompatFetch — shim structural shape', () => {
|
||||
expect(src).not.toContain('/v1/v1/');
|
||||
});
|
||||
|
||||
test('body injects input_type default "document"', async () => {
|
||||
test('body recovers threaded input_type, defaulting to "document"', async () => {
|
||||
const src = await Bun.file(GATEWAY_PATH).text();
|
||||
// The wrapper defaults input_type to 'document' when caller didn't
|
||||
// thread one (matches the document-side correctness for sync /
|
||||
// import / embed CLI paths).
|
||||
// The wrapper recovers the SDK-stripped input_type from
|
||||
// __embedInputTypeStore (#1400) and still defaults to 'document' when
|
||||
// no caller threaded one (document-side correctness for sync / import /
|
||||
// embed CLI paths). Wire-level behavioral coverage lives in
|
||||
// test/embed-input-type-wire.test.ts.
|
||||
expect(src).toMatch(/parsed\.input_type\s*===\s*undefined/);
|
||||
expect(src).toMatch(/parsed\.input_type\s*=\s*['"]document['"]/);
|
||||
expect(src).toMatch(/parsed\.input_type\s*=\s*__embedInputTypeStore\.getStore\(\)\s*\?\?\s*['"]document['"]/);
|
||||
});
|
||||
|
||||
test('body forces encoding_format=float (CDX2-F2)', async () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Structural regression — the DISCONNECT_HARD_DEADLINE_MS force-exit timer in
|
||||
* cli.ts main() must be armed at TEARDOWN ENTRY (inside the finally, before
|
||||
* the drain + disconnect), never before the op-dispatch try block.
|
||||
*
|
||||
* Pre-fix bug: the 10s unref'd setTimeout was armed BEFORE the try, so any op
|
||||
* whose handler ran past 10s wall-clock was killed mid-flight with
|
||||
* process.exit(0) and ZERO stdout — an empty "success" indistinguishable from
|
||||
* no results (a healthy `gbrain search` on a slow Postgres pooler hit this on
|
||||
* every run). Armed in the finally, the timer still bounds a hung
|
||||
* drain/disconnect (the C13 contract) but can no longer kill a
|
||||
* slow-but-progressing op body.
|
||||
*
|
||||
* Source-grep is the right tool here (same rationale as
|
||||
* fix-wave-structural.test.ts): the rule is "this arming must stay at this
|
||||
* location". A behavioral test would need >10s of real wall-clock plus a
|
||||
* deliberately slow op handler in a spawned CLI — slow and flaky by
|
||||
* construction.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
describe('cli.ts — disconnect hard-deadline armed at teardown entry, not before the op body', () => {
|
||||
test('forceExitTimer setTimeout lives inside the finally, gated on the daemon guard, before the drain', () => {
|
||||
const src = readFileSync('src/cli.ts', 'utf8');
|
||||
|
||||
const decl = src.indexOf('const DISCONNECT_HARD_DEADLINE_MS');
|
||||
expect(decl).toBeGreaterThan(-1);
|
||||
const tryIdx = src.indexOf('try {', decl);
|
||||
expect(tryIdx).toBeGreaterThan(-1);
|
||||
const finallyIdx = src.indexOf('} finally {', tryIdx);
|
||||
expect(finallyIdx).toBeGreaterThan(-1);
|
||||
const armIdx = src.indexOf('forceExitTimer = setTimeout', decl);
|
||||
expect(armIdx).toBeGreaterThan(-1);
|
||||
const drainIdx = src.indexOf('drainAllBackgroundWorkForCliExit', finallyIdx);
|
||||
expect(drainIdx).toBeGreaterThan(-1);
|
||||
|
||||
// NO arming between the deadline declaration and the op-body try — a
|
||||
// pre-try timer kills slow-but-progressing op handlers mid-flight with
|
||||
// exit 0 and empty stdout. (`setTimeout(` matches only a call site; the
|
||||
// `ReturnType<typeof setTimeout>` type annotation stays allowed.)
|
||||
expect(src.slice(decl, tryIdx)).not.toContain('setTimeout(');
|
||||
|
||||
// The arming sits AFTER the finally opens (teardown entry) and BEFORE the
|
||||
// drain + disconnect it exists to bound.
|
||||
expect(armIdx).toBeGreaterThan(finallyIdx);
|
||||
expect(armIdx).toBeLessThan(drainIdx);
|
||||
|
||||
// Still gated on the daemon-survival guard so `serve` stays alive, and
|
||||
// still unref'd + cleared on clean teardown.
|
||||
expect(src.slice(finallyIdx, drainIdx)).toMatch(/if \(shouldForceExitAfterMain\(\)\)/);
|
||||
expect(src.slice(finallyIdx, drainIdx)).toContain('forceExitTimer.unref?.()');
|
||||
expect(src.slice(drainIdx)).toContain('if (forceExitTimer) clearTimeout(forceExitTimer)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { dotenvValuesForKey, effectiveEnvDatabaseUrl } from '../src/core/config.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
// #427 — Bun auto-loads `.env` from the process cwd, so running gbrain inside
|
||||
// any web-app checkout whose `.env` defines DATABASE_URL silently retargets
|
||||
// the brain at that app's database. These tests pin the guard:
|
||||
// effectiveEnvDatabaseUrl() must treat a DATABASE_URL whose value matches a
|
||||
// cwd .env assignment as file-origin (ignored), while still honoring
|
||||
// GBRAIN_DATABASE_URL and genuinely exported DATABASE_URLs.
|
||||
//
|
||||
// The dir is injected instead of process.chdir'd so these tests stay safe in
|
||||
// the parallel shard runner (cwd is process-global, same reason with-env.ts
|
||||
// exists for process.env).
|
||||
|
||||
function tmpProject(envFiles: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-'));
|
||||
for (const [name, content] of Object.entries(envFiles)) {
|
||||
writeFileSync(join(dir, name), content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('dotenvValuesForKey', () => {
|
||||
test('collects assignments across all auto-loaded .env variants', () => {
|
||||
const dir = tmpProject({
|
||||
'.env': 'DATABASE_URL=postgres://app:pw@prod.example.test:5432/app\n',
|
||||
'.env.local': "DATABASE_URL='postgres://app:pw@local.example.test:5432/app'\n",
|
||||
});
|
||||
try {
|
||||
const values = dotenvValuesForKey('DATABASE_URL', dir);
|
||||
expect(values.has('postgres://app:pw@prod.example.test:5432/app')).toBe(true);
|
||||
expect(values.has('postgres://app:pw@local.example.test:5432/app')).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('handles export prefix, double quotes, comments, and unrelated keys', () => {
|
||||
const dir = tmpProject({
|
||||
'.env': [
|
||||
'# comment line',
|
||||
'export DATABASE_URL="postgres://quoted.example.test/db"',
|
||||
'OTHER_KEY=not-a-db-url',
|
||||
'EMPTY=',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
try {
|
||||
const values = dotenvValuesForKey('DATABASE_URL', dir);
|
||||
expect(values.has('postgres://quoted.example.test/db')).toBe(true);
|
||||
expect(values.size).toBe(1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('returns empty set when no .env files exist', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-none-'));
|
||||
try {
|
||||
expect(dotenvValuesForKey('DATABASE_URL', dir).size).toBe(0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveEnvDatabaseUrl (#427 guard)', () => {
|
||||
const HIJACK_URL = 'postgres://app:pw@victim-prod.example.test:5432/app';
|
||||
const OPERATOR_URL = 'postgres://operator@deliberate.example.test:5432/brain';
|
||||
|
||||
test('ignores DATABASE_URL whose value matches a cwd .env assignment', async () => {
|
||||
const dir = tmpProject({ '.env': `DATABASE_URL=${HIJACK_URL}\n` });
|
||||
try {
|
||||
await withEnv(
|
||||
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: HIJACK_URL },
|
||||
() => {
|
||||
expect(effectiveEnvDatabaseUrl(dir)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('honors an exported DATABASE_URL that differs from the cwd .env', async () => {
|
||||
const dir = tmpProject({ '.env': `DATABASE_URL=${HIJACK_URL}\n` });
|
||||
try {
|
||||
await withEnv(
|
||||
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: OPERATOR_URL },
|
||||
() => {
|
||||
expect(effectiveEnvDatabaseUrl(dir)).toBe(OPERATOR_URL);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('GBRAIN_DATABASE_URL is never ignored, even when it matches the cwd .env', async () => {
|
||||
const dir = tmpProject({ '.env': `GBRAIN_DATABASE_URL=${OPERATOR_URL}\nDATABASE_URL=${HIJACK_URL}\n` });
|
||||
try {
|
||||
await withEnv(
|
||||
{ GBRAIN_DATABASE_URL: OPERATOR_URL, DATABASE_URL: HIJACK_URL },
|
||||
() => {
|
||||
expect(effectiveEnvDatabaseUrl(dir)).toBe(OPERATOR_URL);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('honors DATABASE_URL when no .env files exist in cwd', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-clean-'));
|
||||
try {
|
||||
await withEnv(
|
||||
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: OPERATOR_URL },
|
||||
() => {
|
||||
expect(effectiveEnvDatabaseUrl(dir)).toBe(OPERATOR_URL);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('returns undefined when neither env var is set', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-env-hijack-unset-'));
|
||||
try {
|
||||
await withEnv(
|
||||
{ GBRAIN_DATABASE_URL: undefined, DATABASE_URL: undefined },
|
||||
() => {
|
||||
expect(effectiveEnvDatabaseUrl(dir)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -317,8 +317,10 @@ describe('Eng-review D3 — executeRaw has no per-call retry wrapper', () => {
|
||||
it('Supervisor still has the 3-strikes-then-reconnect path', () => {
|
||||
const src = readFileSync(resolve('src/core/minions/supervisor.ts'), 'utf-8');
|
||||
expect(src).toContain('consecutiveHealthFailures');
|
||||
// Supervisor invokes reconnect via a typed cast after 3 consecutive failures.
|
||||
expect(src).toMatch(/reconnect\(\): Promise<void>/);
|
||||
// #2034: reconnect() is now a first-class BrainEngine method, so the
|
||||
// supervisor calls it directly after 3 consecutive failures (the prior
|
||||
// `(engine as unknown as { reconnect(): Promise<void> })` cast was removed).
|
||||
expect(src).toContain('this.engine.reconnect(');
|
||||
expect(src).toContain('this.consecutiveHealthFailures >= 3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,15 +136,17 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
|
||||
return resolveSearchMode({ mode: 'balanced' });
|
||||
}
|
||||
|
||||
test('KNOBS_HASH_VERSION is 10 (cross-modal still appended; 9→10 relational recall)', () => {
|
||||
test('KNOBS_HASH_VERSION is 11 (cross-modal still appended; 10→11 asymmetric input_type fix)', () => {
|
||||
// v0.35 ladder: 1→2 reranker, 2→3 floor_ratio. v0.36 piggybacks on v=3
|
||||
// with 7 cross-modal knobs + column/provider context. v0.40.4 (salem) +
|
||||
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
|
||||
// v0.40.3.0 D8 bumps to v=5 (sequenced behind salem's v=4 graph-signals).
|
||||
// v0.41.22.0 (type-unification): 5→6 for alias_resolved post-fusion boost.
|
||||
// T2: 6→7 title_boost. v0.42.3.0: 7→8 autocut. issue #1777: 8→9 archive/ demote.
|
||||
// v0.43: 9→10 relational recall arm.
|
||||
expect(KNOBS_HASH_VERSION).toBe(10);
|
||||
// v0.43: 9→10 relational recall arm. #1400: 10→11 query-side input_type
|
||||
// finally reaches asymmetric providers — pre-fix rows were keyed on
|
||||
// document-side query vectors.
|
||||
expect(KNOBS_HASH_VERSION).toBe(11);
|
||||
});
|
||||
|
||||
test('flipping unified_multimodal changes the hash', () => {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* #1400 — asymmetric `input_type` must survive the AI SDK boundary and
|
||||
* reach the WIRE BODY.
|
||||
*
|
||||
* test/asymmetric-encoding-contract.test.ts pins that embedQuery() threads
|
||||
* `input_type: 'query'` into the transport's providerOptions. This file
|
||||
* pins the layer BELOW that contract: the AI SDK's openai-compatible
|
||||
* adapter validates providerOptions against a fixed schema and silently
|
||||
* drops `input_type` before building the HTTP body. Without the
|
||||
* `__embedInputTypeStore` recovery in the per-recipe fetch shims, every
|
||||
* query was encoded document-side (ZE shim's hard default) or with no
|
||||
* input_type at all (Voyage, llama-server) — asymmetric retrieval silently
|
||||
* collapsed while the providerOptions-level test stayed green.
|
||||
*
|
||||
* These tests run the REAL SDK transport with a mocked global fetch and
|
||||
* assert on the outbound request body — the only place the regression is
|
||||
* observable.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { configureGateway, embed, embedQuery, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
|
||||
type FetchHandler = (url: string, init: RequestInit) => Promise<Response>;
|
||||
let fetchHandler: FetchHandler | null = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchHandler = null;
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
if (!fetchHandler) {
|
||||
throw new Error('fetch called but no handler installed');
|
||||
}
|
||||
return fetchHandler(typeof url === 'string' ? url : url.toString(), init ?? {});
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
/** OpenAI-shaped /v1/embeddings response (llama-server is already OpenAI-shaped). */
|
||||
function openAIShapedResponse(dims: number, count: number): Response {
|
||||
const vec = Array.from({ length: dims }, () => 0.1);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: Array.from({ length: count }, (_, i) => ({ object: 'embedding', index: i, embedding: vec })),
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
/** ZE-shaped /v1/models/embed response (zeroEntropyCompatFetch rewrites results→data). */
|
||||
function zeShapedResponse(dims: number, count: number): Response {
|
||||
const vec = Array.from({ length: dims }, () => 0.1);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: Array.from({ length: count }, () => ({ embedding: vec })),
|
||||
usage: { total_bytes: 12, total_tokens: 3 },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
/** Voyage-shaped response: base64 Float32 LE embeddings (rewriter decodes). */
|
||||
function voyageShapedResponse(dims: number, count: number): Response {
|
||||
const b64 = Buffer.from(new Float32Array(dims).fill(0.1).buffer).toString('base64');
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: Array.from({ length: count }, (_, i) => ({ object: 'embedding', index: i, embedding: b64 })),
|
||||
usage: { total_tokens: 3 },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
describe('ZeroEntropy hosted — input_type reaches the wire body', () => {
|
||||
function configureZE() {
|
||||
configureGateway({
|
||||
embedding_model: 'zeroentropyai:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
env: { ZEROENTROPY_API_KEY: 'sk-fake' },
|
||||
});
|
||||
}
|
||||
|
||||
test('embedQuery sends input_type=query (not the document default)', async () => {
|
||||
configureZE();
|
||||
let capturedUrl = '';
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (url, init) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return zeShapedResponse(1280, 1);
|
||||
};
|
||||
|
||||
await embedQuery('what does foo bar do?');
|
||||
// Sanity: the ZE shim ran (URL path rewritten).
|
||||
expect(capturedUrl).toContain('/models/embed');
|
||||
expect(capturedBody.input_type).toBe('query');
|
||||
});
|
||||
|
||||
test('embed (index path) sends input_type=document', async () => {
|
||||
configureZE();
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return zeShapedResponse(1280, 1);
|
||||
};
|
||||
|
||||
await embed(['this is a document being indexed']);
|
||||
expect(capturedBody.input_type).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openai-compatible recipes (local/proxy asymmetric models) — input_type reaches the wire body', () => {
|
||||
function configureLlamaServer(modelId: string, dims: number) {
|
||||
configureGateway({
|
||||
embedding_model: `llama-server:${modelId}`,
|
||||
embedding_dimensions: dims,
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('embedQuery against a local zembed-1 sends input_type=query', async () => {
|
||||
configureLlamaServer('zembed-1', 1280);
|
||||
let capturedUrl = '';
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (url, init) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return openAIShapedResponse(1280, 1);
|
||||
};
|
||||
|
||||
await embedQuery('what does foo bar do?');
|
||||
// URL untouched — llama-server's /v1/embeddings is already OpenAI-shaped.
|
||||
expect(capturedUrl).toContain('/embeddings');
|
||||
expect(capturedUrl).not.toContain('/models/embed');
|
||||
expect(capturedBody.input_type).toBe('query');
|
||||
});
|
||||
|
||||
test('embed (index path) against a local zembed-1 sends input_type=document', async () => {
|
||||
configureLlamaServer('zembed-1', 1280);
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return openAIShapedResponse(1280, 1);
|
||||
};
|
||||
|
||||
await embed(['this is a document being indexed']);
|
||||
expect(capturedBody.input_type).toBe('document');
|
||||
});
|
||||
|
||||
test('non-asymmetric model: wire body carries NO input_type (strict pass-through)', async () => {
|
||||
// dims.ts only threads input_type for recognized asymmetric models;
|
||||
// for anything else the shim must leave the body untouched so vanilla
|
||||
// llama-server deployments see zero wire change.
|
||||
configureLlamaServer('my-gguf', 768);
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return openAIShapedResponse(768, 1);
|
||||
};
|
||||
|
||||
await embedQuery('hello');
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect('input_type' in capturedBody).toBe(false);
|
||||
});
|
||||
|
||||
test('litellm proxying an asymmetric model: embedQuery sends input_type=query', async () => {
|
||||
// The shim is the fallthrough default for every openai-compatible
|
||||
// recipe without its own compat fetch — dims.ts threads input_type by
|
||||
// model id, so a zembed-1 behind a LiteLLM proxy (e.g. fronting vLLM)
|
||||
// gets the same signal as llama-server.
|
||||
configureGateway({
|
||||
embedding_model: 'litellm:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
env: { LITELLM_API_KEY: 'sk-fake' },
|
||||
base_urls: { litellm: 'http://localhost:4000' },
|
||||
});
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return openAIShapedResponse(1280, 1);
|
||||
};
|
||||
|
||||
await embedQuery('what does foo bar do?');
|
||||
expect(capturedBody.input_type).toBe('query');
|
||||
});
|
||||
|
||||
test('ollama serving an asymmetric model: embedQuery sends input_type=query', async () => {
|
||||
configureGateway({
|
||||
embedding_model: 'ollama:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
env: {},
|
||||
});
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return openAIShapedResponse(1280, 1);
|
||||
};
|
||||
|
||||
await embedQuery('what does foo bar do?');
|
||||
expect(capturedBody.input_type).toBe('query');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voyage hosted — input_type reaches the wire body (opt-in preserved)', () => {
|
||||
function configureVoyage() {
|
||||
configureGateway({
|
||||
embedding_model: 'voyage:voyage-3-large',
|
||||
embedding_dimensions: 1024,
|
||||
env: { VOYAGE_API_KEY: 'sk-fake' },
|
||||
});
|
||||
}
|
||||
|
||||
test('embedQuery sends input_type=query', async () => {
|
||||
configureVoyage();
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return voyageShapedResponse(1024, 1);
|
||||
};
|
||||
|
||||
await embedQuery('what does foo bar do?');
|
||||
expect(capturedBody.input_type).toBe('query');
|
||||
// Existing voyage translation still applies on the same body.
|
||||
expect(capturedBody.output_dimension).toBe(1024);
|
||||
expect(capturedBody.encoding_format).toBe('base64');
|
||||
});
|
||||
|
||||
test('embed (index path) keeps input_type OFF the wire (pre-v0.35.0.0 opt-in shape)', async () => {
|
||||
// dims.ts deliberately emits no input_type for Voyage unless threaded
|
||||
// (`...(inputType ? { input_type: inputType } : {})`); the shim must
|
||||
// not invent a default for it.
|
||||
configureVoyage();
|
||||
let capturedBody: any = null;
|
||||
fetchHandler = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body as string);
|
||||
return voyageShapedResponse(1024, 1);
|
||||
};
|
||||
|
||||
await embed(['this is a document being indexed']);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect('input_type' in capturedBody).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,27 @@ describe('buildFactsAlterRecipe', () => {
|
||||
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
|
||||
expect(recipe).toMatch(/DROP INDEX[\s\S]*ALTER TABLE[\s\S]*CREATE INDEX/);
|
||||
});
|
||||
|
||||
test('dimension change NULLs embeddings BEFORE the alter (pgvector refuses cross-dim casts)', () => {
|
||||
// Same defect class fixed for content_chunks in embeddingMismatchMessage:
|
||||
// pgvector aborts a cross-dimension ALTER while rows still hold old-width
|
||||
// vectors. The dims-change recipe must wipe first; order pinned.
|
||||
const recipe = buildFactsAlterRecipe(1536, 1280, 'halfvec');
|
||||
const nullIdx = recipe.indexOf('UPDATE facts SET embedding = NULL');
|
||||
const alterIdx = recipe.indexOf('ALTER TABLE facts ALTER COLUMN embedding TYPE');
|
||||
expect(nullIdx).toBeGreaterThan(-1);
|
||||
expect(alterIdx).toBeGreaterThan(-1);
|
||||
expect(nullIdx).toBeLessThan(alterIdx);
|
||||
});
|
||||
|
||||
test('same-dim type swap PRESERVES embeddings (no NULL wipe)', () => {
|
||||
// halfvec(1536) <-> vector(1536): the USING cast is lossless and the
|
||||
// whole point is keeping the data. A wipe here would destroy valid
|
||||
// embeddings for no reason.
|
||||
const recipe = buildFactsAlterRecipe(1536, 1536, 'vector');
|
||||
expect(recipe).not.toContain('UPDATE facts SET embedding = NULL');
|
||||
expect(recipe).toContain('USING embedding::vector(1536)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FactsEmbeddingDimMismatchError', () => {
|
||||
|
||||
@@ -103,6 +103,26 @@ describe('embeddingMismatchMessage', () => {
|
||||
expect(msg).toContain('docs/embedding-migrations.md');
|
||||
});
|
||||
|
||||
test('Postgres recipe NULLs embeddings BEFORE the column alter (pgvector refuses cross-dim casts)', () => {
|
||||
// pgvector aborts `ALTER COLUMN TYPE vector(N)` with "expected N
|
||||
// dimensions, not M" while rows still hold old-width vectors — which is
|
||||
// every brain running this recipe. The UPDATE must precede the ALTER
|
||||
// (NULLs cast fine). Order pinned so the printed recipe can't drift from
|
||||
// the corrected docs/embedding-migrations.md again.
|
||||
const msg = embeddingMismatchMessage({
|
||||
currentDims: 1536,
|
||||
requestedDims: 768,
|
||||
requestedModel: 'nomic-embed-text',
|
||||
source: 'init',
|
||||
engineKind: 'postgres',
|
||||
});
|
||||
const nullIdx = msg.indexOf('UPDATE content_chunks SET embedding = NULL');
|
||||
const alterIdx = msg.indexOf('ALTER TABLE content_chunks ALTER COLUMN embedding TYPE vector(768)');
|
||||
expect(nullIdx).toBeGreaterThan(-1);
|
||||
expect(alterIdx).toBeGreaterThan(-1);
|
||||
expect(nullIdx).toBeLessThan(alterIdx);
|
||||
});
|
||||
|
||||
test('Postgres branch skips HNSW recreate when requested dims exceed pgvector cap', () => {
|
||||
// Codex finding #8: 2048d (Voyage 4 Large) cannot be HNSW-indexed in pgvector.
|
||||
// The recipe must NOT instruct a CREATE INDEX HNSW for that dim.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* #1928 regression — extract_facts must NOT wipe conversation facts.
|
||||
*
|
||||
* `extract-conversation-facts` writes facts with source='cli:...' and
|
||||
* source_markdown_slug=<transcript slug>. Those pages carry no `## Facts`
|
||||
* fence, so the cycle's wipe-and-reinsert reconcile (deleteFactsForPage +
|
||||
* insertFactsBatch) used to delete them and reinsert nothing — a brain-wide
|
||||
* conversation-facts wipe on a failed-sync full walk (status `ok`, 0
|
||||
* inserted, 1829 rows gone in the original report).
|
||||
*
|
||||
* The fix scopes the cycle delete with excludeSourcePrefixes: ['cli:'].
|
||||
* These tests pin: (a) the exclusion protects cli:-origin rows while still
|
||||
* deleting fence-owned rows on the same page coordinate, and (b) the default
|
||||
* (no opts) behavior is unchanged — every fact on the coordinate is deleted.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
async function seedPageWithMixedFacts(slug: string, sourceId: string) {
|
||||
await engine.insertFacts(
|
||||
[
|
||||
// Fence-owned row (what extract_facts recreates from the page fence).
|
||||
{ fact: 'fence fact', kind: 'fact', source: 'fence', row_num: 1, source_markdown_slug: slug },
|
||||
// Empty-source row — also fence-default; must stay deletable.
|
||||
{ fact: 'blank-source fact', kind: 'fact', source: '', row_num: 2, source_markdown_slug: slug },
|
||||
// Conversation fact — NOT fence-owned; must survive the reconcile.
|
||||
{ fact: 'conversation fact', kind: 'fact', source: 'cli:extract-conversation-facts', row_num: 3, source_markdown_slug: slug },
|
||||
],
|
||||
{ source_id: sourceId },
|
||||
);
|
||||
}
|
||||
|
||||
async function factSourcesOnPage(slug: string, sourceId: string): Promise<string[]> {
|
||||
const rows = await engine.executeRaw<{ source: string }>(
|
||||
`SELECT COALESCE(source, '') AS source FROM facts
|
||||
WHERE source_id = $1 AND source_markdown_slug = $2 ORDER BY row_num`,
|
||||
[sourceId, slug],
|
||||
);
|
||||
return rows.map(r => r.source);
|
||||
}
|
||||
|
||||
describe('#1928 deleteFactsForPage excludeSourcePrefixes', () => {
|
||||
test('protects cli:-origin facts, still deletes fence + empty-source rows', async () => {
|
||||
await seedPageWithMixedFacts('transcripts/2026-06-01', 'default');
|
||||
expect((await factSourcesOnPage('transcripts/2026-06-01', 'default')).length).toBe(3);
|
||||
|
||||
const { deleted } = await engine.deleteFactsForPage('transcripts/2026-06-01', 'default', {
|
||||
excludeSourcePrefixes: ['cli:'],
|
||||
});
|
||||
|
||||
expect(deleted).toBe(2); // fence + blank-source removed
|
||||
const survivors = await factSourcesOnPage('transcripts/2026-06-01', 'default');
|
||||
expect(survivors).toEqual(['cli:extract-conversation-facts']);
|
||||
});
|
||||
|
||||
test('default behavior (no opts) deletes every fact on the coordinate', async () => {
|
||||
await seedPageWithMixedFacts('transcripts/2026-06-02', 'default');
|
||||
expect((await factSourcesOnPage('transcripts/2026-06-02', 'default')).length).toBe(3);
|
||||
|
||||
const { deleted } = await engine.deleteFactsForPage('transcripts/2026-06-02', 'default');
|
||||
|
||||
expect(deleted).toBe(3);
|
||||
expect((await factSourcesOnPage('transcripts/2026-06-02', 'default')).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Regression — importCodeFile stamps source_id on extracted call-graph edges.
|
||||
*
|
||||
* Pre-fix bug: importCodeFile built CodeEdgeInput rows WITHOUT source_id, so
|
||||
* every extracted edge landed NULL in code_edges_symbol. getCallersOf /
|
||||
* getCalleesOf add `AND source_id = <scoped>` whenever a worktree pin or
|
||||
* --source is in play — NULL never matches that filter, so scoped call-graph
|
||||
* queries silently returned 0 rows on multi-source brains even though the
|
||||
* edges existed. Pre-existing coverage (cathedral-ii-brainbench.test.ts,
|
||||
* code-edges.test.ts) only ever queried with { allSources: true }, which
|
||||
* bypasses the filter — exactly why the NULL never surfaced.
|
||||
*
|
||||
* This test imports a caller/callee pair under a non-default source and
|
||||
* asserts (a) the persisted code_edges_symbol rows carry the source_id, and
|
||||
* (b) the SCOPED getCallersOf/getCalleesOf — the user-visible path that
|
||||
* returned 0 — now find the edge, while a different source scope does not.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { importCodeFile } from '../src/core/import-file.ts';
|
||||
import { runSources } from '../src/commands/sources.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
await runSources(engine, ['add', 'testsrc', '--no-federated']);
|
||||
|
||||
// Same fixture shape as cathedral-ii-brainbench: runner() calls helper(),
|
||||
// Layer 5 edge extraction captures the unresolved 'calls' edge — but here
|
||||
// the import is pinned to a non-default source.
|
||||
await importCodeFile(
|
||||
engine,
|
||||
'src/a.ts',
|
||||
'export function runner() { return helper(); }\n',
|
||||
{ noEmbed: true, sourceId: 'testsrc' },
|
||||
);
|
||||
await importCodeFile(
|
||||
engine,
|
||||
'src/b.ts',
|
||||
'export function helper() { return 42; }\n',
|
||||
{ noEmbed: true, sourceId: 'testsrc' },
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
describe('importCodeFile — source_id stamped on extracted call-graph edges', () => {
|
||||
test('edges land with the import source_id and scoped caller/callee queries match', async () => {
|
||||
// (a) The persisted unresolved edge rows carry the source, not NULL.
|
||||
const rows = await engine.executeRaw<{ source_id: string | null }>(
|
||||
`SELECT source_id FROM code_edges_symbol WHERE from_symbol_qualified = $1`,
|
||||
['runner'],
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const r of rows) {
|
||||
expect(r.source_id).toBe('testsrc');
|
||||
}
|
||||
|
||||
// (b) The scoped queries — the path that silently returned 0 pre-fix.
|
||||
const callers = await engine.getCallersOf('helper', { sourceId: 'testsrc' });
|
||||
const fromRunner = callers.find(r => r.from_symbol_qualified === 'runner');
|
||||
expect(fromRunner).toBeDefined();
|
||||
expect(fromRunner!.edge_type).toBe('calls');
|
||||
expect(fromRunner!.source_id).toBe('testsrc');
|
||||
|
||||
const callees = await engine.getCalleesOf('runner', { sourceId: 'testsrc' });
|
||||
expect(callees.some(r => r.to_symbol_qualified === 'helper')).toBe(true);
|
||||
|
||||
// Source isolation still holds: a different scope must NOT see the edge.
|
||||
const otherScope = await engine.getCallersOf('helper', { sourceId: 'default' });
|
||||
expect(otherScope.find(r => r.from_symbol_qualified === 'runner')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('UNSCOPED import stamps edges with the schema-default source, not NULL', async () => {
|
||||
// The other door of the same bug: an import WITHOUT opts.sourceId (legacy
|
||||
// unscoped callers — `gbrain reindex --code` with no --source) lands its
|
||||
// pages under the schema default (pages.source_id DEFAULT 'default').
|
||||
// If its edges were stamped NULL, the matching scoped query
|
||||
// getCallersOf(sym, { sourceId: 'default' }) — a worktree pinned to
|
||||
// default, --source default, GBRAIN_SOURCE=default — would miss them.
|
||||
await importCodeFile(
|
||||
engine,
|
||||
'src/c.ts',
|
||||
'export function unscopedRunner() { return unscopedHelper(); }\n',
|
||||
{ noEmbed: true },
|
||||
);
|
||||
await importCodeFile(
|
||||
engine,
|
||||
'src/d.ts',
|
||||
'export function unscopedHelper() { return 7; }\n',
|
||||
{ noEmbed: true },
|
||||
);
|
||||
|
||||
const rows = await engine.executeRaw<{ source_id: string | null }>(
|
||||
`SELECT source_id FROM code_edges_symbol WHERE from_symbol_qualified = $1`,
|
||||
['unscopedRunner'],
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const r of rows) {
|
||||
expect(r.source_id).toBe('default');
|
||||
}
|
||||
|
||||
const callers = await engine.getCallersOf('unscopedHelper', { sourceId: 'default' });
|
||||
expect(callers.some(r => r.from_symbol_qualified === 'unscopedRunner')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -135,4 +135,51 @@ describe('engine.updateSourceConfig', () => {
|
||||
const all = await engine.listAllSources();
|
||||
expect(all.find(s => s.id === 'delta')!.config.last_full_cycle_at).toBe('2026-05-22T11:00:00.000Z');
|
||||
});
|
||||
|
||||
// IS JSON guard: the postgres-engine atomic merge gates its `::jsonb` cast
|
||||
// behind the SQL `IS JSON` predicate so a historical bad row whose config is
|
||||
// a JSONB string of NON-JSON text normalizes to `{}` instead of raising
|
||||
// `invalid input syntax for type json` and aborting the UPDATE.
|
||||
//
|
||||
// We exercise the SQL CASE expression directly via executeRaw against PGLite
|
||||
// (which ships Postgres 17 + IS JSON parity) rather than calling
|
||||
// PostgresEngine.updateSourceConfig (which requires a live Postgres pool).
|
||||
test('IS-JSON guard: non-JSON string config normalizes to {} on merge', async () => {
|
||||
const patch = { merged_key: 'v' };
|
||||
|
||||
const guarded = (configExpr: string) => `
|
||||
SELECT (
|
||||
CASE
|
||||
WHEN jsonb_typeof(${configExpr}) = 'object' THEN ${configExpr}
|
||||
WHEN jsonb_typeof(${configExpr}) = 'string'
|
||||
THEN CASE
|
||||
WHEN (${configExpr} #>> '{}') IS JSON
|
||||
THEN COALESCE(NULLIF((${configExpr} #>> '{}'), '')::jsonb, '{}'::jsonb)
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
WHEN jsonb_typeof(${configExpr}) = 'array'
|
||||
THEN COALESCE(
|
||||
(SELECT jsonb_object_agg(kv.key, kv.value)
|
||||
FROM jsonb_array_elements(${configExpr}) elem,
|
||||
jsonb_each(elem) kv),
|
||||
'{}'::jsonb
|
||||
)
|
||||
ELSE '{}'::jsonb
|
||||
END || $1::jsonb
|
||||
) AS result`;
|
||||
|
||||
// 1. JSONB string holding NON-JSON text → normalizes to {} then merges patch.
|
||||
const bad = await engine.executeRaw<{ result: Record<string, unknown> }>(
|
||||
guarded(`to_jsonb('garbage text'::text)`),
|
||||
[JSON.stringify(patch)],
|
||||
);
|
||||
expect(bad[0].result).toEqual({ merged_key: 'v' });
|
||||
|
||||
// 2. JSONB string holding double-encoded valid JSON object → parsed + merged.
|
||||
const good = await engine.executeRaw<{ result: Record<string, unknown> }>(
|
||||
guarded(`to_jsonb('{"x":1}'::text)`),
|
||||
[JSON.stringify(patch)],
|
||||
);
|
||||
expect(good[0].result).toEqual({ x: 1, merged_key: 'v' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Authorize-grant scope default (RFC 6749 §3.3).
|
||||
*
|
||||
* When a client omits `scope` on /authorize, the granted scope must default to
|
||||
* the client's full registered scope — NOT the empty set. Regression guard for
|
||||
* the bug where an omitted request granted [], which then propagated into the
|
||||
* access + refresh tokens and never self-healed: every op failed
|
||||
* `insufficient_scope` even though the client was registered `read write`
|
||||
* (some MCP connectors omit `scope` on /authorize). The clamp must still hold —
|
||||
* an explicit over-broad request cannot escalate past the client's allowed set.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts';
|
||||
import { sqlQueryForEngine } from '../src/core/sql-query.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let provider: GBrainOAuthProvider;
|
||||
let sql: ReturnType<typeof sqlQueryForEngine>;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
sql = sqlQueryForEngine(engine);
|
||||
provider = new GBrainOAuthProvider({ sql });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await (engine as any).db.exec('DELETE FROM oauth_tokens');
|
||||
await (engine as any).db.exec('DELETE FROM oauth_codes');
|
||||
await (engine as any).db.exec('DELETE FROM oauth_clients');
|
||||
});
|
||||
|
||||
// authorize() writes the granted scope into oauth_codes then redirects; we
|
||||
// assert on the stored grant directly, so the redirect is a no-op.
|
||||
const noopRes = { redirect() {} } as any;
|
||||
|
||||
async function authorizeAndReadScopes(
|
||||
scope: string,
|
||||
requested: string[] | undefined,
|
||||
): Promise<string[]> {
|
||||
const reg = await provider.registerClientManual(
|
||||
'authz-test', ['authorization_code'], scope, ['https://example.test/cb'],
|
||||
);
|
||||
const client = await provider.clientsStore.getClient(reg.clientId);
|
||||
expect(client).toBeTruthy();
|
||||
await provider.authorize(
|
||||
client!,
|
||||
{
|
||||
scopes: requested,
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: 'https://example.test/cb',
|
||||
state: 'xyz',
|
||||
} as any,
|
||||
noopRes,
|
||||
);
|
||||
const rows = (await sql`
|
||||
SELECT scopes FROM oauth_codes WHERE client_id = ${reg.clientId}
|
||||
`) as Array<{ scopes: string[] }>;
|
||||
expect(rows.length).toBe(1);
|
||||
return rows[0].scopes ?? [];
|
||||
}
|
||||
|
||||
describe('authorize() scope default — omitted scope inherits client grant', () => {
|
||||
test('omitted scope → inherits full registered scope', async () => {
|
||||
expect((await authorizeAndReadScopes('read write', undefined)).sort()).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
test('empty scope array → inherits full registered scope', async () => {
|
||||
expect((await authorizeAndReadScopes('read write', [])).sort()).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
test('explicit subset is honored (not overridden to full)', async () => {
|
||||
expect(await authorizeAndReadScopes('read write admin', ['read'])).toEqual(['read']);
|
||||
});
|
||||
|
||||
test('clamp preserved: over-broad request cannot escalate', async () => {
|
||||
expect(await authorizeAndReadScopes('read', ['read', 'admin'])).toEqual(['read']);
|
||||
});
|
||||
|
||||
test('clamp preserved: requesting only a disallowed scope grants nothing (no inheritance)', async () => {
|
||||
expect(await authorizeAndReadScopes('read write', ['admin'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { hashToken, generateToken } from '../src/core/utils.ts';
|
||||
import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts';
|
||||
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import type { AuthInfo as CoreAuthInfo } from '../src/core/operations.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup: in-memory PGLite with OAuth tables
|
||||
@@ -310,6 +311,33 @@ describe('verifyAccessToken', () => {
|
||||
expect(authInfo.clientId).toBe('legacy-agent');
|
||||
expect(authInfo.scopes).toEqual(['read', 'write', 'admin']); // grandfathered full access
|
||||
});
|
||||
|
||||
test('legacy access_tokens fallback honors permissions.source_id array grants', async () => {
|
||||
// oauth.test.ts initializes the static PGLite schema blob, not the full
|
||||
// migration stack. Add the v38 permissions column here so the row matches
|
||||
// a modern brain carrying a legacy-token source grant.
|
||||
await sql`
|
||||
ALTER TABLE access_tokens
|
||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
|
||||
`;
|
||||
|
||||
const legacyToken = generateToken('gbrain_');
|
||||
const hash = hashToken(legacyToken);
|
||||
await sql`
|
||||
INSERT INTO access_tokens (id, name, token_hash, permissions)
|
||||
VALUES (
|
||||
${crypto.randomUUID()},
|
||||
${'legacy-federated-agent'},
|
||||
${hash},
|
||||
${JSON.stringify({ source_id: ['default', 'src-a', 'src-b'] })}::jsonb
|
||||
)
|
||||
`;
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
|
||||
expect(authInfo.clientId).toBe('legacy-federated-agent');
|
||||
expect(authInfo.sourceId).toBe('default');
|
||||
expect(authInfo.allowedSources).toEqual(['default', 'src-a', 'src-b']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { acquireLock, releaseLock, type LockHandle } from '../src/core/pglite-lock';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
const TEST_DIR = join(tmpdir(), 'gbrain-lock-test-' + process.pid);
|
||||
|
||||
@@ -99,3 +100,96 @@ describe('pglite-lock', () => {
|
||||
await releaseLock(lock2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pglite-lock #2058 heartbeat + steal-grace', () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeHolder(fields: { pid: number; acquiredAgoMs: number; refreshedAgoMs: number }) {
|
||||
const lockDir = join(TEST_DIR, '.gbrain-lock');
|
||||
mkdirSync(lockDir, { recursive: true });
|
||||
const now = Date.now();
|
||||
writeFileSync(join(lockDir, 'lock'), JSON.stringify({
|
||||
pid: fields.pid,
|
||||
acquired_at: now - fields.acquiredAgoMs,
|
||||
refreshed_at: now - fields.refreshedAgoMs,
|
||||
command: 'test holder',
|
||||
}));
|
||||
}
|
||||
|
||||
test('[REGRESSION] a LIVE holder with a fresh heartbeat is NOT stolen even when the lock is old', async () => {
|
||||
// The WAL-corruption bug: a >5min embed used to get its lock force-removed.
|
||||
// Now an alive holder that heartbeated recently is left alone regardless of
|
||||
// age. acquired 20min ago, but refreshed just now → must wait, not steal.
|
||||
writeHolder({ pid: process.pid, acquiredAgoMs: 20 * 60_000, refreshedAgoMs: 0 });
|
||||
|
||||
await expect(acquireLock(TEST_DIR, { timeoutMs: 1200 })).rejects.toThrow(/Timed out/);
|
||||
// Holder's lock still present (was never stolen).
|
||||
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a LIVE PID whose heartbeat went stale past the grace window IS reaped', async () => {
|
||||
// PID is alive (our own) but hasn't refreshed in 20min (> 600s grace):
|
||||
// hung holder, or a reused PID whose real holder is gone. Reap + acquire.
|
||||
writeHolder({ pid: process.pid, acquiredAgoMs: 25 * 60_000, refreshedAgoMs: 20 * 60_000 });
|
||||
|
||||
const lock = await acquireLock(TEST_DIR, { timeoutMs: 2000 });
|
||||
expect(lock.acquired).toBe(true);
|
||||
await releaseLock(lock);
|
||||
});
|
||||
|
||||
test('GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS tunes the grace window', async () => {
|
||||
// withEnv keeps the process-global mutation isolated across shard files.
|
||||
await withEnv({ GBRAIN_PGLITE_LOCK_STEAL_GRACE_SECONDS: '5' }, async () => {
|
||||
// Refreshed 30s ago — fresh under the 600s default, STALE under 5s.
|
||||
writeHolder({ pid: process.pid, acquiredAgoMs: 60_000, refreshedAgoMs: 30_000 });
|
||||
const lock = await acquireLock(TEST_DIR, { timeoutMs: 2000 });
|
||||
expect(lock.acquired).toBe(true);
|
||||
await releaseLock(lock);
|
||||
});
|
||||
});
|
||||
|
||||
test('[REGRESSION] releaseLock does NOT remove a lock that was stolen + re-acquired by another process', async () => {
|
||||
// We acquire, then simulate a steal: another process reaped us past grace
|
||||
// and now owns the lock (different pid + acquired_at). Our releaseLock must
|
||||
// NOT delete their live lock — doing so would let a third process in
|
||||
// alongside the new owner (the #2058 corruption class).
|
||||
const lock: LockHandle = await acquireLock(TEST_DIR);
|
||||
expect(lock.acquired).toBe(true);
|
||||
expect(lock.ownerToken).toBeDefined();
|
||||
if (lock.heartbeat) clearInterval(lock.heartbeat); // stop our heartbeat for a deterministic test
|
||||
|
||||
// Overwrite the lock file as if process B re-acquired it.
|
||||
const lockFile = join(TEST_DIR, '.gbrain-lock', 'lock');
|
||||
const bNow = Date.now() + 1;
|
||||
writeFileSync(lockFile, JSON.stringify({ pid: 999999, acquired_at: bNow, refreshed_at: bNow, command: 'process B' }));
|
||||
|
||||
await releaseLock(lock); // our (stale) handle
|
||||
|
||||
// B's lock survives — we did not clobber it.
|
||||
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
|
||||
const after = JSON.parse(readFileSync(lockFile, 'utf-8'));
|
||||
expect(after.pid).toBe(999999);
|
||||
|
||||
// Cleanup for afterEach.
|
||||
rmSync(join(TEST_DIR, '.gbrain-lock'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('acquire starts a heartbeat and seeds refreshed_at; release clears it', async () => {
|
||||
const lock: LockHandle = await acquireLock(TEST_DIR);
|
||||
expect(lock.acquired).toBe(true);
|
||||
expect(lock.heartbeat).toBeDefined();
|
||||
const data = JSON.parse(readFileSync(join(TEST_DIR, '.gbrain-lock', 'lock'), 'utf-8'));
|
||||
expect(data.refreshed_at).toBeDefined();
|
||||
expect(typeof data.refreshed_at).toBe('number');
|
||||
|
||||
await releaseLock(lock);
|
||||
expect(lock.heartbeat).toBeUndefined();
|
||||
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* #2034 — engine-parity reconnect().
|
||||
*
|
||||
* The autopilot health-probe recovery path used `disconnect()` + bare
|
||||
* `connect()`, which (a) threw `database_url undefined` forever on Postgres
|
||||
* because the config was lost, and (b) was a silent no-op on PGLite which had
|
||||
* no reconnect() at all. Both engines now expose `reconnect()`; this pins the
|
||||
* PGLite side: it restores connectivity against the config captured at the
|
||||
* last connect(), and is a safe no-op when never connected.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
describe('#2034 PGLiteEngine.reconnect', () => {
|
||||
test('restores connectivity and persisted state against the saved config', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-reconnect-'));
|
||||
const engine = new PGLiteEngine();
|
||||
try {
|
||||
await engine.connect({ database_path: dir });
|
||||
await engine.initSchema();
|
||||
await engine.setConfig('reconnect.probe', 'pre');
|
||||
|
||||
// Reconnect with no args — the #2034 contract: it must reuse the config
|
||||
// captured at connect(), not require it to be re-passed.
|
||||
await engine.reconnect();
|
||||
|
||||
// Still queryable, and persisted state survived (same data dir).
|
||||
const rows = await engine.executeRaw<{ x: number }>('SELECT 1 AS x');
|
||||
expect(rows[0]?.x).toBe(1);
|
||||
expect(await engine.getConfig('reconnect.probe')).toBe('pre');
|
||||
} finally {
|
||||
await engine.disconnect();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('reconnect() before any connect() is a safe no-op', async () => {
|
||||
const engine = new PGLiteEngine();
|
||||
await expect(engine.reconnect()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,7 @@ describe('alias_resolved boost stage', () => {
|
||||
});
|
||||
|
||||
describe('KNOBS_HASH_VERSION', () => {
|
||||
it('is 10 (9→10 relational recall arm invalidates rel-off cache rows, v0.43)', () => {
|
||||
expect(KNOBS_HASH_VERSION).toBe(10);
|
||||
it('is 11 (10→11 asymmetric input_type fix invalidates document-side query-vector rows, #1400)', () => {
|
||||
expect(KNOBS_HASH_VERSION).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,7 +403,11 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
|
||||
// must not be served from a cache row written before the policy change.
|
||||
// v0.43: bumped 9→10 for the relational recall arm (rel=/reld=) — a
|
||||
// relational-on write must not be served to a relational-off lookup.
|
||||
expect(KNOBS_HASH_VERSION).toBe(10);
|
||||
// #1400: bumped 10→11 for the asymmetric input_type fix — embedQuery()
|
||||
// now produces query-side vectors for asymmetric providers (zembed-1,
|
||||
// Voyage v3+), so rows keyed on pre-fix document-side query vectors
|
||||
// must not be served to post-fix lookups.
|
||||
expect(KNOBS_HASH_VERSION).toBe(11);
|
||||
});
|
||||
|
||||
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
|
||||
@@ -568,8 +572,8 @@ describe('v0.40.4 — graph_signals knob', () => {
|
||||
});
|
||||
|
||||
describe('v0.42.3.0 — autocut knobs', () => {
|
||||
test('KNOBS_HASH_VERSION is 10 (9→10 relational recall arm, v0.43)', () => {
|
||||
expect(KNOBS_HASH_VERSION).toBe(10);
|
||||
test('KNOBS_HASH_VERSION is 11 (10→11 asymmetric input_type fix, #1400)', () => {
|
||||
expect(KNOBS_HASH_VERSION).toBe(11);
|
||||
});
|
||||
|
||||
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ function baseKnobs(): ResolvedSearchKnobs {
|
||||
}
|
||||
|
||||
describe('KNOBS_HASH_VERSION + version invariants', () => {
|
||||
test('version is 10 (…; 7→8 autocut; 8→9 archive-demote #1777; 9→10 relational recall)', () => {
|
||||
test('version is 11 (…; 8→9 archive-demote #1777; 9→10 relational recall; 10→11 asymmetric input_type #1400)', () => {
|
||||
// v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold
|
||||
// floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs
|
||||
// (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation).
|
||||
@@ -58,7 +58,10 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
|
||||
// autocut. issue #1777: 8→9 archive/ demote (search-exclude policy change
|
||||
// isn't in the hash, so the bump invalidates archive-excluded cache rows).
|
||||
// v0.43: 9→10 relational recall arm (rel=/reld=).
|
||||
expect(KNOBS_HASH_VERSION).toBe(10);
|
||||
// #1400: 10→11 asymmetric input_type fix — embedQuery() now produces
|
||||
// query-side vectors for asymmetric providers, so rows keyed on
|
||||
// pre-fix document-side query vectors must not be served.
|
||||
expect(KNOBS_HASH_VERSION).toBe(11);
|
||||
});
|
||||
|
||||
test('hash is 16 hex chars regardless of reranker config', () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* #2057 regression — addTimelineEntriesBatch must accept JS Date `date` values.
|
||||
*
|
||||
* The original bug: callers source `date` from SQL rows (e.g.
|
||||
* `meeting.effective_date`), which arrive as JS Date objects. The OLD insert
|
||||
* bound them into `${dates}::text[]`, which threw `cannot cast type timestamp
|
||||
* with time zone to text[]`, and a bare `catch {}` in the meetings extractor
|
||||
* swallowed it — timeline stayed empty forever.
|
||||
*
|
||||
* The #1861 refactor moved the insert to `jsonb_to_recordset` + `v.date::date`,
|
||||
* where a Date serializes to an ISO string that casts cleanly. This test pins
|
||||
* that a Date-bearing batch round-trips, so a future refactor can't silently
|
||||
* reintroduce the cast failure. (The companion fix un-silences the extractor's
|
||||
* catch so any future failure is visible rather than a phantom "0 entries".)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { importFromContent } from '../src/core/import-file.ts';
|
||||
import type { TimelineBatchInput } from '../src/core/engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
await importFromContent(engine, 'people/alice-example', `---\ntitle: Alice\ntype: note\n---\n\n# Alice\n`, {
|
||||
noEmbed: true,
|
||||
sourceId: 'default',
|
||||
sourcePath: 'people/alice-example.md',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('#2057 addTimelineEntriesBatch with Date date values', () => {
|
||||
test('a Date `date` is inserted and round-trips as the right calendar day', async () => {
|
||||
// Mimic the real caller: `date` typed string on the interface, but a JS
|
||||
// Date at runtime (straight off a TIMESTAMPTZ column). The cast must hold.
|
||||
const effectiveDate = new Date('2026-04-03T00:00:00.000Z');
|
||||
const batch: TimelineBatchInput[] = [
|
||||
{
|
||||
slug: 'people/alice-example',
|
||||
date: effectiveDate as unknown as string,
|
||||
source: 'cli:test',
|
||||
summary: 'met alice',
|
||||
source_id: 'default',
|
||||
},
|
||||
];
|
||||
|
||||
const inserted = await engine.addTimelineEntriesBatch(batch);
|
||||
expect(inserted).toBe(1);
|
||||
|
||||
const rows = await engine.executeRaw<{ date: string }>(
|
||||
`SELECT date::text AS date FROM timeline_entries WHERE summary = 'met alice'`,
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].date).toBe('2026-04-03');
|
||||
});
|
||||
|
||||
test('a plain ISO string `date` still works (no regression)', async () => {
|
||||
const inserted = await engine.addTimelineEntriesBatch([
|
||||
{
|
||||
slug: 'people/alice-example',
|
||||
date: '2026-05-01',
|
||||
source: 'cli:test',
|
||||
summary: 'string-dated entry',
|
||||
source_id: 'default',
|
||||
},
|
||||
]);
|
||||
expect(inserted).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* #2038 — idx_timeline_dedup schema-drift self-heal.
|
||||
*
|
||||
* A brain that ran the pre-renumber v99 variant of the dedup migration is
|
||||
* stamped past v102 with the OLD 3-column index. `runMigrations` early-returns
|
||||
* (nothing pending) so a migration verify-hook can't fix it. The repair is
|
||||
* keyed off the index SHAPE and runs regardless. These tests simulate the
|
||||
* drifted states directly and pin: detection, rebuild, dedupe-before-rebuild
|
||||
* (only possible when the index was absent), and idempotency.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import {
|
||||
checkTimelineDedupIndex,
|
||||
repairTimelineDedupIndex,
|
||||
} from '../src/core/timeline-dedup-repair.ts';
|
||||
import { importFromContent } from '../src/core/import-file.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let pageId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
await importFromContent(engine, 'people/alice-example', `---\ntitle: Alice\ntype: note\n---\n\n# Alice\n`, {
|
||||
noEmbed: true,
|
||||
sourceId: 'default',
|
||||
sourcePath: 'people/alice-example.md',
|
||||
});
|
||||
const pid = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id::text AS id FROM pages WHERE slug = 'people/alice-example' AND source_id = 'default'`,
|
||||
);
|
||||
pageId = pid[0].id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
/** Force the index back to the broken pre-v102 3-column shape. */
|
||||
async function regressTo3Col() {
|
||||
await engine.executeRaw(`DELETE FROM timeline_entries`);
|
||||
await engine.executeRaw(`DROP INDEX IF EXISTS idx_timeline_dedup`);
|
||||
await engine.executeRaw(
|
||||
`CREATE UNIQUE INDEX idx_timeline_dedup ON timeline_entries(page_id, date, summary)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The other drift shape: the index was dropped entirely, letting true
|
||||
* 4-tuple duplicates accumulate that would block a naive CREATE UNIQUE INDEX. */
|
||||
async function regressToAbsentWithDupes() {
|
||||
await engine.executeRaw(`DELETE FROM timeline_entries`);
|
||||
await engine.executeRaw(`DROP INDEX IF EXISTS idx_timeline_dedup`);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO timeline_entries (page_id, date, summary, source, detail)
|
||||
VALUES ($1, '2026-04-03', 'met alice', 'meeting', ''),
|
||||
($1, '2026-04-03', 'met alice', 'meeting', ''),
|
||||
($1, '2026-04-03', 'met alice', 'cli:extract', '')`,
|
||||
[pageId],
|
||||
);
|
||||
}
|
||||
|
||||
describe('#2038 idx_timeline_dedup drift repair', () => {
|
||||
test('detects the 3-column drift', async () => {
|
||||
await regressTo3Col();
|
||||
const status = await checkTimelineDedupIndex(engine);
|
||||
expect(status.tablePresent).toBe(true);
|
||||
expect(status.indexPresent).toBe(true);
|
||||
expect(status.columns).toEqual(['page_id', 'date', 'summary']);
|
||||
expect(status.needsRepair).toBe(true);
|
||||
});
|
||||
|
||||
test('rebuilds the 3-column index to 4 columns (no dupes to collapse)', async () => {
|
||||
await regressTo3Col();
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO timeline_entries (page_id, date, summary, source, detail)
|
||||
VALUES ($1, '2026-04-03', 'met alice', 'meeting', '')`,
|
||||
[pageId],
|
||||
);
|
||||
|
||||
const res = await repairTimelineDedupIndex(engine);
|
||||
expect(res.repaired).toBe(true);
|
||||
expect(res.reason).toBe('rebuilt');
|
||||
expect(res.collapsedDuplicates).toBe(0);
|
||||
|
||||
const after = await checkTimelineDedupIndex(engine);
|
||||
expect(after.columns).toEqual(['page_id', 'date', 'summary', 'source']);
|
||||
expect(after.needsRepair).toBe(false);
|
||||
});
|
||||
|
||||
test('dedupes true 4-tuple duplicates before building the unique index', async () => {
|
||||
await regressToAbsentWithDupes(); // index absent + a real (meeting) dup
|
||||
|
||||
const before = await checkTimelineDedupIndex(engine);
|
||||
expect(before.indexPresent).toBe(false);
|
||||
expect(before.needsRepair).toBe(true);
|
||||
|
||||
const res = await repairTimelineDedupIndex(engine);
|
||||
expect(res.repaired).toBe(true);
|
||||
expect(res.collapsedDuplicates).toBe(1); // one of the two 'meeting' rows removed
|
||||
|
||||
const after = await checkTimelineDedupIndex(engine);
|
||||
expect(after.columns).toEqual(['page_id', 'date', 'summary', 'source']);
|
||||
const rows = await engine.executeRaw<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM timeline_entries`,
|
||||
);
|
||||
expect(parseInt(rows[0].n, 10)).toBe(2); // meeting (deduped) + cli:extract
|
||||
});
|
||||
|
||||
test('idempotent — a second repair is a no-op', async () => {
|
||||
await regressTo3Col();
|
||||
await repairTimelineDedupIndex(engine);
|
||||
const second = await repairTimelineDedupIndex(engine);
|
||||
expect(second.repaired).toBe(false);
|
||||
expect(second.reason).toBe('already_correct');
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,58 @@ describe('writePageThrough', () => {
|
||||
expect(res).toEqual({ written: false, skipped: 'page_not_found_after_write' });
|
||||
});
|
||||
|
||||
test('[REGRESSION #2018] default page (null local_path) in a multi-source brain → skipped, no leak into a sibling source repo', async () => {
|
||||
// A sibling federated source with its OWN working tree.
|
||||
const siblingDir = path.join(tmpRoot, 'housefax');
|
||||
fs.mkdirSync(siblingDir, { recursive: true });
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('housefax', 'Housefax', $1, '{}'::jsonb)`,
|
||||
[siblingDir],
|
||||
);
|
||||
// The leak trigger: global sync.repo_path points at the sibling's tree
|
||||
// while the default source (re-seeded by resetPgliteState) has no
|
||||
// local_path of its own.
|
||||
await engine.setConfig('sync.repo_path', siblingDir);
|
||||
|
||||
const slug = 'internal/cross-cutting-note';
|
||||
await seedPage(slug); // sourceId 'default'
|
||||
|
||||
const res = await writePageThrough(engine, slug, { sourceId: 'default' });
|
||||
|
||||
expect(res).toEqual({ written: false, skipped: 'source_repo_belongs_to_other_source' });
|
||||
// The sibling source's repo stays clean — the whole point of #2018.
|
||||
expect(walkFiles(siblingDir).some((f) => f.endsWith('.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('[#2018] page assigned to a source with its own local_path writes to that tree root, not the global path', async () => {
|
||||
const alphaDir = path.join(tmpRoot, 'alpha-repo');
|
||||
fs.mkdirSync(alphaDir, { recursive: true });
|
||||
const globalDir = path.join(tmpRoot, 'global-repo');
|
||||
fs.mkdirSync(globalDir, { recursive: true });
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('alpha', 'Alpha', $1, '{}'::jsonb)`,
|
||||
[alphaDir],
|
||||
);
|
||||
// Must NOT be used — the assigned source has its own tree.
|
||||
await engine.setConfig('sync.repo_path', globalDir);
|
||||
|
||||
const slug = 'notes/alpha-thing';
|
||||
await importFromContent(engine, slug, `---\ntitle: T\ntype: note\n---\n\n# Body\n`, {
|
||||
noEmbed: true,
|
||||
sourceId: 'alpha',
|
||||
sourcePath: `${slug}.md`,
|
||||
});
|
||||
|
||||
const res = await writePageThrough(engine, slug, { sourceId: 'alpha' });
|
||||
|
||||
expect(res.written).toBe(true);
|
||||
// File at the source's tree ROOT, never nested under `.sources/<id>/`.
|
||||
expect(res.path).toBe(path.join(alphaDir, `${slug}.md`));
|
||||
expect(fs.existsSync(path.join(alphaDir, `${slug}.md`))).toBe(true);
|
||||
// The global repo path is untouched.
|
||||
expect(walkFiles(globalDir).some((f) => f.endsWith('.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('[REGRESSION] mkdir ENOTDIR (parent is a file) → error, no partial .md, no .tmp', async () => {
|
||||
await engine.setConfig('sync.repo_path', brainDir);
|
||||
// Block the `wiki/` directory by putting a FILE named "wiki" under the repo,
|
||||
|
||||
Reference in New Issue
Block a user