From f401d7407eafc46a60e311fba0cdf328929378d2 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 7 Jun 2026 19:12:50 -0700 Subject: [PATCH] v0.42.31.0 feat(links): open link_source provenance + link-add/link-rm/link-sources (#1941) (#1957) * feat(links): relax link_source CHECK to kebab-case provenance + migration v114 Open link_source from a closed allowlist to a kebab-case format gate (^[a-z][a-z0-9]*(-[a-z0-9]+)*$, char_length<=64) so external derivers stamp their own provenance (e.g. citation-graph) without a per-deriver migration. Migration v114: Postgres NOT VALID + VALIDATE (lock-friendly, transaction:false); PGLite plain DROP+ADD. Updates the schema.sql + engine provenance contract comments. (#1941) * feat(links): expose link provenance on link ops + link-add/link-rm/link-sources add_link/remove_link now accept --link-source/--link-type; add_link guards the reconciliation-managed built-ins (markdown/frontmatter/mentions/ wikilink-resolved) and defaults omitted provenance to 'manual' (was the misleading engine default 'markdown'). New cliHints.aliases mechanism with a startup collision guard registers link-add/link-rm; printOpHelp shows the invoked alias name. New list_link_sources read op + listLinkSources engine method (both engines, {sourceId?,sourceIds?}, deterministic order) powers `gbrain link-sources`, added to the minion read allowlist. (#1941) * test(links): kebab provenance, op guard, link-sources, aliases + parity Covers the v114 regex/length boundaries, upgrade-path constraint swap on existing data, the managed-built-in op guard + manual default, remove_link type/source filters, list_link_sources scoping (scalar + federated) and PG/PGLite parity, and alias resolution/collision/help. Fixes the prior 'inferred'-rejection assertion (now valid kebab) in the mentions test. (#1941) * chore: bump version and changelog (v0.42.31.0) Co-Authored-By: Claude Opus 4.8 (1M context) * docs: update KEY_FILES for v0.42.31.0 link provenance surface KEY_FILES.md current-state updates for #1941: link_source now an open kebab-case provenance (migration v114), the add_link/remove_link guard + defaults, list_link_sources + listLinkSources, and cliHints.aliases. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(minions): supervisor queue-singleton keying + pidfile cleanup (follow-up #1849) Two correctness bugs in the v0.42.29.0 supervisor-singleton work, caught by adversarial review: - supervisorLockId mixed a config-derived DB identity into the key, but the lock row already lives inside the target database. Two supervisors on the same physical DB via different-but-equivalent URLs (pooler vs direct port, host alias, trailing params) computed different ids and BOTH acquired the "singleton" lock. Key on the queue alone; the database half of the mutex is physical. Removes the now-dead currentDbIdentity() from worker-registry. - The pidfile-cleanup process.on('exit') listener was installed AFTER the DB-lock acquire, so the LOCK_HELD early-exit stranded the pidfile this process had just created. Install the listener first. Regression test pins the listener-before-lock ordering; updates the lockId test to the queue-only invariant; KEY_FILES updated to current state. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 21 ++ VERSION | 2 +- docs/architecture/KEY_FILES.md | 12 +- package.json | 2 +- src/cli.ts | 53 ++- src/core/engine.ts | 24 +- src/core/migrate.ts | 40 +++ src/core/minions/supervisor.ts | 63 ++-- src/core/minions/tools/brain-allowlist.ts | 5 + src/core/minions/worker-registry.ts | 18 -- src/core/operations.ts | 62 +++- src/core/pglite-engine.ts | 26 ++ src/core/pglite-schema.ts | 11 +- src/core/postgres-engine.ts | 24 ++ src/core/schema-embedded.ts | 14 +- src/schema.sql | 30 +- test/brain-allowlist.serial.test.ts | 9 +- test/e2e/engine-parity.test.ts | 25 ++ test/link-source-namespaced-regex.test.ts | 306 ++++++++++++++++++ ...chema-migrate-link-source-mentions.test.ts | 8 +- test/supervisor-db-lock.test.ts | 67 +++- 21 files changed, 716 insertions(+), 106 deletions(-) create mode 100644 test/link-source-namespaced-regex.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf7747c2..f5df24d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to GBrain will be documented in this file. +## [0.42.31.0] - 2026-06-07 + +**You can now write typed graph edges with your own provenance straight from the CLI — `gbrain link-add a b --link-type relies-on --link-source citation-graph` — and an external edge-writer (a citation-graph ingester, an importer, a classifier) no longer needs a gbrain schema migration to register a new provenance.** Two ergonomics gaps for tools that compute edges out-of-band, filed by a downstream agent building a citation-graph ingester (#1941). + +Before this, `link_source` was a closed allowlist: anything outside `markdown`/`frontmatter`/`manual`/`mentions`/`wikilink-resolved` was rejected by a CHECK constraint, so a deriver had to either patch gbrain's schema or stamp its machine-derived edges `manual` — which made them indistinguishable from hand-entered ones. `link_source` is now an open, format-validated provenance: any lowercase kebab-case tag up to 64 chars (`citation-graph`, `relies-on-graph`, your-tag) is valid, no migration needed. The format gate still rejects garbage (uppercase, spaces, underscores, leading/trailing/double dashes). + +The CLI gap is closed too. `gbrain link` / `gbrain unlink` now take `--link-source` and `--link-type`, with `link-add` / `link-rm` aliases for discoverability. A new `gbrain link-sources` lists the distinct provenances a brain carries (with counts) — the read-side replacement for the discoverability the old allowlist gave you for free. CLI-created edges now record `manual` provenance by default instead of masquerading as parsed-from-`markdown`, and the reconciliation-managed provenances stay reserved for the writers that own their semantics. + +### Added +- **`gbrain link-add` / `link-rm` / `link-sources`** plus `--link-source` and `--link-type` on the link ops. Write typed, provenance-tagged, source-scoped edges from the CLI and list which provenances a brain holds. Provenance is any kebab-case tag; removals can filter by provenance so machine-derived edges delete cleanly without touching hand-entered ones. (#1941) + +### Changed +- **`link_source` is an open kebab-case provenance, not a closed allowlist (migration v114).** External edge-writers register a new provenance with no gbrain migration. Existing provenances are unaffected; the migration is lock-friendly on Postgres (validates without blocking writes) and applies automatically on upgrade. CLI-created links now default to `manual` provenance. + +### Fixed +- **Supervisor queue-singleton hardening (follow-up to #1849).** Two supervisors pointed at the same database via different-but-equivalent connection strings could each acquire the "one per queue" lock; the lock is now keyed on the queue alone (its row already lives in the target database), so same-database + same-queue collides correctly. A supervisor that loses the lock race on startup also no longer leaves its pidfile behind to block the next start. + +### To take advantage of v0.42.31.0 + +`gbrain upgrade`. The constraint migration runs automatically. To write edges from a tool or the CLI: `gbrain link-add --link-type --link-source `; `gbrain link-sources` shows what's in the graph; `gbrain link-rm --link-source ` removes only that provenance's edges. + ## [0.42.29.0] - 2026-06-07 **The background-job queue stops thrashing on long jobs, the cycle stops wedging itself, and you can no longer run two supervisors against one queue by accident.** Three fixes plus a voice-agent feature. diff --git a/VERSION b/VERSION index d69e0528e..dfd694371 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.42.29.0 \ No newline at end of file +0.42.31.0 diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 45cd0e836..2bd2e2542 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -8,7 +8,7 @@ lives in `CHANGELOG.md` + `git log` / `git blame`, NOT here. Do not append per-release `**vX.Y.Z:**` narration — CI enforces this (`scripts/check-key-files-current-state.sh`). -- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Exports upload validators `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` is a REQUIRED field flagging untrusted callers; `OperationContext.allowedSlugPrefixes` is the trusted-workspace allow-list set by the dream cycle; `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link skipped only when `remote=true && !trustedWorkspace`. Every `Operation` carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`; `sync_brain`, `file_upload`, `file_list`, `file_url` are `admin + localOnly` (rejected over HTTP). Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) use FAIL-CLOSED semantics: `ctx.remote === false` for trusted-only sites, `ctx.remote !== false` for "untrust unless explicit-false" — anything not strictly `false` is treated as remote (closes the HTTP MCP shell-job RCE where a read+write OAuth token could submit `shell` jobs). `sourceScopeOpts(ctx)` encodes the source-scoped read precedence ladder — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId`/`ctx.auth.sourceId`) over nothing; every read-side op handler routes through it so a source-bound OAuth client can't see neighboring sources via `search`/`query`/`list_pages`/`get_page`/`find_experts`/query's image path. `put_page`'s inline disk write-through is the shared `writePageThrough` helper (`src/core/write-through.ts`), ATOMIC via temp-sibling + rename so a crash or concurrent `gbrain sync` can't read a half-written `.md`; same helper backs `gbrain brainstorm/lsd --save`. +- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Exports upload validators `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` is a REQUIRED field flagging untrusted callers; `OperationContext.allowedSlugPrefixes` is the trusted-workspace allow-list set by the dream cycle; `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link skipped only when `remote=true && !trustedWorkspace`. Every `Operation` carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`; `sync_brain`, `file_upload`, `file_list`, `file_url` are `admin + localOnly` (rejected over HTTP). Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) use FAIL-CLOSED semantics: `ctx.remote === false` for trusted-only sites, `ctx.remote !== false` for "untrust unless explicit-false" — anything not strictly `false` is treated as remote (closes the HTTP MCP shell-job RCE where a read+write OAuth token could submit `shell` jobs). `sourceScopeOpts(ctx)` encodes the source-scoped read precedence ladder — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId`/`ctx.auth.sourceId`) over nothing; every read-side op handler routes through it so a source-bound OAuth client can't see neighboring sources via `search`/`query`/`list_pages`/`get_page`/`find_experts`/query's image path. `put_page`'s inline disk write-through is the shared `writePageThrough` helper (`src/core/write-through.ts`), ATOMIC via temp-sibling + rename so a crash or concurrent `gbrain sync` can't read a half-written `.md`; same helper backs `gbrain brainstorm/lsd --save`. Link provenance surface (#1941): `add_link` (`gbrain link`/`link-add`) + `remove_link` (`gbrain unlink`/`link-rm`) expose `link_source`/`link_type`; `add_link` rejects the reconciliation-managed built-ins via `MANAGED_LINK_SOURCES` (`markdown`/`frontmatter`/`mentions`/`wikilink-resolved`) and defaults omitted provenance to `'manual'` (the engine's own default stays `'markdown'` for internal callers); `list_link_sources` (`gbrain link-sources`, read) lists provenances via `sourceScopeOpts`. CLI aliases register through `cliHints.aliases` (collision-guarded in `src/cli.ts`). - `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput`/`TimelineBatchInput` for the bulk-insert API (`addLinksBatch`/`addTimelineEntriesBatch`). `readonly kind: 'postgres' | 'pglite'` discriminator lets `src/core/migrate.ts` and others branch without `instanceof` + dynamic imports. Methods: `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[],$2::text[],$3::real[])` composite-keyed on `(slug, source_id)`), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` has `sort?: 'updated_desc'|'updated_asc'|'created_desc'|'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines. `listAllPageRefs(): Promise>` ordered by `(source_id, slug)` — cheap cross-source enumeration replacing the `getAllSlugs()→getPage(slug)` N+1 (which silently defaulted to `source_id='default'`); parity across postgres-engine.ts + pglite-engine.ts; Pinned by `test/e2e/multi-source-bug-class.test.ts`. `SearchOpts`+`PageFilters` add `sourceIds?: string[]` (federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when set, preserve scalar `sourceId` fast path when unset); `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId`/`opts.sourceIds`. `traverseGraph` opts has `frontierCap?: number` (per-iteration recursive-CTE cap, approx per-BFS-layer); return type `Promise` for MCP wire stability; export `TraverseGraphOpts`; Postgres uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term, PGLite mirrors with positional params; Pinned by `test/regressions/v0_36_frontier_cap.test.ts`. Phantom-redirect methods: `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (content_hash refresh required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug`+`source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence; parity at `test/phantom-redirect-engine-parity.test.ts`. `getAdjacencyBoosts(pageIds): Promise>` powers the per-query graph-signals stage — one SQL query returning inbound-link counts among top-K plus a cross-source count (links from differing `source_id`); `COALESCE(p.source_id,'default')` null safety, `HAVING >= 1`, cross-source CASE-WHEN excludes the target's own source; parity SQL across both engines; `SearchResult` gains optional `base_score`, `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, `reranker_delta` + internal staging fields; Pinned by `test/e2e/graph-signals-engine.test.ts`. Two REQUIRED methods: `deletePages(slugs, {sourceId}): Promise` (single-batch primitive returning slugs actually deleted) and `resolveSlugsByPaths(paths, {sourceId}): Promise>` (batch path→slug lookup); `sourceId` REQUIRED on both at the type level (asymmetric with single-row `deletePage` which keeps optional/'default'); both short-circuit on empty input and throw when `> DELETE_BATCH_SIZE`. Embedding-signature stale-detection quartet: `countStaleChunks(opts?)` gains optional `signature?: string` widening the stale predicate from `embedding IS NULL` to ALSO include chunks whose JOINed page `embedding_signature IS NOT NULL AND <> $signature` (NULL signature is GRANDFATHERED, never counted; omit `signature` for the legacy NULL-only count); `sumStaleChunkChars(opts?: {sourceId?, signature?}): Promise` = `SUM(LENGTH(chunk_text))` over stale chunks (same predicate + embed_skip filter + optional sourceId scope), used by `gbrain sync --all` cost preview via `estimateCostFromChars`; `setPageEmbeddingSignature(slug, {sourceId?, signature})` stamps `pages.embedding_signature` after a page's chunks (re)embed, idempotent no-op when page absent; `invalidateStaleSignatureEmbeddings({signature, sourceId?}): Promise` NULLs `embedding`+`embedded_at` on every chunk whose page signature is set AND differs, returning the count, called BEFORE `listStaleChunks` so signature-drift pages flow through the NULL-embedding keyset cursor unchanged (NULL never invalidated). Widens `findOrphanPages(opts?: {sourceId?, sourceIds?})` (candidate-side scoping only; inbound links counted from any source). Pinned by `test/sum-stale-chunk-chars.test.ts`, `test/embedding-signature-stale.test.ts`, `test/e2e/engine-parity.test.ts`. Free-text alias layer: `resolveAliases(aliasNorms, opts?): Promise>>` (READ; maps each normalized alias to declaring `(slug, source_id)` pairs, source-scoped) and `setPageAliases(slug, sourceId, aliasNorms)` (WRITE; replaces the full alias set, delete-then-insert, empty clears, idempotent on the unique triple), called by the `importFromContent` ingest projection and the `reindex --aliases` backfill; parity across both engines, Pinned by `test/search/page-aliases-engine.test.ts`. `searchVector` in both engines injects the shared `buildBestPerPagePoolCte` per-page max-pool so a page surfaces on its strongest chunk. `executeRawDirect(sql, params?, opts?)` is the lock-hot-path sibling of `executeRaw`: same single-statement contract, but routes to the direct session-mode pool when dual-pool is active (Postgres/Supabase port 5432) so a long-held lock heartbeat survives the transaction pooler's per-transaction connection recycling; PGLite delegates straight to `executeRaw` (no pooler). Both engines implement it; the Minion lock path (`claim`/`renewLock`) is the consumer. - `src/core/engine-constants.ts` — single source of truth for engine batch-sizing constants. Exports `DELETE_BATCH_SIZE = 500` consumed by both engines' `deletePages` + `resolveSlugsByPaths` and by the sync delete + rename loops. Lives outside `engine.ts` (the interface module) to avoid circular-import worry — bounded per-statement work for predictable lock hold time + write amplification. - `src/core/background-work.ts` (#1762/#1745/#1775) — process background-work registry: the single owner of "drain every fire-and-forget DB-write sink before the CLI disconnects." `registerBackgroundWorkDrainer({name, order, drain(timeoutMs), abort?})` + `drainAllBackgroundWorkForCliExit({timeoutMs})` over a `Map` (idempotent registration by name; `__registerDrainerForTest` returns an unregister handle). Drains in explicit `(order, name)` order — facts FIRST (order 0) so its abort-path DB `logIngest` runs against the freshest live engine — and AWAITS `abort()` only when `drain()` reports `unfinished>0`. Best-effort per drainer: one sink's failure never blocks the others or the disconnect. FOUR sinks register at module import (rule-of-four): `facts/queue.ts` (order 0; `abort`=`shutdown()` cancels a hung facts:absorb Haiku via internalAbort), `last-retrieved.ts` (order 1), `search/hybrid.ts` (order 2; `awaitPendingSearchCacheWrites` bounded via `Promise.race`), `eval-capture.ts` (order 3; `captureEvalCandidate` self-tracks its promise via `awaitPendingEvalCaptures`). Both CLI-exit paths (`src/cli.ts` op-dispatch finally + `handleCliOnly` finally) call it before `engine.disconnect()` — closing the PGLite busy-loop where `db.close()` raced an in-flight job and pinned the single-writer lock (#1762). CLI-EXIT-ONLY: the facts `shutdown()` abort is permanent process state, never call in a long-lived `gbrain serve`. Companion changes: `src/core/ai/gateway.ts` `withDefaultTimeout(caller, ms)` bounds every outbound AI call (chat 300s, embed+multimodal 60s; env `GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS`; composed with caller signals via `AbortSignal.any`) and the op-dispatch + handleCliOnly force-exit timers `process.exit(process.exitCode ?? 0)` so a hung disconnect can't mask an errored op as success; `src/core/postgres-engine.ts` `reconnect()` module-mode branch re-establishes via idempotent `db.connect()` + `connectionManager.setReadPool` refresh instead of `db.disconnect()` (no null window for concurrent ops; fail-loud on real connect failure — #1745); `src/core/search/hybrid.ts` `embedQueryBounded` + a shared `QueryEmbedDeadline` (6s, floored 2s per embed via `MIN_QUERY_EMBED_BUDGET_MS`; env `GBRAIN_QUERY_EMBED_TIMEOUT_MS`) bounds the cache-lookup AND inner query embeds so a stalled provider falls back to keyword instead of hanging past the 10s force-exit (#1775). Incorporates + hardens PR #1763 (@ElliotDrel). Pinned by `test/core/background-work.test.ts`, `test/search/query-embed-deadline.test.ts`, `test/eval-capture-drain.test.ts`, `test/e2e/postgres-reconnect-singleton.test.ts`, `test/e2e/pglite-cli-exit.serial.test.ts`, `test/fix-wave-structural.test.ts`. @@ -21,7 +21,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/commands/search.ts:gbrain search stats` extension — `graph_signals` section (enabled/source/failures_count/failures_by_reason). JSON envelope adds a `graph_signals` sibling property; `_meta.metric_glossary` adds `graph_signals.enabled` + `graph_signals.failures_by_reason`. Human output prints the section after the existing block. Reads `search.graph_signals` config first, falls back to the mode default. Pinned by `test/search/search-stats-graph-signals.test.ts`. - `src/commands/doctor.ts` extension — `graph_signals_coverage` check wired into both `runDoctor` (local) and `doctorReportRemote` (HTTP/JSON thin-client path). Reads `search.graph_signals` config first, falls back to mode default; silent `ok` when disabled. Computes inbound link coverage on the page set; warns at <10% with `gbrain extract all` fix hint; `ok` at ≥30% ("fire on most queries") and 10-29% ("fire occasionally"), each with the percentage embedded. Pinned by cases in `test/doctor.test.ts`. - `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`). -- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the whole batch as one JSONB document via `jsonb_to_recordset(($1::jsonb)->'rows')` (bound through `executeRawJsonb` with a `{ rows }` wrapper; rows built by the shared `src/core/batch-rows.ts` helpers, NUL-stripped), and are `batchRetry`-wrapped. `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error (macOS 26.3 WASM bug #223, points at `gbrain doctor`); the lock is released on failure so the next process can retry cleanly. `searchKeyword`/`searchKeywordChunks` multiply `ts_rank` by the source-factor CASE at chunk grain; `searchVector` is a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`, inner LIMIT scales with offset to preserve pagination. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for forward-referenced state the embedded blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target, plus `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`) and adds only what's missing; threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope; no-op on fresh installs and modern brains (closes the upgrade-wedge bug class #239/#243/#266/#357/#366/#374/#375/#378/#395/#396/#1018/#974/#820). `getBrainScore` returns 100/100 with full breakdown (35/25/15/15/10) when `pageCount === 0` (vacuous truth — empty brain has no coverage problem); Pinned by `test/brain-score-breakdown.test.ts` empty-brain assertion + `test/doctor-report-remote.serial.test.ts`. `disconnect()` uses snapshot+early-null (snapshot `_db`/`_lock`, null instance fields BEFORE any `await` so a concurrent `connect()` can't see a partial mid-close state) wrapped in try/finally guaranteeing lock-release even if `db.close()` throws; KEEPS close-then-release order (release-then-close was rejected: it would widen the window where a sibling process connects to a still-closing brain); Pinned by `test/pglite-engine-disconnect.serial.test.ts`. Exports `classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown'` + `buildPgliteInitErrorMessage(verdict, original)` routing the catch-block hint by failure shape (`bunfs` matches literal `$$bunfs` OR `ENOENT[\s\S]*pglite\.data` co-occurrence, surfaces a paste-ready `bun upgrade` + Node fallback; `macos-26-3` keeps the #223 link; `unknown` falls through); Pinned by `test/pglite-init-classifier.test.ts`. Implements `deletePages(slugs, {sourceId})` + `resolveSlugsByPaths(paths, {sourceId})` via `slug = ANY($1::text[])` array-param binding, caller-chunking primitive throwing when input exceeds `DELETE_BATCH_SIZE`, `deletePages` returns `RETURNING slug` rows so callers filter `pagesAffected` to confirmed deletes. Implements the embedding-signature stale-detection quartet — `sumStaleChunkChars({sourceId?, signature?})`, `setPageEmbeddingSignature(slug, {sourceId?, signature})`, `invalidateStaleSignatureEmbeddings({signature, sourceId?})`, widened `countStaleChunks({sourceId?, signature?})` (the `signature` opt widens via `JOIN pages p ... WHERE cc.embedding IS NULL OR (p.embedding_signature IS NOT NULL AND p.embedding_signature <> $signature)`, NULL grandfathered); parity SQL with postgres-engine.ts. PGLite-specific DDL (pgvector, pg_trgm, triggers). +- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all BrainEngine methods. `listLinkSources({sourceId?, sourceIds?})` returns distinct `link_source` provenances + counts (`ORDER BY count DESC, link_source ASC NULLS LAST`; scalar + federated scoped; parity with postgres-engine.ts) powering `gbrain link-sources`. `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the whole batch as one JSONB document via `jsonb_to_recordset(($1::jsonb)->'rows')` (bound through `executeRawJsonb` with a `{ rows }` wrapper; rows built by the shared `src/core/batch-rows.ts` helpers, NUL-stripped), and are `batchRetry`-wrapped. `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error (macOS 26.3 WASM bug #223, points at `gbrain doctor`); the lock is released on failure so the next process can retry cleanly. `searchKeyword`/`searchKeywordChunks` multiply `ts_rank` by the source-factor CASE at chunk grain; `searchVector` is a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`, inner LIMIT scales with offset to preserve pagination. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for forward-referenced state the embedded blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target, plus `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`) and adds only what's missing; threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope; no-op on fresh installs and modern brains (closes the upgrade-wedge bug class #239/#243/#266/#357/#366/#374/#375/#378/#395/#396/#1018/#974/#820). `getBrainScore` returns 100/100 with full breakdown (35/25/15/15/10) when `pageCount === 0` (vacuous truth — empty brain has no coverage problem); Pinned by `test/brain-score-breakdown.test.ts` empty-brain assertion + `test/doctor-report-remote.serial.test.ts`. `disconnect()` uses snapshot+early-null (snapshot `_db`/`_lock`, null instance fields BEFORE any `await` so a concurrent `connect()` can't see a partial mid-close state) wrapped in try/finally guaranteeing lock-release even if `db.close()` throws; KEEPS close-then-release order (release-then-close was rejected: it would widen the window where a sibling process connects to a still-closing brain); Pinned by `test/pglite-engine-disconnect.serial.test.ts`. Exports `classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown'` + `buildPgliteInitErrorMessage(verdict, original)` routing the catch-block hint by failure shape (`bunfs` matches literal `$$bunfs` OR `ENOENT[\s\S]*pglite\.data` co-occurrence, surfaces a paste-ready `bun upgrade` + Node fallback; `macos-26-3` keeps the #223 link; `unknown` falls through); Pinned by `test/pglite-init-classifier.test.ts`. Implements `deletePages(slugs, {sourceId})` + `resolveSlugsByPaths(paths, {sourceId})` via `slug = ANY($1::text[])` array-param binding, caller-chunking primitive throwing when input exceeds `DELETE_BATCH_SIZE`, `deletePages` returns `RETURNING slug` rows so callers filter `pagesAffected` to confirmed deletes. Implements the embedding-signature stale-detection quartet — `sumStaleChunkChars({sourceId?, signature?})`, `setPageEmbeddingSignature(slug, {sourceId?, signature})`, `invalidateStaleSignatureEmbeddings({signature, sourceId?})`, widened `countStaleChunks({sourceId?, signature?})` (the `signature` opt widens via `JOIN pages p ... WHERE cc.embedding IS NULL OR (p.embedding_signature IS NOT NULL AND p.embedding_signature <> $signature)`, NULL grandfathered); parity SQL with postgres-engine.ts. PGLite-specific DDL (pgvector, pg_trgm, triggers). - `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the batch as one JSONB document — `INSERT ... SELECT FROM jsonb_to_recordset(($1::jsonb)->'rows') AS v(...) JOIN pages ...` bound through `executeRawJsonb({ rows })` — which encodes arbitrary free text safely (the old `unnest(${arr}::text[])` array-literal path crashed Postgres with "malformed array literal" on calendar/Zoom context, gbrain#1861) and sidesteps the 65535-parameter cap; takes declares native recordset column types (`page_id int, weight real, active boolean, …`) so no per-element casts; all three are `batchRetry`-wrapped. `searchKeyword`/`searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection. `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. `searchKeyword`/`searchKeywordChunks`/`searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude from `src/core/search/sql-ranking.ts`; `searchVector` is a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in outer SELECT) carrying `p.source_id` inner→outer. `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures, and by `batchRetry` on a retryable connection error). Concurrent callers share one in-flight `_reconnectPromise` (they await the single reconnect rather than racing a half-rebuilt pool); ownership re-samples through the atomic `db.connect()` token on the connect leg. `reconnect(ctx?)` accepts the triggering error and records a pool-recovery audit event (`reap_detected`/`reconnect_other`/`reconnect_succeeded`/`reconnect_failed`) for the `pool_reap_health` doctor check. `executeRaw` is a single-statement passthrough — no per-call retry (unsound for non-idempotent statements; recovery is supervisor-driven). `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. `countStaleChunks()`+`listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale` (eliminates ~76 MB/call client-side pull); `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same probe set as PGLite (extended for column-only forward-reference cases: `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`/`archived_at`/`archive_expires_at`); the entire probe path runs on the DDL connection threaded from `initSchema` (closing a concurrent-bootstrap race for Supabase pooler users); closes #1018/#974/#820. `disconnect()` is idempotent — `_connectionStyle` tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than clobbering the singleton; and a module-style engine only calls `db.disconnect()` when it owns the singleton (`_ownsModuleSingleton`, set from the `db.connect()` creation token), so a borrower probe engine's teardown leaves the cycle owner's connection intact. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` + `test/postgres-engine-singleton-ownership.test.ts`. `getBrainScore` empty-brain parity with PGLite — 100/100 with breakdown 35/25/15/15/10 when `pageCount === 0` (both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic). Implements `deletePages(slugs, {sourceId}): Promise` via `DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug` (single round-trip; caller chunks); `resolveSlugsByPaths` does `SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2`; FK cascades through `content_chunks`/`links`/`tags`/`raw_data`/`timeline_entries`/`page_versions`, `files.page_id`+`links.origin_page_id` go SET NULL; throws when input exceeds `DELETE_BATCH_SIZE` (from `src/core/engine-constants.ts`); both short-circuit on empty input. Implements the embedding-signature stale-detection quartet (`sumStaleChunkChars`, `setPageEmbeddingSignature`, `invalidateStaleSignatureEmbeddings`, widened `countStaleChunks`, all accept optional `signature` extending "stale" to model/dims-swap drift via the `pages.embedding_signature` JOIN, NULL grandfathered; the `embedding IS NULL` server-side filter is preserved as the no-signature fast path); Pinned by `test/e2e/engine-parity.test.ts`. - `src/core/cjk.ts` — Single source of truth for CJK detection. Exports `CJK_RANGES_REGEX`, `CJK_SLUG_CHARS` (character-class fragment for embedding inside other regexes), `CJK_SENTENCE_DELIMITERS` (`。!?`), `CJK_CLAUSE_DELIMITERS` (`;:,、`), `CJK_DENSITY_THRESHOLD = 0.30`, `hasCJK(s)`, `countCJKAwareWords(s)` (30% density threshold — English docs with one Japanese term stay whitespace-tokenized; Chinese-dominant docs get char-counted), and `escapeLikePattern(s)` (escapes `%`, `_`, `\\` for `ILIKE ... ESCAPE '\\'`). BMP-only ranges (Han / Hiragana / Katakana / Hangul Syllables). Consumers: `expansion.ts`, `sync.ts:slugifySegment`, `operations.ts:validatePageSlug + validateFilename`, `chunkers/recursive.ts:countWords + DELIMITERS`, `pglite-engine.ts:searchKeyword + searchKeywordChunks`. - `src/core/audit-slug-fallback.ts` — Weekly ISO-week-rotated audit JSONL at `~/.gbrain/audit/slug-fallback-YYYY-Www.jsonl`. `logSlugFallback(slug, sourcePath)` fires when `importFromFile` falls back to a frontmatter slug because `slugifyPath` returned empty (emoji / Thai / Arabic / non-CJK exotic-script filenames). `readRecentSlugFallbacks(days)` reads the last N days for `gbrain doctor`'s `slug_fallback_audit` check. Honors `GBRAIN_AUDIT_DIR` via the shared `resolveAuditDir()`. Separate surface from `sync-failures.jsonl` — that file carries bookmark-gating semantics that info events shouldn't trigger. @@ -174,7 +174,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/conversation-parser/` — 14-pattern built-in chat-format registry + opt-in LLM polish/fallback. Modules: `types.ts` (PatternEntry + ParseResult + DateContext + CaptureMap + TimezonePolicy), `builtins.ts` (14 hand-vetted patterns sourced from public format docs — iMessage/Slack, Telegram bracket + text-export, bold-paren-time, bold-name-no-time, Discord classic + export, WhatsApp ISO + US, Signal, Matrix/Element, IRC classic + weechat, Teams export; module-load validation runs every `test_positive[]` + `test_negative[]` sample at startup so a typo in any built-in regex makes gbrain refuse to start; `DEFAULT_SPEAKER_CLEAN` exported as a module-level default), `parse.ts` (orchestrator with pattern-priority scoring across the first 10 lines + date derivation chain `explicit > frontmatter.date > effective_date > '1970-01-01'` + multi-line continuation + timezone warning), `llm-base.ts` (shared `runLlmCall` with content-hash cache in-process + DB-persistent via migration v97 + 4-strategy JSON repair + Anthropic-key probe; polish and fallback are thin wrappers), `llm-polish.ts` (opt-IN; headroom guard skips when tracker within $0.10 of cap; pure `applyPolish` for merge/drop/edit ops), `llm-fallback.ts` (opt-IN; NO regex inference + NO persistence), `eval.ts` (`scoreFixture` + `aggregateScores` + `parseFixtureJsonl` for the fixture-corpus CI gate), `nightly-probe.ts` (DI-stubbed; mode-gated default tokenmax=ON, conservative/balanced opt-in; adversarial false-positive detection). Pattern `bold-name-no-time` (regex `/^\*\*(?!\[)(.+?):\*\*\s*(.*)$/`, index 3 after `bold-paren-time`) parses `**Speaker:** text` with NO per-line timestamp (Circleback/Granola/Zoom), anchoring every message at `T00:00:00Z` of the frontmatter date (line order preserves sequence, same no-time convention as `irc-classic`); the `(?!\[)` lookahead rejects telegram-bracket `**[18:37] Name:**`; non-shadow is the colon-INSIDE-bold regex (NOT declaration order — `parse.ts` scores every candidate independently, order is only the tie-break). Because `**Label:** text` is a common prose idiom, the pattern sets optional `PatternEntry.score_full_body: true` so `parse.ts` recomputes the winner's acceptance score over the FULL body before the `SCORING_MIN_ACCEPTANCE` floor, keeping a bold-label notes page at `no_match`. Pattern `bold-paren-time` parses `**Speaker** (HH:MM): text` and `(HH:MM:SS)` (date_source: frontmatter). Fallback gates: `SCORING_HEAD_TRIGGER_THRESHOLD = 0.3` triggers a full-body re-score when the head pass scores below that; `SCORING_MIN_ACCEPTANCE = 0.05` blocks essay false-positives. Exported `scorePatternFull(body, entry)`; private `getNonBlankLines(body, headCap?)` + `scoreFromLines(lines, entry)` DRY the quick_reject+regex loop. CLI surfaces at `src/commands/eval-conversation-parser.ts` (`gbrain eval conversation-parser ` exit 0/1/2, wired into `bun run verify` via `check:conversation-parser`) and `src/commands/conversation-parser.ts` (`scan ` debug, `list-builtins`, `validate `). Doctor checks: `conversation_format_coverage`, `progressive_batch_audit_health`, `conversation_parser_probe_health`. Pinned by `test/conversation-parser/{parse,llm-base,llm-fallback,llm-polish,nightly-probe}.test.ts` + the 27-case baseline at `test/extract-conversation-facts.test.ts` (back-compat invariant). Migration v97 (`conversation_parser_llm_cache_table`). Fixtures at `test/fixtures/conversation-formats/{imessage,telegram-bracket,whatsapp-iso,whatsapp-us,signal-export,irc-classic,irc-weechat,matrix-element,teams-export,all,adversarial,bold-name-no-time}.jsonl` with `scripts/check-fixture-privacy.sh` banning real-name leaks. - `src/core/progressive-batch/` — shared ramp-up + cost-cap + verification primitive (trial 10 → ramp 100 → ramp 500 → full, with verification at each stage), with verifier+policy injection (callers describe HOW TO MEASURE SUCCESS, not WHEN TO WAIT FOR CTRL-C). Modules: `types.ts` (Stage, StageVerdict, AbortReason, discriminated `Verifier` union `OutputCountVerifier | IdempotentMutationVerifier | NoopVerifier`, Policy, StageReport), `orchestrator.ts` (`runProgressiveBatch(items, verifier, policy, runner)` — reads `getCurrentBudgetTracker()` ahead of `Policy.maxCostUsd` fail-closed; null both ways triggers `abort_cost_cap reason='no_budget_safety_net'`), `audit.ts` (ISO-week JSONL at `~/.gbrain/audit/progressive-batch-YYYY-Www.jsonl` via the shared `audit-writer` primitive), `stage-report.ts` (ASCII formatter for the default `Policy.onStageReport`). Env knobs: `GBRAIN_PROGRESSIVE_BATCH_DISABLED=1`, `GBRAIN_PROGRESSIVE_BATCH_AUTO=1` (skip Ctrl-C grace), `GBRAIN_PROGRESSIVE_BATCH_STAGES=10,100,500`. Sites that "jump straight to full" stay that way by default; ramp is opt-in per-site via `Policy.interactiveAbortMs > 0`. Pinned by `test/progressive-batch/orchestrator.test.ts` (35 cases, every verdict path). - `src/commands/extract-conversation-facts.ts` + `src/core/cycle/conversation-facts-backfill.ts` — bulk fact extraction for long-form conversation pages. Walks conversation/meeting/slack/email pages, splits into time-windowed segments (30-min gap or 30-msg cap), prepends a topical/temporal header, and runs through `extractFactsFromTurn()` so anchor-rich facts surface in `gbrain search`. Invariants: **strict per-source core** (`runExtractConversationFactsCore({sourceId, ...})` always takes one sourceId; CLI + cycle phase each do their own multi-source iteration because PHASE_SCOPE='source' is taxonomy-only); **two-phase enumeration** (paginated `listPages({type, sourceId, limit:10})`; per-page body cap MAX_PAGE_BODY_BYTES=25MB with `pages_skipped_too_large` counter surfaced in doctor); **page-global row_num accumulator** (facts unique index is `(source_id, source_markdown_slug, row_num)` per migration v51 — per-segment row_num would collide); **page-level TERMINAL audit row** to facts table after all segments commit (source='cli:extract-conversation-facts:terminal'; doctor's NOT EXISTS matches the terminal row so partial-extraction pages stay in backlog); **optional `opts.budgetTracker?`** (when present, used as-is — nested `withBudgetTracker` REPLACES; when absent, core auto-wraps with `BudgetTracker({maxCostUsd})`); **body read covers compiled_truth + timeline**; **honors `facts.extraction_enabled` kill-switch** with `--override-disabled` escape; **--types LIST allowlist** (`conversation,meeting,slack,email`) with CLI default reading `cycle.conversation_facts_backfill.types`; **fingerprint on sourceId only**; **string-encoded op-checkpoint** entries `"||"` for resume (durable audit is the facts terminal row); **`--background` via maybeBackground** (Minion handler `extract-conversation-facts` re-creates BudgetTracker from `data.max_cost_usd`; on `BudgetExhausted` mid-job catches + persists + marks `completed` with `result.budget_exhausted=true`). The companion cycle phase `conversation_facts_backfill` (default OFF) iterates `listSources(engine)`, creates ONE brain-wide tracker per tick + wraps the loop in `withBudgetTracker` + passes the tracker into every per-source call. Two-layer cost AND walltime caps: per-source (`max_cost_usd=$1`, `max_walltime_min=20`) AND brain-wide (`max_total_cost_usd=$5`, `max_total_walltime_min=30`). Pinned by `test/extract-conversation-facts.test.ts` (27 cases). Migration v94 adds partial index `idx_facts_extract_conversation_session ON facts(source_id, source_session) WHERE source LIKE 'cli:extract-conversation-facts%'` (`transaction:false` + invalid-index pre-drop on Postgres; plain CREATE INDEX on PGLite). `src/commands/doctor.ts:computeConversationFactsBacklogCheck` is 3-state (SKIPPED when disabled; OK when caught up; WARN when >10 pages lack the terminal row, with paste-ready `gbrain doctor --remediate` step). `src/commands/sources.ts:runAudit` adds `facts_backfill_estimate: {pages, est_segments, est_cost_usd, types}`. Schema-pack `gbrain-base.yaml` promotes `conversation` (temporal, extractable) + `atom` (annotation, NOT extractable) into the base seed; backstop uses hardcoded `ELIGIBLE_TYPES` in `src/core/facts/eligibility.ts:51` not pack extractable. `ALL_PAGE_TYPES` in `src/core/types.ts` extended with the two new types. -- `src/core/link-extraction.ts` — shared library for the graph layer. `extractEntityRefs` (canonical) matches `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks; `extractPageLinks`, `inferLinkType` heuristics (attended/works_at/invested_in/founded/advises/source/mentions), `parseTimelineEntries`, `isAutoLinkEnabled`. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts. **Opt-in global-basename wikilink resolution** (issue #972, default off): `WIKILINK_GENERIC_RE` catches bare `[[name]]` wikilinks outside `DIR_PATTERN` (third pass `2c` in `extractEntityRefs`); `EntityRef.needsResolution: true` tags refs from this pass (the ref's `slug` is the wikilink TARGET, `name` the optional display alias). `SlugResolver` gains optional `resolveBasenameMatches(name): Promise` (multi-match by design — emits one edge per matching page). The single shared basename matcher is `buildBasenameIndex(slugs)` + `queryBasenameIndex(index, name)` + `normalizeBasename` (keys raw/lower/slugified tail, stable-sorted shorter-first then lexical), used by `makeResolver`, the FS `resolveBasenameMatchesFromSlugs`, AND the doctor check so they cannot drift. `makeResolver(engine, {mode, sourceId})` builds the index lazily via `engine.getAllSlugs({sourceId})` — source-scoped so a bare `[[name]]` never resolves to a same-tail page in a different source. `extractPageLinks` gains `opts.globalBasename` (routes `needsResolution` refs through `resolveBasenameMatches` keyed on `ref.slug`, emits candidates tagged `linkType: 'wikilink_basename'` + `linkSource: 'wikilink-resolved'`, skips self-loops) and `opts.skipFrontmatter` (replaces the old `nullResolver` ternary). All three surfaces (FS extract, DB extract, `put_page` auto-link) tag provenance with `link_source='wikilink-resolved'`; `put_page` includes it in its reconcilable-edge set so stale basename edges are removed when the wikilink or the flag goes away. Exports `WIKILINK_BASENAME_LINK_TYPE` + `isGlobalBasenameEnabled(engine)` (resolution order: env `GBRAIN_LINK_RESOLUTION_GLOBAL_BASENAME` → DB config `link_resolution.global_basename` → default false). `gbrain doctor`'s `link_resolution_opportunity` check surfaces a paste-ready enable hint when ≥5 bare wikilinks would resolve AND ≥20% match. Migration v113 widens `links_link_source_check` to admit `'wikilink-resolved'`. `LINK_EXTRACTOR_VERSION_TS` also lives here (bump like `CHUNKER_VERSION` to invalidate prior extract-stale stamps). Pinned by `test/link-extraction.test.ts`, `test/extract-fs.test.ts`, `test/doctor.test.ts`, `test/e2e/global-basename-pglite.test.ts`. +- `src/core/link-extraction.ts` — shared library for the graph layer. `extractEntityRefs` (canonical) matches `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks; `extractPageLinks`, `inferLinkType` heuristics (attended/works_at/invested_in/founded/advises/source/mentions), `parseTimelineEntries`, `isAutoLinkEnabled`. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts. **Opt-in global-basename wikilink resolution** (issue #972, default off): `WIKILINK_GENERIC_RE` catches bare `[[name]]` wikilinks outside `DIR_PATTERN` (third pass `2c` in `extractEntityRefs`); `EntityRef.needsResolution: true` tags refs from this pass (the ref's `slug` is the wikilink TARGET, `name` the optional display alias). `SlugResolver` gains optional `resolveBasenameMatches(name): Promise` (multi-match by design — emits one edge per matching page). The single shared basename matcher is `buildBasenameIndex(slugs)` + `queryBasenameIndex(index, name)` + `normalizeBasename` (keys raw/lower/slugified tail, stable-sorted shorter-first then lexical), used by `makeResolver`, the FS `resolveBasenameMatchesFromSlugs`, AND the doctor check so they cannot drift. `makeResolver(engine, {mode, sourceId})` builds the index lazily via `engine.getAllSlugs({sourceId})` — source-scoped so a bare `[[name]]` never resolves to a same-tail page in a different source. `extractPageLinks` gains `opts.globalBasename` (routes `needsResolution` refs through `resolveBasenameMatches` keyed on `ref.slug`, emits candidates tagged `linkType: 'wikilink_basename'` + `linkSource: 'wikilink-resolved'`, skips self-loops) and `opts.skipFrontmatter` (replaces the old `nullResolver` ternary). All three surfaces (FS extract, DB extract, `put_page` auto-link) tag provenance with `link_source='wikilink-resolved'`; `put_page` includes it in its reconcilable-edge set so stale basename edges are removed when the wikilink or the flag goes away. Exports `WIKILINK_BASENAME_LINK_TYPE` + `isGlobalBasenameEnabled(engine)` (resolution order: env `GBRAIN_LINK_RESOLUTION_GLOBAL_BASENAME` → DB config `link_resolution.global_basename` → default false). `gbrain doctor`'s `link_resolution_opportunity` check surfaces a paste-ready enable hint when ≥5 bare wikilinks would resolve AND ≥20% match. Migration v113 widens `links_link_source_check` to admit `'wikilink-resolved'`; v114 (#1941) then opens it to any kebab-case provenance (`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`, ≤64 chars) so external derivers register their own tag (e.g. `citation-graph`) without a migration. `LINK_EXTRACTOR_VERSION_TS` also lives here (bump like `CHUNKER_VERSION` to invalidate prior extract-stale stamps). Pinned by `test/link-extraction.test.ts`, `test/extract-fs.test.ts`, `test/doctor.test.ts`, `test/e2e/global-basename-pglite.test.ts`. - `src/commands/extract.ts` — `gbrain extract links|timeline|all [--source fs|db] [--source-id ]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use for live brains with no local checkout). No in-memory dedup pre-load — candidates buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, `created` counter returns real rows inserted. `ExtractOpts.slugs?: string[]` enables incremental extract via `extractForSlugs()` (single combined links+timeline pass); the cycle path threads sync's `pagesAffected` through. `walkMarkdownFiles(brainDir)` still runs to build `allSlugs` for link resolution. `--source-id ` scopes extraction to one source on federated brains (resolved via `resolveSourceWithTier()` before any SQL; failures hint `gbrain sources list`). `gbrain extract --stale [--source-id ] [--catch-up] [--dry-run] [--json]` branch (`extractStaleFromDB`) — incremental DB-source link+timeline sweep over pages whose `pages.links_extracted_at` watermark is stale. Stale predicate (shared by both engines + the doctor check): `links_extracted_at IS NULL OR links_extracted_at < LINK_EXTRACTOR_VERSION_TS::timestamptz OR updated_at > links_extracted_at` (the `updated_at` arm catches MCP `put_page` / `sync --no-extract` edited-since-extract). Three new `BrainEngine` methods (parity in postgres-engine.ts + pglite-engine.ts + bootstrap probes): `countStalePagesForExtraction(opts?)`, `listStalePagesForExtraction({batchSize, afterPageId?, sourceId?, versionTs?})` (returns page CONTENT to avoid N+1 `getPage`; `rowToStalePage` in utils.ts maps the row, `StalePageRow` in types.ts), `markPagesExtractedBatch(refs, defaultExtractedAt)` (3-array unnest `slug[],source_id[],ts[]`; each ref may carry its own `extractedAt`). `STALE_BATCH_SIZE` default 25 (`GBRAIN_EXTRACT_STALE_BATCH`; small because page bodies are unbounded — the LIMIT is the only fetch-time memory bound); `STALE_TIME_BUDGET_MS` 30min wall-clock (`--catch-up` removes the cap). Non-swallowing flush: link/timeline flush throws propagate and abort the batch; stamp LAST so a crash leaves pages unstamped and they re-extract idempotently (`addLinksBatch` ON CONFLICT DO NOTHING + timeline dedup). Race fix: `extractStaleFromDB` stamps with each row's READ `updated_at` (not `now()`), so a concurrent edit during the sweep keeps the page stale and it re-extracts next run rather than marked fresh-with-old-content. Source-correct stamping at DB-extract sites via `stampExtracted` (best-effort, never throws); `extractLinksFromDB` only stamps the combined watermark when `subcommand === 'all'` (a links-only run must not hide timeline staleness). `LINK_EXTRACTOR_VERSION_TS` lives in `src/core/link-extraction.ts` (bump like `CHUNKER_VERSION` to invalidate all prior stamps). Migration v112 (`pages_links_extracted_at`) adds nullable `TIMESTAMPTZ` + composite `(source_id, links_extracted_at)` index (CONCURRENTLY + invalid-remnant pre-drop on Postgres, plain on PGLite), NO backfill so the real backlog surfaces on first `gbrain doctor`. Schema parity in schema.sql + pglite-schema.ts + schema-embedded.ts + `REQUIRED_BOOTSTRAP_COVERAGE`. `src/commands/doctor.ts:checkLinksExtractionLag` (the `links_extraction_lag` check, also in `doctorReportRemote`) warn-only by default (>`GBRAIN_EXTRACTION_LAG_WARN_PCT`, default 20%; shared `EXTRACTION_LAG_WARN_PCT_DEFAULT` + `EXTRACTION_LAG_MIN_PAGES=100` + exported `_resolveEnvNumber`), hard-fails only when `GBRAIN_EXTRACTION_LAG_FAIL_PCT` is set; vacuous-skips <100 pages (no `--source`); pre-v112 brains graceful-skip via `isUndefinedColumnError`; strictly a SQL COUNT (safe on remote/thin-client). `src/commands/sync.ts` gains `--no-extract` (threaded through single-source + `--all` + `syncOneSource`), stamps `links_extracted_at` for `pagesAffected` at the inline-extract call site, and `maybeExtractionNudge` prints a one-line stderr nudge after a `synced | first_sync | up_to_date` sync that leaves a backlog (`shouldNudgeAfterSync` pure predicate; `GBRAIN_SYNC_NO_EXTRACT_NUDGE` suppresses). `src/core/retry.ts` adds `'extract.stale'` to `BATCH_AUDIT_SITES`; `src/core/doctor-categories.ts` adds `links_extraction_lag` to `BRAIN_CHECK_NAMES`. Pinned by `test/extract-stale.test.ts` (incl. edited-after-stamp regression + crash-contract), `test/sync-inline-extract-stamps.serial.test.ts`, `test/sync-nudge-status-gate.test.ts`, `test/doctor-links-extraction-lag.test.ts`, engine-parity (Postgres↔PGLite) for the 3 methods + v112 round-trip. The stale SELECT in both engines projects a deterministic full-µs UTC string `to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS updated_at_iso` (carried on `StalePageRow.updated_at_iso`, populated by `rowToStalePage` in utils.ts with an ISO-only fallback — never `String(Date)`, which `::timestamptz` misparses); `extractStaleFromDB` stamps that exact-precision value, not a JS `Date` (which truncates to milliseconds), so on Postgres `links_extracted_at` equals the row's `updated_at` to the microsecond and `links_extraction_lag` clears — a ms-truncated stamp stays strictly below the µs `updated_at` and leaves every page perpetually stale, which `extract --stale` could never satisfy. `to_char` (not raw `::text`, which is `DateStyle`-fragile) keeps the projection deterministic. The `markPagesExtractedBatch` SQL is unchanged, so callers passing an explicit (e.g. backdated) `extractedAt` still control the stamp and the edited-since arm is exact. A deterministic PGLite regression in `test/extract-stale.test.ts` injects a µs `updated_at`, runs `--stale`, and asserts the lag is 0 and stays 0. - `src/core/extract/receipt-writer.ts` + `src/core/extract/rollup-writer.ts` + `src/commands/extract-status.ts` + `src/commands/extract-explain.ts` + `src/commands/extract-benchmark.ts` + `src/core/schema-pack/scaffold-extractable.ts` — unified extract operator surface. Every shipped extractor (deterministic `facts.conversation` in `src/commands/extract-conversation-facts.ts` + three LLM-backed cycle phases at `src/core/cycle/{extract-atoms,synthesize-concepts,propose-takes,extract-facts}.ts`) writes ONE receipt page per run (`writeReceipt`) + UPSERTs a row to `extract_rollup_7d` (`upsertExtractRollup`). Receipt slug `extracts/{date}/{kind}/{source_id}/{run_id_short}/round-{N}.md`; frontmatter stamps BOTH `type: extract_receipt` AND `dream_generated: true` (belt+suspenders against extraction-loop guard drift). `extract_receipt` joins `ALL_PAGE_TYPES` in `src/core/types.ts`; `extracts/` prefix gets a 0.3x source-boost demote in `src/core/search/source-boost.ts`. Migration v104 adds `extract_rollup_7d (kind, source_id, day, cost_usd, halt_count, eval_pass_count, eval_fail_count, round_completed_count, rollup_write_failures, updated_at)` with PK `(kind, source_id, day)` + `idx_extract_rollup_7d_day`. Rollup writes best-effort with process-scoped error-dedup so transient DB failures bump `rollup_write_failures` instead of crashing the cycle. `extract_health` doctor check reads last 7 days, warns at halt-rate > 10% AND when rollup_write_failures > 0; pre-v104 brains report `ok`. CLI: `gbrain extract status [--source-id ID] [--kind X] [--verbose] [--json]` (7-day rollup, sorted halt_rate desc + cost desc, top-5 + "more rows" hint, stable `schema_version: 1`); `gbrain extract --explain ` (resolution chain pack-declared vs built-in cycle phase, prompt_template + fixture_corpus paths with `✓`/`(missing)`, last 7d rollup); `gbrain extract benchmark --pack X --kind Y` (loads pack fixture corpus through strict path validation — rejects absolute paths, `..` traversal, null bytes, AND symlinks resolving outside pack root; ships as a stub-reporter). `src/core/schema-pack/manifest-v1.ts` widens `extractable` from `z.boolean()` to `z.union([z.boolean(), ExtractableSpecSchema])` (carries `prompt_template`, `fixture_corpus`, `eval_dimensions`, `benchmark_min_recall`, plus reserved `verifier_path` — parses but refuses at runtime); `extractableSpecsFromPack` + `getExtractableSpec` + `refuseVerifierPathInV042` in `src/core/schema-pack/extractable.ts`; `gbrain schema scaffold-extractable --pack ` declares the type extractable, generates 5 placeholder fixtures + a prompt template stub under `packs//{fixtures,prompts}/extract/`, refuses to overwrite without `--force`. Pinned by `test/extractable-spec-widening.test.ts` (22), `test/extract/receipt-writer.test.ts` (12, canonical PGLite block R3+R4), `test/extract/benchmark.test.ts` (17), `test/extract/status.test.ts` (15), `test/schema-pack/scaffold-extractable.test.ts` (15, privacy guards), `test/doctor-extract-health.test.ts` (8). - `src/commands/import.ts` — `gbrain import [--source-id ]`: page import with the path-set checkpoint. `--source-id ` routes pages to the named source (resolved via `resolveSourceWithTier()` at the boundary; consistent across `import`, `extract`, `graph-query`, `sources current`). Pinned by `test/import-source-id.test.ts`. @@ -214,11 +214,11 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types, protected-names, quiet-hours, stagger, handlers/shell). - `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail). `add()` takes a 4th `trusted` arg (separate from `opts` to prevent spread leakage); protected names in `PROTECTED_JOB_NAMES` require `{allowProtectedSubmit: true}` and the check runs trim-normalized (whitespace-bypass safe). `add()` plumbs `max_stalled` through with a `[1, 100]` clamp; omitted values let the schema DEFAULT (5) kick in. `handleWallClockTimeouts(lockDurationMs)` is Layer 3 kill shot for jobs where `FOR UPDATE SKIP LOCKED` stall detection and the timeout sweep both fail to evict (wedged worker holding a row lock via a pending transaction). The `maxWaiting` coalesce path uses `pg_advisory_xact_lock` keyed on `(name, queue)` to serialize concurrent submits for the same key, and filters on `queue` in addition to `name` so cross-queue same-name jobs don't suppress each other. `claim` and `renewLock` issue their UPDATE via `engine.executeRawDirect` (not `executeRaw`) so the lock heartbeat runs on the direct session-mode pool that the transaction pooler won't recycle mid-hold; on PGLite this is identical to `executeRaw`. The two terminal dead-letter paths (`handleWallClockTimeouts` wall-clock kill and the stall dead-letter CTE in the stall sweep) BOTH increment `attempts_made` so a long job killed there reads as an honest attempt instead of `attempts 0 / started N`; the stall path also bumps `stalled_counter`, surfaced by `gbrain jobs get` as `Attempts: M/N (started: X, stalled: S/MaxS)`. At submit, `add()` stamps a default `timeout_ms` via `defaultTimeoutMsFor(jobName)` (from `handler-timeouts.ts`) when the caller passed none, so long handlers aren't wall-clock-killed mid-progress by the short null-default; an explicit `opts.timeout_ms` always wins. Guarded by `test/queue-lock-retry.test.ts` (claim never falls back to `executeRaw`), `test/postgres-execute-raw-direct.test.ts` (routing decision matrix), and `test/minions.test.ts` (attempt accounting + default-timeout stamping). - `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). Aborted jobs call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`); `shutdownAbort` (instance field) fires on SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` (shell handler listens; non-shell handlers don't). Per-job timeout fires `abort.abort(new Error('timeout'))` then a 30s grace-then-evict safety net force-evicts the job from `inFlight` and marks it dead if the handler ignores the abort signal (the moved-out generic abort listener now fires for ANY abort reason). The `launchJob` lock-renewal block is a thin sync wrapper around the pure `runLockRenewalTick` from `src/core/minions/lock-renewal-tick.ts` (NEVER `setInterval(async () => await renewLock(...))` — that shape was the unhandledRejection crash class during PgBouncer rotation). Gaps closed: (1) `cancelled` flag captured in the timer closure stops in-flight IIFEs writing misleading audit events after the job ended; (2) re-entrancy guard `tickInFlight` + per-call `Promise.race` timeout (`GBRAIN_LOCK_RENEWAL_CALL_TIMEOUT_MS`, default `lockDuration/3`); (3) time-based abort (`Date.now() - lastSuccessfulRenewalAt >= lockDuration - GBRAIN_LOCK_RENEWAL_SAFETY_MARGIN_MS`) so we release the lock BEFORE another worker can reclaim; (4) explicit `.catch()` on the stored `executeJob(...).finally(...)` promise closes the second unhandledRejection vector; (5) exported `INFRASTRUCTURE_ABORT_REASONS = new Set(['lock-renewal-failed', 'lock-lost'])` so executeJob's catch skips `failJob` for these (PgBouncer blips don't dead-letter healthy jobs; the stall detector reclaims). CI guard `scripts/check-worker-lock-renewal-shape.sh` (in `bun run verify`) asserts the bug pattern stays absent AND `launchJob` keeps calling `runLockRenewalTick`. Engine-ownership invariant: `start()` does NOT call `engine.disconnect()` on shutdown — the CLI handler in `src/commands/jobs.ts case 'work'` owns engine lifecycle via try/finally with loud error logging. RSS watchdog uses non-file-backed pages on Linux: exported `parseRssFromProcStatus(status)` (pure parser; field-presence regex so `RssAnon: 0 + RssShmem: 512` parses correctly) and `getAccurateRss(readStatus?)` (reads `/proc/self/status` for `RssAnon + RssShmem`, falls back to `process.memoryUsage().rss` on macOS / restricted containers / kernel <4.5); the default `getRss` in `WorkerOpts` is `getAccurateRss`. `checkMemoryLimit` tracks peak RSS, fires an 80%-of-cap soft-warn (once per crossing, carrying peak + in-flight job kinds), and on exceed sets `_rssWatchdogTriggered=true` (exposed via `get rssWatchdogTriggered()`) so `jobs work`'s finally can `process.exit(WORKER_EXIT_RSS_WATCHDOG)` after disconnect (drain self-identifying instead of an opaque code-0 exit). The poll loop wraps `claim` in try/catch: on a retryable conn error it reconnects ONCE and continues to the next tick rather than blind-retrying (a retry after `UPDATE...RETURNING` committed but the socket died would double-claim). `LockRenewalDeps` is wired with `reconnect` when the engine supports it. The self-health DB-liveness probe now runs EVEN under a supervisor (`GBRAIN_SUPERVISED=1`): the outer guard is `if (this.opts.healthCheckInterval > 0)` and only the STALL-detection block is wrapped in `if (!isSupervisedChild)` — so a supervised worker whose own pool dies self-exits `unhealthy(db_dead)` after `dbFailExitAfter` probes (the supervisor watches a different connection and can't see this worker's dead pool), while the supervisor's progress watchdog owns forward-progress (#1801). Pinned by `test/worker-lock-renewal.test.ts` (18), `test/audit/lock-renewal-audit.test.ts` (11), `test/scripts/check-worker-lock-renewal-shape.test.ts` (5), `test/worker-shutdown-disconnect.test.ts` (asserts `disconnectSpy).not.toHaveBeenCalled()`), `test/worker-rss.test.ts` (11), `test/worker-supervised-db-probe.test.ts` (3). -- `src/core/minions/supervisor.ts` — MinionSupervisor process manager. Spawns `gbrain jobs work` as a child, restarts on crash with exponential backoff, periodic health check. `consecutiveHealthFailures` counter; on 3 consecutive failures emits `health_warn` with `reason: 'db_connection_degraded'` and calls `engine.reconnect()` to swap in a fresh pool, then resets. Worker exit classifier emits `likely_cause` on `worker_exited` events: `oom_or_external_kill` (SIGKILL), `graceful_shutdown` (SIGTERM), `runtime_error` (code 1), `clean_exit` (code 0), `unknown`. Consumes `detectTini()` + `buildSpawnInvocation()` from `src/core/minions/spawn-helpers.ts` to wrap the worker subtree in tini-as-PID-1 when tini is on `PATH` (handles native-addon zombie reaping the in-process SIGCHLD reaper can't reach); exposes `isTiniDetected` read-only accessor. The spawn-and-respawn loop is the shared `ChildWorkerSupervisor` core: MinionSupervisor composes it via `runSuperviseLoop()` → `new ChildWorkerSupervisor({...})` and maps `ChildSupervisorEvent` back through `emit()` SupervisorEvent (JSONL audit consumers see byte-compatible output). PID lock, signal handlers, health check, and `process.exit` on max-crashes stay in MinionSupervisor. `code=0` leaves `crashCount` untouched (so a worker alternating real crashes + watchdog drains still trips `max_crashes`); `cleanRestartBudget` (default 10 restarts per 60s) caps the macOS/non-Linux-fallback tight-loop via `health_warn { reason: 'clean_restart_budget_exceeded' }` + backoff. `shutdown()` drains via `childSupervisor.killChild('SIGTERM')` + `awaitChildExit(35_000)`. Progress watchdog (#1801): `healthCheck()` restarts an alive-but-wedged child via `childSupervisor.restartCurrentChild(35_000)` when a queue has claimable work, 0 live-lock active jobs, and stale completions across `wedgeRestartChecks` (default 3) consecutive checks past `wedgeRestartMinutes` (default 15, 0 disables) + a `startupGraceMs` window; bounded by `wedgeRestartLoopBudget` (default 3 / `wedgeRestartLoopWindowMs`) which switches to a one-shot `wedge_restart_loop` alert. The wedge query is the exported `queryWedgeSignals(engine, queue, handlerNames)` — name+queue-scoped, `active_healthy` = live-lock only (an expired-lock active row does NOT mask the wedge), due-delayed counted. Claimable names are derived at start via a throwaway `registerBuiltinHandlers` worker (its new `quiet` opt). Flags `--wedge-restart-minutes` / `--wedge-restart-checks` + env `GBRAIN_WEDGE_RESTART_MINUTES` / `GBRAIN_WEDGE_RESTART_CHECKS`. The worker argv is built by the exported pure `buildWorkerArgs(opts)` (appends `--nice N` when `opts.nice_requested` is set, alongside `--concurrency`/`--queue`/`--max-rss`); the niceness apply RESULT (`nice_requested`/`nice_effective`/`nice_error`, computed by the CLI in jobs.ts — the supervisor doesn't call setPriority) rides on the `started`/`worker_spawned` audit emissions (#1815). Queue-scoped singleton (#1849): the real authority is a DB lock (`tryAcquireDbLock` from `src/core/db-lock.ts`) keyed on `supervisorLockId(queue, dbIdentity)` = `gbrain-supervisor::` — the raw DB identity from `currentDbIdentity()`, NOT the lossy `currentBrainId` hash — so two supervisors with different `$HOME`/`--pid-file` against the same `(database, queue)` no longer both run with conflicting `--max-rss`; the second exits `LOCK_HELD`. The default pidfile is now brain-scoped (`supervisor-.pid`) so different brains under one HOME don't false-block. The lock refreshes on its own `setInterval` (TTL 5min, refresh 60s, max 3 failures = 180s < TTL); a refresh that fails past the threshold exits `LOCK_LOST` (code 4) rather than risk a split-brain. `shutdown()` releases the lock so a clean restart re-acquires immediately. The `started` audit now records `max_rss_mb` so `gbrain doctor`'s `supervisor_singleton` check can surface the effective cap. Exports `supervisorLockId()` and the pure `classifySupervisorSingleton({lockLive, lockHolderHost, lockHolderPid, localHost, localPid}) → 'no_lock'|'single'|'mismatch'` (host+pid compare, bare pid meaningless cross-host) that doctor consumes. Pinned by `test/supervisor.test.ts` (16 cases), `test/supervisor-tini.test.ts`, `test/supervisor-wedge.test.ts`, `test/supervisor-build-worker-args.test.ts`, and `test/supervisor-db-lock.test.ts`. +- `src/core/minions/supervisor.ts` — MinionSupervisor process manager. Spawns `gbrain jobs work` as a child, restarts on crash with exponential backoff, periodic health check. `consecutiveHealthFailures` counter; on 3 consecutive failures emits `health_warn` with `reason: 'db_connection_degraded'` and calls `engine.reconnect()` to swap in a fresh pool, then resets. Worker exit classifier emits `likely_cause` on `worker_exited` events: `oom_or_external_kill` (SIGKILL), `graceful_shutdown` (SIGTERM), `runtime_error` (code 1), `clean_exit` (code 0), `unknown`. Consumes `detectTini()` + `buildSpawnInvocation()` from `src/core/minions/spawn-helpers.ts` to wrap the worker subtree in tini-as-PID-1 when tini is on `PATH` (handles native-addon zombie reaping the in-process SIGCHLD reaper can't reach); exposes `isTiniDetected` read-only accessor. The spawn-and-respawn loop is the shared `ChildWorkerSupervisor` core: MinionSupervisor composes it via `runSuperviseLoop()` → `new ChildWorkerSupervisor({...})` and maps `ChildSupervisorEvent` back through `emit()` SupervisorEvent (JSONL audit consumers see byte-compatible output). PID lock, signal handlers, health check, and `process.exit` on max-crashes stay in MinionSupervisor. `code=0` leaves `crashCount` untouched (so a worker alternating real crashes + watchdog drains still trips `max_crashes`); `cleanRestartBudget` (default 10 restarts per 60s) caps the macOS/non-Linux-fallback tight-loop via `health_warn { reason: 'clean_restart_budget_exceeded' }` + backoff. `shutdown()` drains via `childSupervisor.killChild('SIGTERM')` + `awaitChildExit(35_000)`. Progress watchdog (#1801): `healthCheck()` restarts an alive-but-wedged child via `childSupervisor.restartCurrentChild(35_000)` when a queue has claimable work, 0 live-lock active jobs, and stale completions across `wedgeRestartChecks` (default 3) consecutive checks past `wedgeRestartMinutes` (default 15, 0 disables) + a `startupGraceMs` window; bounded by `wedgeRestartLoopBudget` (default 3 / `wedgeRestartLoopWindowMs`) which switches to a one-shot `wedge_restart_loop` alert. The wedge query is the exported `queryWedgeSignals(engine, queue, handlerNames)` — name+queue-scoped, `active_healthy` = live-lock only (an expired-lock active row does NOT mask the wedge), due-delayed counted. Claimable names are derived at start via a throwaway `registerBuiltinHandlers` worker (its new `quiet` opt). Flags `--wedge-restart-minutes` / `--wedge-restart-checks` + env `GBRAIN_WEDGE_RESTART_MINUTES` / `GBRAIN_WEDGE_RESTART_CHECKS`. The worker argv is built by the exported pure `buildWorkerArgs(opts)` (appends `--nice N` when `opts.nice_requested` is set, alongside `--concurrency`/`--queue`/`--max-rss`); the niceness apply RESULT (`nice_requested`/`nice_effective`/`nice_error`, computed by the CLI in jobs.ts — the supervisor doesn't call setPriority) rides on the `started`/`worker_spawned` audit emissions (#1815). Queue-scoped singleton (#1849): the real authority is a DB lock (`tryAcquireDbLock` from `src/core/db-lock.ts`) keyed on `supervisorLockId(queue)` = `gbrain-supervisor:` — keyed on the QUEUE ALONE because the lock row lives inside the target database, so the (database) half of the mutex is physical, not part of the key (an earlier revision mixed in a config-derived DB identity, which let two supervisors on the same physical DB via different-but-equivalent URLs compute different ids and both acquire — fixed). Two supervisors with different `$HOME`/`--pid-file` against the same `(database, queue)` no longer both run with conflicting `--max-rss`; the second exits `LOCK_HELD`. The pidfile-cleanup `process.on('exit')` listener is installed BEFORE the DB-lock acquisition so the `LOCK_HELD` early-exit can't strand the pidfile this process just created. The default pidfile is now brain-scoped (`supervisor-.pid`) so different brains under one HOME don't false-block. The lock refreshes on its own `setInterval` (TTL 5min, refresh 60s, max 3 failures = 180s < TTL); a refresh that fails past the threshold exits `LOCK_LOST` (code 4) rather than risk a split-brain. `shutdown()` releases the lock so a clean restart re-acquires immediately. The `started` audit now records `max_rss_mb` so `gbrain doctor`'s `supervisor_singleton` check can surface the effective cap. Exports `supervisorLockId()` and the pure `classifySupervisorSingleton({lockLive, lockHolderHost, lockHolderPid, localHost, localPid}) → 'no_lock'|'single'|'mismatch'` (host+pid compare, bare pid meaningless cross-host) that doctor consumes. Pinned by `test/supervisor.test.ts` (16 cases), `test/supervisor-tini.test.ts`, `test/supervisor-wedge.test.ts`, `test/supervisor-build-worker-args.test.ts`, and `test/supervisor-db-lock.test.ts`. - `src/core/minions/child-worker-supervisor.ts` — shared spawn-and-respawn core reused by both `MinionSupervisor` (standalone `gbrain jobs supervisor` daemon) and `src/commands/autopilot.ts` (autopilot daemon), so the two consumers can't drift into parallel-loop bugs. Pure class: NO PID file, NO signal handlers, NO `process.exit`, NO health check. Lifecycle events fire via injected `onEvent: (ChildSupervisorEvent) => void`. Exit classifier: `code === 0` leaves `crashCount` UNCHANGED (preserves flap detection across mixed exit sequences); `code != 0` follows `runDuration > stableRunResetMs ? 1 : ++crashCount`. Clean-restart budget: sliding window of code=0 exits; when count exceeds `cleanRestartBudget` (default 10) inside `cleanRestartWindowMs` (default 60s), emits `health_warn { reason: 'clean_restart_budget_exceeded' }` and applies `cleanRestartBudgetBackoffMs` (default 1s). The exit classifier special-cases `WORKER_EXIT_RSS_WATCHDOG` — `likely_cause='rss_watchdog'`, and that exit does NOT bump `crashCount` (routes to its own breaker); a dedicated `_watchdogExitTimestamps` sliding window trips a loud `rss_watchdog_loop` health_warn naming the cap when N watchdog exits land inside the window, INDEPENDENT of the stable-run reset (which would otherwise hide a >5-min-run watchdog drain loop). New opts `watchdogLoopBudget` (3), `watchdogLoopWindowMs` (600000), `watchdogBackoffMs` (30000); `ChildSupervisorEvent` extended. Public read-only accessors `childAlive`, `inBackoff`, `crashCount`; `killChild(signal)` gates on liveness (`exitCode === null && signalCode === null`), NOT `.killed` — `.killed` flips true once a signal is *sent*, so the old `!this._child.killed` guard made a follow-up SIGKILL after an ignored SIGTERM a silent no-op (#1801; the bug also lived in the `shutdown()` drain). `restartCurrentChild(graceMs)` (#1801 wedge self-heal) captures the CURRENT child ref, SIGTERM→grace→SIGKILLs THAT ref (never the respawn — closes the timer-kills-fresh-worker race), and flags `_intentionalRestart` so the exit is `likelyCause='wedge_restart'`, leaves `crashCount` UNTOUCHED (never trips `max_crashes`; like `rss_watchdog`), and respawns immediately (`backoff ms:0 reason='wedge_restart'`). `awaitChildExit(timeoutMs)` short-circuits when `child.exitCode !== null || child.signalCode !== null` so fast-SIGTERM responders don't cause a 35s shutdown hang. Test hooks `_backoffFloorMs`, `_now`. `supervisor-audit.ts` adds `rss_watchdog` as a non-clean cause + its own `CrashSummary.by_cause` bucket, and `wedge_restart` to `CLEAN_EXIT_CAUSES` (a self-heal, not a crash; denylist preserved so future causes route to `legacy`). Pinned by `test/child-worker-supervisor.test.ts` (12 cases). - `src/core/minions/spawn-helpers.ts` — pure `detectTini()` + `buildSpawnInvocation()` consumed by both `supervisor.ts` and `autopilot.ts` (resolves the DRY violation between the two spawn sites and makes tini wrapping testable without `mock.module()`, rule R2 of `scripts/check-test-isolation.sh`). `detectTini()` calls `execFileSync('which', ['tini'])` with explicit `env: process.env` so Bun sees runtime PATH mutations. `buildSpawnInvocation(tiniPath, cmd, args)` returns `{cmd, args}` with tini prepended when present, or the bare invocation otherwise. Pinned by `test/spawn-helpers.test.ts` (5) and `test/supervisor-tini.test.ts` (4). - `src/core/minions/niceness.ts` — OS scheduling-priority (niceness) primitives for the `--nice` flag. `parseNiceValue(raw)` whole-string parses + range-validates to POSIX `[-20, 19]` (rejects `"3.5"`/`"10abc"` that `parseInt` would silently truncate). `applyNiceness(nice, setPriority?, getPriority?)` calls `os.setPriority(0, n)` and ALWAYS re-reads `os.getPriority(0)` afterwards — in both the success and the catch paths — so a denied renice (EPERM) or an `RLIMIT_NICE` clamp records the real effective value (e.g. `0`), not null; returns `{applied, requested, effective, error?}`. `getEffectiveNiceness(pid, getPriority?)` reads an arbitrary pid's niceness (null on dead/unreadable). `formatNice(n)` → `+10`/`0`/`-5`. Applied only at the CLI layer (jobs.ts) so worker.ts/supervisor.ts stay embeddable. Pinned by `test/niceness.test.ts`. -- `src/core/minions/worker-registry.ts` — live worker registry backing niceness observability. Each running `gbrain jobs work` self-registers `worker-.json` under `gbrainPath('workers')` (brain-isolated via `GBRAIN_HOME`; entries tagged with `currentBrainId()` so multiple DBs under one home don't cross-report). `registerWorker(info)` is best-effort (never blocks the worker) and returns a cleanup fn the caller wires to BOTH the shutdown `finally` AND `process.on('exit')` (the unhealthy `process.exit(1)` bypasses the awaited finally). `readWorkers(getNice?)` enumerates the dir, drops confirmed-dead pids (`classifyLiveness`: ESRCH = dead/prune, EPERM = alive/keep), applies a pid-reuse start-time guard (`ps -o lstart`, rejects a pid whose process started >5s after the entry was written), and re-measures each live worker's niceness now. Reports the worker's REAL pid, sidestepping the tini-wrapper-pid problem. Also exports `currentDbIdentity()` (#1849) — the RAW database identity (`database_url` or `database_path`, else `'default'`), read from config without a DB connect, used as the authoritative key for the supervisor's queue-scoped singleton lock (avoids the hash-collision question that `currentBrainId`'s djb2 would raise). Pinned by `test/worker-registry.test.ts`. +- `src/core/minions/worker-registry.ts` — live worker registry backing niceness observability. Each running `gbrain jobs work` self-registers `worker-.json` under `gbrainPath('workers')` (brain-isolated via `GBRAIN_HOME`; entries tagged with `currentBrainId()` so multiple DBs under one home don't cross-report). `registerWorker(info)` is best-effort (never blocks the worker) and returns a cleanup fn the caller wires to BOTH the shutdown `finally` AND `process.on('exit')` (the unhealthy `process.exit(1)` bypasses the awaited finally). `readWorkers(getNice?)` enumerates the dir, drops confirmed-dead pids (`classifyLiveness`: ESRCH = dead/prune, EPERM = alive/keep), applies a pid-reuse start-time guard (`ps -o lstart`, rejects a pid whose process started >5s after the entry was written), and re-measures each live worker's niceness now. Reports the worker's REAL pid, sidestepping the tini-wrapper-pid problem. Pinned by `test/worker-registry.test.ts`. - `src/core/minions/supervisor-pid.ts` — `readSupervisorPid(pidFile) → {pid, running}`: the shared `existsSync → readFileSync → parseInt → process.kill(pid,0)` PID-file + liveness reader extracted from the three copies in `jobs.ts` (supervisor status), `jobs.ts` (stats), and `doctor.ts`. EPERM from the liveness probe counts as running. Pinned by `test/supervisor-pid.test.ts`. - `src/core/minions/handler-timeouts.ts` (#1737) — per-handler default wall-clock budgets. `HANDLER_DEFAULT_TIMEOUT_MS` maps the long handlers (`subagent`, `subagent_aggregator`, `embed-backfill`, `autopilot-cycle`) to a 30-min default (the value `cycle/patterns.ts` already passed for subagents, generalized). `defaultTimeoutMsFor(jobName)` returns that default or `null` for short handlers (keep the tight null-default wall-clock). `MinionQueue.add()` stamps this onto `minion_jobs.timeout_ms` at submit time when the caller passed no `timeout_ms`, so a long job submitted without one isn't wall-clock-killed mid-progress; an explicit value always wins and already-queued jobs are NOT backfilled. Pinned by `test/minions.test.ts`. - `src/core/minions/types.ts` — `MinionJobInput` + `MinionJobStatus` + handler context types. `MinionJobInput.max_stalled` is optional; omitted values let the schema DEFAULT (5) kick in, provided values are clamped to `[1, 100]`. @@ -274,7 +274,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/commands/transcripts.ts` — `gbrain transcripts recent [--days N] [--full] [--json]`: recent raw `.txt` transcripts from the dream-cycle corpus dirs. Imports `listRecentTranscripts` from `src/core/transcripts.ts` (the same library the gated `get_recent_transcripts` MCP op uses). Local-only by construction — the CLI always runs with `ctx.remote=false`. - `src/commands/integrity.ts` — `gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). Batch-load fast path on Postgres uses a single SQL query (fixes the PgBouncer round-trip timeout, ~60s → ~6s), gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1`. Batch projection is `SELECT ... ORDER BY source_id, slug` (NOT `SELECT DISTINCT ON (slug)`, which collapsed same-slug-different-source pages into one scan) so multi-source brains scan each `(source, slug)` row independently. Sequential and auto-repair loops use `listAllPageRefs()` to enumerate `(slug, source_id)` pairs and thread `sourceId` to `getPage`; batch + sequential paths report the same page count on multi-source brains. - `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. Checks include `jsonb_integrity` + `markdown_body_completeness` (reliability), `schema_version` (fails loudly when `version=0`, routes to `gbrain apply-migrations --yes`), `queue_health` (Postgres-only: stalled-forever active jobs started_at > 1h, waiting-depth-per-name > threshold default 10 via `GBRAIN_QUEUE_WAITING_THRESHOLD`, and dead-lettered subagent jobs with `last_error` matching the `prompt_too_long` classifier in last 24h), `sync_failures` (`[CODE=N, ...]` breakdown for unacked-warn + acked-ok), `rls_event_trigger` (healthy `evtenabled` set is `('O','A')` only; fix hint `gbrain apply-migrations --force-retry 35`), `graph_coverage` (short-circuits to ok when `SELECT COUNT(*) FROM pages WHERE type IN ('entity','person','company','organization')` returns 0; WARN hint is `gbrain extract all`), `embedding_column_registry` (probes each declared column via Postgres `format_type(atttypid, atttypmod)` to catch dim mismatch with a paste-ready `gbrain config set embedding_columns '{...}'` hint, probes HNSW index presence via `pg_indexes`, computes default-column population via `COUNT(*) FILTER (WHERE IS NOT NULL) / COUNT(*)` warning below 90% except empty brains where chunk_count=0 short-circuits to ok; PGLite parity via `executeRaw`), and `skill_brain_first` (walks SKILL.md via `autoDetectSkillsDirReadOnly`, calls `analyzeSkillBrainFirst()` from `src/core/skill-brain-first.ts` per file with structured `Check.issues[]`; warn states `missing_brain_first`/`brain_first_typo`, ok states `compliant_callout`/`compliant_phase`/`compliant_position`/`exempt_frontmatter`/`no_external`; snapshot+diff audit at `~/.gbrain/audit/skill-brain-first-YYYY-Www.jsonl`). `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts via `src/core/dry-fix.ts` (and MISSING_RULE_PATTERNS for the brain-first callout); `--fix --dry-run` previews. `--index-audit` (Postgres-only, informational, no auto-drop) reports zero-scan indexes from `pg_stat_user_indexes`. Every DB check runs under a progress phase; `markdown_body_completeness` runs under a 1s heartbeat. `runDoctor` uses `autoDetectSkillsDirReadOnly` (from `src/core/repo-root.ts`; install-path fallback so `cd ~ && gbrain doctor` finds bundled skills); `--fix` carries a D6 install-path safety gate that refuses auto-repair when `detected.source === 'install_path'` (would rewrite the bundled tree). The Lane D supervisor check at `doctor.ts:1011-1043` consumes `summarizeCrashes(events)` from `src/core/minions/handlers/supervisor-audit.ts` (warn at `>=1` real crash; ok message has `clean_exits_24h=N`; warn message has `runtime=A oom=B unknown=C legacy=D` per-cause breakdown) so OOM/runtime/unknown crashes are distinguishable from clean code=0 worker drains; cross-surface parity with `gbrain jobs supervisor status` is pinned by source-grep wiring assertions requiring the breakdown substrings in BOTH `doctor.ts` and `jobs.ts`. `checkSyncFreshness` (exported, in `runDoctor` local + `doctorReportRemote` thin-client) is a staleness probe: warns at 24h, fails at 72h or never-synced; future-`last_sync_at` warns ("clock skew") instead of falling through ok; env overrides `GBRAIN_SYNC_FRESHNESS_WARN_HOURS`/`GBRAIN_SYNC_FRESHNESS_FAIL_HOURS` (invalid fall back with once-per-process stderr warn via `_resolveSyncFreshnessHours`); failure messages embed `source.id` so the printed `gbrain sync --source ` matches. It has a `localOnly`-gated git short-circuit (`runDoctor` passes `localOnly: true`; `doctorReportRemote` runs in the HTTP MCP server `src/commands/serve-http.ts` and keeps default `false` so that path never walks DB-supplied `local_path` via subprocess — trust boundary). The local predicate mirrors sync's "do work?" gate (HEAD == `last_commit` AND working tree clean via `requireCleanWorkingTree: 'ignore-untracked'` so a quiet repo with only untracked dirs is `unchanged` not SEVERE, AND `chunker_version === CURRENT`); the inline SELECT carries `last_commit + chunker_version + newest_content_at`. The REMOTE path computes lag via `lagFromContentMs(newest_content_at, lastSync, now)` from the stored column, NO git subprocess; LOCAL fall-through and the `< 0` clock-skew check stay on raw wall-clock. Three-bucket count math populates `Check.details = {unchanged_count, synced_recently_count, stale_count}` with the invariant `sum === sources.length`. `checkCycleFreshness` is DELIBERATELY NOT git-short-circuited or content-relativized (`last_commit == HEAD` can't answer "did the full cycle complete?"; a sync can succeed while later cycle phases fail; different axis `last_full_cycle_at`). Pinned by `test/doctor.test.ts` (incl. IRON-RULE regression banning stale verb names, the sync_freshness boundary matrix, the D4 regression guard verifying git probes are NEVER called when `localOnly` is unset/false, the three-bucket invariant, and the untracked-folders / remote-never-shells-out trust-boundary cases). -- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). `Migration` interface carries `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses in a transaction; ignored on PGLite). Key migrations: v14 (handler branches on `engine.kind` for CONCURRENTLY-on-Postgres with invalid-remnant pre-drop via `pg_index.indisvalid`, plain `CREATE INDEX` on PGLite); v15 (`minion_jobs.max_stalled` default 1→5 + backfill non-terminal rows); v24 `rls_backfill_missing_tables` (`sqlFor: { pglite: '' }` no-op — PGLite has no RLS engine, targets subagent tables absent from pglite-schema.ts); v30 `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))` (RLS-enabled under BYPASSRLS; synthesize reads/writes to avoid re-judging); v35 auto-RLS event trigger `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` running `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on new `public.*` tables (no FORCE) + one-time backfill on every existing `public.*` base table whose comment doesn't match `^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}` (per-table failure aborts the offending CREATE TABLE; no EXCEPTION wrap; PGLite no-op via `sqlFor.pglite: ''`; breaking change: intentionally-RLS-off public tables need the GBRAIN:RLS_EXEMPT comment before upgrade); v40 `pages_emotional_weight` (`pages.emotional_weight REAL NOT NULL DEFAULT 0.0`, column-only metadata-only); v46 `mcp_request_log_params_jsonb_normalize` (`UPDATE ... SET params = params::text::jsonb WHERE jsonb_typeof(params) = 'string'`, idempotent); v60-v65 six-migration chain wiring source-scoping into `oauth_clients` — v60 (`oauth_clients_source_id_fk`: `source_id TEXT` NULL→`'default'` backfill + FK to `sources(id) ON DELETE SET NULL`), v61 (`federated_read TEXT[] NOT NULL DEFAULT '{}'`), v62 (explicit-CASE backfill so `source_id IS NULL` → `'{}'`), v63 (fail-loud check every row's source_id is in its federated_read array), v64 (FK flipped to `ON DELETE RESTRICT`), v65 (GIN index for array-containment); v68 `eval_candidates_embedding_column` (`eval_candidates.embedding_column TEXT NULL` per-row provenance for `gbrain eval replay` to reproduce the same retrieval space; NULL-tolerant); v108 `pages_embedding_signature` (`pages.embedding_signature TEXT NULL` = `:` stamped via `setPageEmbeddingSignature`; GRANDFATHER — stale predicate is `embedding_signature IS NOT NULL AND embedding_signature <> $current` so NULL is NEVER stale and upgrade never re-embeds the whole corpus; no index; metadata-only); v109 `sources_newest_content_at` (`sources.newest_content_at TIMESTAMPTZ` durable newest-COMMIT HEAD committer time written by `writeSyncAnchor`, read by the REMOTE staleness path instead of shelling to git; mirror in pglite-schema.ts + schema.sql + bootstrap probe); v110 `page_aliases` (`(id, source_id, alias_norm, slug, ...)` with `UNIQUE (source_id, alias_norm, slug)` + lookup indexes on `(source_id, alias_norm)` and `(source_id, slug)`; `alias_norm` is `normalizeAlias()` output so WRITE/READ key on the same form; also in `src/core/pglite-schema.ts`); v111 `search_telemetry_rank1_columns` (`ADD COLUMN IF NOT EXISTS` on both engines: `sum_rank1_score`, `count_rank1`, three buckets `rank1_lt_solid`/`rank1_solid`/`rank1_high` on `search_telemetry` — aggregate not per-query rows so rank-1 median drift is bounded-growth; ALTERs right after v57 which created the table). +- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). `Migration` interface carries `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses in a transaction; ignored on PGLite). Key migrations: v14 (handler branches on `engine.kind` for CONCURRENTLY-on-Postgres with invalid-remnant pre-drop via `pg_index.indisvalid`, plain `CREATE INDEX` on PGLite); v15 (`minion_jobs.max_stalled` default 1→5 + backfill non-terminal rows); v24 `rls_backfill_missing_tables` (`sqlFor: { pglite: '' }` no-op — PGLite has no RLS engine, targets subagent tables absent from pglite-schema.ts); v30 `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))` (RLS-enabled under BYPASSRLS; synthesize reads/writes to avoid re-judging); v35 auto-RLS event trigger `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` running `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on new `public.*` tables (no FORCE) + one-time backfill on every existing `public.*` base table whose comment doesn't match `^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}` (per-table failure aborts the offending CREATE TABLE; no EXCEPTION wrap; PGLite no-op via `sqlFor.pglite: ''`; breaking change: intentionally-RLS-off public tables need the GBRAIN:RLS_EXEMPT comment before upgrade); v40 `pages_emotional_weight` (`pages.emotional_weight REAL NOT NULL DEFAULT 0.0`, column-only metadata-only); v46 `mcp_request_log_params_jsonb_normalize` (`UPDATE ... SET params = params::text::jsonb WHERE jsonb_typeof(params) = 'string'`, idempotent); v60-v65 six-migration chain wiring source-scoping into `oauth_clients` — v60 (`oauth_clients_source_id_fk`: `source_id TEXT` NULL→`'default'` backfill + FK to `sources(id) ON DELETE SET NULL`), v61 (`federated_read TEXT[] NOT NULL DEFAULT '{}'`), v62 (explicit-CASE backfill so `source_id IS NULL` → `'{}'`), v63 (fail-loud check every row's source_id is in its federated_read array), v64 (FK flipped to `ON DELETE RESTRICT`), v65 (GIN index for array-containment); v68 `eval_candidates_embedding_column` (`eval_candidates.embedding_column TEXT NULL` per-row provenance for `gbrain eval replay` to reproduce the same retrieval space; NULL-tolerant); v108 `pages_embedding_signature` (`pages.embedding_signature TEXT NULL` = `:` stamped via `setPageEmbeddingSignature`; GRANDFATHER — stale predicate is `embedding_signature IS NOT NULL AND embedding_signature <> $current` so NULL is NEVER stale and upgrade never re-embeds the whole corpus; no index; metadata-only); v109 `sources_newest_content_at` (`sources.newest_content_at TIMESTAMPTZ` durable newest-COMMIT HEAD committer time written by `writeSyncAnchor`, read by the REMOTE staleness path instead of shelling to git; mirror in pglite-schema.ts + schema.sql + bootstrap probe); v110 `page_aliases` (`(id, source_id, alias_norm, slug, ...)` with `UNIQUE (source_id, alias_norm, slug)` + lookup indexes on `(source_id, alias_norm)` and `(source_id, slug)`; `alias_norm` is `normalizeAlias()` output so WRITE/READ key on the same form; also in `src/core/pglite-schema.ts`); v111 `search_telemetry_rank1_columns` (`ADD COLUMN IF NOT EXISTS` on both engines: `sum_rank1_score`, `count_rank1`, three buckets `rank1_lt_solid`/`rank1_solid`/`rank1_high` on `search_telemetry` — aggregate not per-query rows so rank-1 median drift is bounded-growth; ALTERs right after v57 which created the table); v114 `links_link_source_check_kebab_regex` (#1941, opens `link_source` from the closed allowlist to a kebab-case format gate `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` + `char_length<=64`; Postgres branch uses `NOT VALID` + `VALIDATE CONSTRAINT` with `transaction:false`, PGLite plain DROP+ADD; existing built-ins all satisfy the regex so VALIDATE never fails on existing data). - `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY `\r`-rewriting; non-TTY plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. `emitHumanLine` is prefix-aware — inside a `withSourcePrefix(id, ...)` scope from `src/core/console-prefix.ts` it prepends `[id] ` (and TTY-rewrite mode `\r\x1b[2K` carries the prefix inside the clear-to-EOL escape); `emitJson` is intentionally NOT prefixed so NDJSON consumers don't choke on a `[id] {...}` shape. - `src/core/console-prefix.ts` — `AsyncLocalStorage`-backed per-source line-prefix helper. Exports `withSourcePrefix(id, fn)` (runs `fn` with `id` as active prefix; nested wraps replace then restore), `getSourcePrefix()` (read-only accessor; test seam), and `slog(...)` / `serr(...)` (prefix-aware `console.log`/`console.error`). Embedded-newline-safe: a multi-line string under prefix `[foo]` emits `[foo] line1\n[foo] line2`. Outside a wrap, `slog`/`serr` fall through to bare `console.log`/`console.error` so single-source callers see identical output (back-compat invariant). Use `src.id` (slug-validated by `sources add`) NOT `src.name` (free-form) to defeat log-injection through newline/control-character names. Coverage: `src/commands/sync.ts` performSync + callees, `src/commands/embed.ts` runEmbedCore + helpers, `src/core/progress.ts` emitHumanLine. - `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers. diff --git a/package.json b/package.json index 163ae563d..9fa1ccd32 100644 --- a/package.json +++ b/package.json @@ -143,5 +143,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.42.29.0" + "version": "0.42.31.0" } diff --git a/src/cli.ts b/src/cli.ts index 1f487b06f..09d187cea 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -94,6 +94,25 @@ const CLI_ONLY_SELF_HELP = new Set([ 'connect', ]); +// v114 (#1941): alias -> operation lookup, kept separate from `cliOps` so +// aliases don't double-list in printHelp's auto-generated section. Collisions +// with a primary CLI name, a CLI_ONLY command, or another alias throw at module +// load — a silent route-shadow is worse than a loud boot failure. Placed after +// CLI_ONLY so the collision check can see it. +export const cliAliases = new Map(); +for (const op of operations) { + if (op.cliHints?.hidden) continue; + for (const alias of op.cliHints?.aliases ?? []) { + if (cliOps.has(alias) || CLI_ONLY.has(alias) || cliAliases.has(alias)) { + throw new Error( + `CLI alias collision: '${alias}' (op '${op.name}') conflicts with an existing ` + + `command or alias. Rename the alias in src/core/operations.ts.`, + ); + } + cliAliases.set(alias, op); + } +} + // v0.42 self-upgrade: commands that must NOT trigger the startup update-check // (they ARE the update path, or are trivial/no-DB) and which set // GBRAIN_SKIP_STARTUP_HOOKS for any children they spawn. @@ -248,9 +267,9 @@ async function main() { // Per-command --help if (hasHelpFlag(subArgs)) { - const op = cliOps.get(command); + const op = cliOps.get(command) ?? cliAliases.get(command); if (op) { - printOpHelp(op); + printOpHelp(op, command); return; } if (CLI_ONLY.has(command) && !CLI_ONLY_SELF_HELP.has(command)) { @@ -265,8 +284,8 @@ async function main() { return; } - // Shared operations - const op = cliOps.get(command); + // Shared operations (fall through to aliases, e.g. link-add -> add_link) + const op = cliOps.get(command) ?? cliAliases.get(command); if (!op) { console.error(`Unknown command: ${command}`); console.error('Run gbrain --help for available commands.'); @@ -2058,9 +2077,11 @@ async function connectEngine(opts?: { probeOnly?: boolean }): Promise `<${p}>`).join(' '); - const name = op.cliHints?.name || op.name; + // v114 (#1941): when invoked via an alias (e.g. `gbrain link-add --help`), + // show the alias the user typed, not the primary op name. + const name = invokedName || op.cliHints?.name || op.name; console.log(`Usage: gbrain ${name} ${positional} [options]\n`); console.log(op.description + '\n'); const entries = Object.entries(op.params); @@ -2125,8 +2146,11 @@ EMBEDDINGS embed [|--all|--stale] Generate/refresh embeddings LINKS - link [--type T] Create typed link - unlink Remove link + link Create typed link (alias: link-add) + [--link-type T] [--link-source S] provenance defaults to 'manual' + unlink Remove link (alias: link-rm) + [--link-type T] [--link-source S] filter which edges to remove + link-sources List provenances in use, with edge counts backlinks Incoming links graph [--depth N] Traverse link graph (returns nodes) graph-query [--type T] Edge-based traversal with type/direction filters @@ -2222,7 +2246,12 @@ Run gbrain --help for command-specific help. `); } -main().catch(e => { - console.error(e.message || e); - process.exit(1); -}); +// Only auto-run when invoked as the entry point (the compiled binary or +// `bun src/cli.ts`). Guarded so tests can import cliAliases / printOpHelp +// without triggering argv parsing + main(). v114 (#1941). +if (import.meta.main) { + main().catch(e => { + console.error(e.message || e); + process.exit(1); + }); +} diff --git a/src/core/engine.ts b/src/core/engine.ts index a7e864faa..a75e30110 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -127,10 +127,16 @@ export interface LinkBatchInput { link_type?: string; context?: string; /** - * Provenance (v0.13+). Pass 'frontmatter' for edges derived from YAML - * frontmatter, 'markdown' for [Name](path) refs, 'manual' for user-created. - * NULL means "legacy / unknown" and is only used by pre-v0.13 rows; new - * writes should always set this. Missing on input defaults to 'markdown'. + * Provenance (v0.13+; opened to kebab tags in v114 / #1941). Any lowercase + * kebab-case value <=64 chars is DB-valid (CHECK `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`), + * so external derivers stamp their own tag (e.g. 'citation-graph'). The + * reconciliation-managed built-ins are 'markdown' ([Name](path) refs), + * 'frontmatter' (YAML-derived, see origin_*), 'mentions', 'wikilink-resolved'; + * 'manual' is for user/tool-created edges. NULL = legacy/unknown (pre-v0.13). + * Missing on this batch input defaults to 'markdown'. NOTE: the add_link OP + * (not this engine method) forbids callers from passing the four managed + * built-ins and defaults omitted to 'manual' — internal callers use the + * engine directly and keep writing the managed values. */ link_source?: string; /** For link_source='frontmatter': slug of the page whose frontmatter created this edge. */ @@ -1137,6 +1143,16 @@ export interface BrainEngine { * applied to the to-page side of the join. */ getBacklinks(slug: string, opts?: { sourceId?: string }): Promise; + /** + * v114 (#1941): distinct link_source provenances with edge counts, for + * `gbrain link-sources`. Source-scoped via `{sourceId?, sourceIds?}` (both + * forms, so federated `allowedSources` reads don't leak cross-source counts). + * Deterministic order `count DESC, link_source ASC NULLS LAST` for PG/PGLite + * parity. `link_source` may be NULL (legacy/unknown rows). + */ + listLinkSources( + opts?: { sourceId?: string; sourceIds?: string[] }, + ): Promise<{ link_source: string | null; count: number }[]>; /** * Fuzzy-match a display name to a page slug using pg_trgm similarity. * Zero embedding cost, zero LLM cost — designed for the v0.13 resolver used diff --git a/src/core/migrate.ts b/src/core/migrate.ts index c351aa965..929f8a368 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -5120,6 +5120,46 @@ export const MIGRATIONS: Migration[] = [ `, }, }, + { + version: 114, + name: 'links_link_source_check_kebab_regex', + // Issue #1941: open link_source from a closed allowlist to a kebab-case + // format gate so external derivers (e.g. 'citation-graph') stamp their own + // provenance without a per-deriver gbrain migration. Format: lowercase + // kebab `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` (rejects UPPER, leading digit/dash, + // trailing/double dash, underscore, space) + char_length <= 64 cap on the + // indexed free-text column. The five prior built-ins all satisfy the regex, + // so existing rows pass `VALIDATE` and the constraint swap never fails. + // + // DELIBERATELY diverges from the v95/v113 plain DROP+ADD pattern: on real + // Postgres a plain `ADD CONSTRAINT ... CHECK` takes ACCESS EXCLUSIVE + a + // full-table validation scan, which can stall writes on a large `links` + // table. The postgres branch instead does `ADD ... NOT VALID` (instant, + // no scan) then `VALIDATE CONSTRAINT` (scans under SHARE UPDATE EXCLUSIVE, + // does not block reads/writes). That two-phase form requires running + // OUTSIDE a transaction → `transaction: false`. PGLite (single-writer WASM, + // no lock concern) keeps the plain one-shot DROP+ADD, and is the branch the + // schema-version hash reads (pglite-engine.ts). + // + // Idempotent via DROP ... IF EXISTS; no-ops on installs that never created + // the constraint and safe to re-run. + idempotent: true, + transaction: false, + sql: '', // engine-specific via sqlFor (postgres two-phase vs pglite one-shot) + sqlFor: { + postgres: ` + ALTER TABLE links DROP CONSTRAINT IF EXISTS links_link_source_check; + ALTER TABLE links ADD CONSTRAINT links_link_source_check + CHECK (link_source IS NULL OR (link_source ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' AND char_length(link_source) <= 64)) NOT VALID; + ALTER TABLE links VALIDATE CONSTRAINT links_link_source_check; + `, + pglite: ` + ALTER TABLE links DROP CONSTRAINT IF EXISTS links_link_source_check; + ALTER TABLE links ADD CONSTRAINT links_link_source_check + CHECK (link_source IS NULL OR (link_source ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' AND char_length(link_source) <= 64)); + `, + }, + }, ]; export const LATEST_VERSION = MIGRATIONS.length > 0 diff --git a/src/core/minions/supervisor.ts b/src/core/minions/supervisor.ts index 8a8505fe9..3853a6793 100644 --- a/src/core/minions/supervisor.ts +++ b/src/core/minions/supervisor.ts @@ -44,7 +44,7 @@ import { import { dirname } from 'path'; import type { BrainEngine } from '../engine.ts'; import { tryAcquireDbLock, type DbLockHandle } from '../db-lock.ts'; -import { currentDbIdentity, currentBrainId } from './worker-registry.ts'; +import { currentBrainId } from './worker-registry.ts'; export type SupervisorEvent = | 'started' @@ -298,14 +298,20 @@ const SUPERVISOR_LOCK_REFRESH_MS = 60_000; const SUPERVISOR_LOCK_REFRESH_MAX_FAILURES = 3; // 3 × 60s = 180s < 5min TTL /** - * #1849: the queue-scoped supervisor singleton lock id. Keyed on the raw DB - * identity (T2) + queue so the mutex domain is the (database, queue) pair — - * not the pidfile path. Exported so `gbrain doctor` queries the same row to - * surface the holder + effective --max-rss. Pass an explicit dbIdentity - * (defaults to `currentDbIdentity()`, which reads config without a DB connect). + * #1849: the queue-scoped supervisor singleton lock id. Keyed ONLY on the + * queue, because the lock ROW lives inside the target database — the (database) + * half of the mutex domain is physical, not part of the key. Keying on a + * config-derived DB identity (the prior `currentDbIdentity()`) was a bug: two + * supervisors pointed at the SAME physical database via different-but-equivalent + * URLs/config paths (pooler vs direct port, host alias, trailing params) hashed + * to different ids and BOTH acquired the "singleton" lock in the one shared + * locks table. Queue-only keying makes same-DB + same-queue collide correctly, + * while different physical databases never collide (separate locks tables). + * Exported so `gbrain doctor` queries the same row to surface the holder + + * effective --max-rss. */ -export function supervisorLockId(queue: string, dbIdentity: string = currentDbIdentity()): string { - return `gbrain-supervisor:${dbIdentity}:${queue}`; +export function supervisorLockId(queue: string): string { + return `gbrain-supervisor:${queue}`; } /** @@ -495,24 +501,13 @@ export class MinionSupervisor { process.exit(ExitCodes.PID_UNWRITABLE); } - // 1b. #1849: queue-scoped DB singleton lock — the REAL authority. A second - // supervisor with a different $HOME / --pid-file passes the pidfile check - // above but loses here, so it can't run a conflicting --max-rss worker on - // the same (db, queue). Keyed on the raw DB identity (not the lossy - // currentBrainId hash) per T2. - this.dbLock = await tryAcquireDbLock(this.engine, this.supervisorLockId(), SUPERVISOR_LOCK_TTL_MIN); - if (!this.dbLock) { - console.error( - `Supervisor already running for queue '${this.opts.queue}' on this database ` + - `(another supervisor holds the queue lock, regardless of pidfile path). Exiting.`, - ); - process.exit(ExitCodes.LOCK_HELD); - } - // Refresh the lock on its own timer (independent of healthInterval, which - // can be 0/disabled) so the TTL never lapses while we're alive. - this.lockRefreshTimer = setInterval(() => { void this.refreshDbLock(); }, SUPERVISOR_LOCK_REFRESH_MS); - // 2. Cleanup on process exit (covers any exit path including process.exit). + // Installed BEFORE the DB-lock acquisition below: acquirePidLock just + // wrote OUR pid into the pidfile, so any early `process.exit` after this + // point (notably the LOCK_HELD path in 1b) MUST clean it up or it leaves + // a stale pidfile that blocks the next start on this path. The listener + // only unlinks when the file still holds our pid, so it's a no-op on the + // 'held'/'unwritable' paths above (those never created our pidfile). this.exitListener = () => { try { if (existsSync(this.opts.pidFile)) { @@ -525,6 +520,24 @@ export class MinionSupervisor { }; process.on('exit', this.exitListener); + // 1b. #1849: queue-scoped DB singleton lock — the REAL authority. A second + // supervisor with a different $HOME / --pid-file passes the pidfile check + // above but loses here, so it can't run a conflicting --max-rss worker on + // the same (db, queue). Keyed on the queue alone; the database half of the + // mutex is physical (the lock row lives in this DB). + this.dbLock = await tryAcquireDbLock(this.engine, this.supervisorLockId(), SUPERVISOR_LOCK_TTL_MIN); + if (!this.dbLock) { + console.error( + `Supervisor already running for queue '${this.opts.queue}' on this database ` + + `(another supervisor holds the queue lock, regardless of pidfile path). Exiting.`, + ); + // The exit listener installed above removes the pidfile we just created. + process.exit(ExitCodes.LOCK_HELD); + } + // Refresh the lock on its own timer (independent of healthInterval, which + // can be 0/disabled) so the TTL never lapses while we're alive. + this.lockRefreshTimer = setInterval(() => { void this.refreshDbLock(); }, SUPERVISOR_LOCK_REFRESH_MS); + // 3. Signal handlers (tracked refs; removed on shutdown for test lifecycle hygiene). this.sigtermListener = () => { void this.shutdown('SIGTERM', ExitCodes.CLEAN); }; this.sigintListener = () => { void this.shutdown('SIGINT', ExitCodes.CLEAN); }; diff --git a/src/core/minions/tools/brain-allowlist.ts b/src/core/minions/tools/brain-allowlist.ts index f01dfe595..70beaad46 100644 --- a/src/core/minions/tools/brain-allowlist.ts +++ b/src/core/minions/tools/brain-allowlist.ts @@ -54,6 +54,10 @@ export const BRAIN_TOOL_ALLOWLIST: ReadonlySet = new Set([ 'file_url', 'get_backlinks', 'traverse_graph', + // v114 (#1941): read-only provenance discovery. Edge-WRITE ops (add_link / + // remove_link) are deliberately NOT allowlisted — exposing graph writes to + // subagents is a separate trust decision. + 'list_link_sources', 'resolve_slugs', 'get_ingest_log', 'put_page', @@ -89,6 +93,7 @@ export const BRAIN_TOOL_USAGE_HINTS: Readonly> = { file_url: 'Get a presigned URL for a brain-stored file. Read-only; expires.', get_backlinks: 'List every page that links TO the given slug. Use for "what references this".', traverse_graph: 'Walk the typed-edge graph starting from a slug (e.g. `works_at`, `founded`, `invested_in`). Use for relationship queries.', + list_link_sources: 'List the distinct link provenances in the brain with edge counts (e.g. `citation-graph`, `manual`). Use to discover which edge-writers have populated the graph.', resolve_slugs: 'Resolve free-form entity names to canonical slugs (e.g. "Alice" → `people/alice-example`). Use before any tool that takes a slug if the user gave a name not a slug.', get_ingest_log: 'Read the brain ingestion log for diagnostic / verification queries.', put_page: 'Write a markdown page to the gbrain DATABASE (NOT the local filesystem). Page becomes searchable + linkable. Slug must match the agent\'s allowed namespace.', diff --git a/src/core/minions/worker-registry.ts b/src/core/minions/worker-registry.ts index c366da323..03ccaaea1 100644 --- a/src/core/minions/worker-registry.ts +++ b/src/core/minions/worker-registry.ts @@ -75,24 +75,6 @@ export function currentBrainId(): string { } } -/** - * The RAW database identity string (url or path), unhashed. Used as the - * authoritative key for the #1849 queue-scoped supervisor singleton DB lock: - * the protected resource is the (database, queue) pair, so the lock id must - * key on the real DB identity, not the lossy djb2 of {@link currentBrainId} - * (T2 — removes any hash-collision question). Reads config only (no DB - * connection), so it's safe to call before the engine connects. Falls back - * to 'default' so two unconfigured brains under one HOME still serialize. - */ -export function currentDbIdentity(): string { - try { - const cfg = loadConfig(); - return cfg?.database_url ?? cfg?.database_path ?? 'default'; - } catch { - return 'default'; - } -} - function entryPath(pid: number): string { return join(workerRegistryDir(), `worker-${pid}.json`); } diff --git a/src/core/operations.ts b/src/core/operations.ts index 72bbeb93b..56b0eb801 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -498,6 +498,12 @@ export interface Operation { localOnly?: boolean; cliHints?: { name?: string; + /** + * Alternate CLI command names that dispatch to this same op (v114 / #1941). + * e.g. `link-add` aliasing `link`. Registered in `cli.ts`'s `cliAliases` + * map; collisions with primary names or CLI_ONLY commands throw at startup. + */ + aliases?: string[]; positional?: string[]; stdin?: string; hidden?: boolean; @@ -1793,6 +1799,18 @@ const get_tags: Operation = { // --- Links --- +/** + * v114 (#1941): reconciliation-managed provenances a CALLER must not forge via + * the add_link op. Internal writers (import-file frontmatter reconciliation, + * extract --by-mention, wikilink resolution) write these straight through the + * engine — they're excluded here, not at the DB CHECK. A hand-created edge + * tagged 'frontmatter' with no origin_page_id would be a phantom that put_page + * reconciliation (link_source='frontmatter' AND origin_page_id=written_page) + * never cleans (see src/schema.sql). `manual` is intentionally absent — it IS + * the user-facing provenance and the default for omitted link_source. + */ +export const MANAGED_LINK_SOURCES = ['markdown', 'frontmatter', 'mentions', 'wikilink-resolved']; + const add_link: Operation = { name: 'add_link', description: 'Create link between pages', @@ -1801,11 +1819,22 @@ const add_link: Operation = { to: { type: 'string', required: true }, link_type: { type: 'string', description: 'Link type (e.g., invested_in, works_at)' }, context: { type: 'string', description: 'Context for the link' }, + link_source: { type: 'string', description: "Provenance tag (kebab-case, e.g. 'citation-graph'). Defaults to 'manual'. Reconciliation-managed built-ins (markdown/frontmatter/mentions/wikilink-resolved) are rejected." }, }, mutating: true, scope: 'write', handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to }; + // v114 (#1941): default omitted provenance to 'manual' (NOT the engine's + // 'markdown' default) so hand/tool-created CLI edges are honestly manual, + // and forbid forging the reconciliation-managed built-ins. + const linkSource = ((p.link_source as string) || 'manual').trim(); + if (MANAGED_LINK_SOURCES.includes(linkSource)) { + throw new Error( + `link_source '${linkSource}' is reconciliation-managed and cannot be set manually; ` + + `use 'manual' (the default) or a custom kebab tag like 'citation-graph'`, + ); + } // v0.31.8 (D7): single ctx.sourceId scopes both endpoints + origin. Cross- // source link creation is out of scope for this wave; use the engine API // directly for that edge case. @@ -1815,12 +1844,12 @@ const add_link: Operation = { await ctx.engine.addLink( // gbrain-allow-direct-insert: add_link MCP op is the explicit canonical surface for manual link creation; auto-link reconciliation runs separately via auto_link post-hook p.from as string, p.to as string, (p.context as string) || '', (p.link_type as string) || '', - undefined, undefined, undefined, + linkSource, undefined, undefined, linkOpts, ); return { status: 'ok' }; }, - cliHints: { name: 'link', positional: ['from', 'to'] }, + cliHints: { name: 'link', aliases: ['link-add'], positional: ['from', 'to'] }, }; const remove_link: Operation = { @@ -1829,6 +1858,8 @@ const remove_link: Operation = { params: { from: { type: 'string', required: true }, to: { type: 'string', required: true }, + link_type: { type: 'string', description: 'Only remove edges of this link type (omit = all types)' }, + link_source: { type: 'string', description: 'Only remove edges of this provenance (e.g. citation-graph); omit = any provenance' }, }, mutating: true, scope: 'write', @@ -1837,10 +1868,15 @@ const remove_link: Operation = { const linkOpts = ctx.sourceId ? { fromSourceId: ctx.sourceId, toSourceId: ctx.sourceId } : undefined; - await ctx.engine.removeLink(p.from as string, p.to as string, undefined, undefined, linkOpts); + await ctx.engine.removeLink( + p.from as string, p.to as string, + (p.link_type as string) || undefined, + (p.link_source as string) || undefined, + linkOpts, + ); return { status: 'ok' }; }, - cliHints: { name: 'unlink', positional: ['from', 'to'] }, + cliHints: { name: 'unlink', aliases: ['link-rm'], positional: ['from', 'to'] }, }; const get_links: Operation = { @@ -1872,6 +1908,22 @@ const get_backlinks: Operation = { cliHints: { name: 'backlinks', positional: ['slug'] }, }; +const list_link_sources: Operation = { + name: 'list_link_sources', + // v114 (#1941): the read-side counterpart to link-add/link-rm. Since + // link_source is now an open kebab provenance (no allowlist), this is how an + // agent discovers which provenances a brain actually carries. + description: 'List distinct link_source provenances in the brain with edge counts (e.g. citation-graph, manual, markdown)', + params: {}, + handler: async (ctx) => { + // Route through sourceScopeOpts so the read honors both scalar ctx.sourceId + // and federated ctx.auth.allowedSources (no cross-source provenance leak). + return ctx.engine.listLinkSources(sourceScopeOpts(ctx)); + }, + scope: 'read', + cliHints: { name: 'link-sources' }, +}; + /** * Hard cap on traverse_graph depth from MCP callers. Each recursive CTE iteration * grows a `visited` array per path; in `direction=both` the join is `OR`-based and @@ -4677,7 +4729,7 @@ export const operations: Operation[] = [ // Tags add_tag, remove_tag, get_tags, // Links - add_link, remove_link, get_links, get_backlinks, traverse_graph, + add_link, remove_link, get_links, get_backlinks, list_link_sources, traverse_graph, // Timeline add_timeline_entry, get_timeline, // Admin diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index cfb1e70c3..9df57522a 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -2576,6 +2576,32 @@ export class PGLiteEngine implements BrainEngine { return rows as unknown as Link[]; } + async listLinkSources( + opts?: { sourceId?: string; sourceIds?: string[] }, + ): Promise<{ link_source: string | null; count: number }[]> { + // v114 (#1941): distinct provenances + counts for `gbrain link-sources`. + // Scope by the FROM page's source (consistent with getLinks). Federated + // {sourceIds} takes precedence over scalar {sourceId}; neither = unscoped. + const params: unknown[] = []; + let where = ''; + if (opts?.sourceIds && opts.sourceIds.length > 0) { + params.push(opts.sourceIds); + where = `JOIN pages f ON f.id = l.from_page_id WHERE f.source_id = ANY($${params.length}::text[])`; + } else if (opts?.sourceId) { + params.push(opts.sourceId); + where = `JOIN pages f ON f.id = l.from_page_id WHERE f.source_id = $${params.length}`; + } + const { rows } = await this.db.query( + `SELECT l.link_source, COUNT(*)::int AS count + FROM links l + ${where} + GROUP BY l.link_source + ORDER BY count DESC, l.link_source ASC NULLS LAST`, + params, + ); + return rows as unknown as { link_source: string | null; count: number }[]; + } + async findByTitleFuzzy( name: string, dirPrefix?: string, diff --git a/src/core/pglite-schema.ts b/src/core/pglite-schema.ts index 39e2c7a28..80fb0a973 100644 --- a/src/core/pglite-schema.ts +++ b/src/core/pglite-schema.ts @@ -255,12 +255,11 @@ CREATE TABLE IF NOT EXISTS links ( to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, link_type TEXT NOT NULL DEFAULT '', context TEXT NOT NULL DEFAULT '', - -- 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. See src/schema.sql for full rationale. - link_source TEXT CHECK (link_source IS NULL OR link_source IN ('markdown', 'frontmatter', 'manual', 'mentions', 'wikilink-resolved')), + -- v114 (#1941): open kebab-case provenance (not a closed allowlist) so + -- external derivers stamp their own tag. Format gate only: lowercase kebab, + -- <=64 chars. Managed built-ins still satisfy this; see src/schema.sql for + -- full rationale + the add_link op-layer guard against forging them. + 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 (codex finding #12): nullable link_kind distinguishes -- "plain body mention" from "verb-pattern-derived typed link" within -- link_source='mentions'. See src/schema.sql for full rationale. diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 99ebfbf85..7066c6934 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -2663,6 +2663,30 @@ export class PostgresEngine implements BrainEngine { return rows as unknown as Link[]; } + async listLinkSources( + opts?: { sourceId?: string; sourceIds?: string[] }, + ): Promise<{ link_source: string | null; count: number }[]> { + const sql = this.sql; + // v114 (#1941): distinct provenances + counts for `gbrain link-sources`. + // Scope by the FROM page's source (consistent with getLinks). Federated + // {sourceIds} takes precedence over scalar {sourceId}; neither = unscoped. + const sourceCondition = + opts?.sourceIds && opts.sourceIds.length > 0 + ? sql`WHERE f.source_id = ANY(${opts.sourceIds}::text[])` + : opts?.sourceId + ? sql`WHERE f.source_id = ${opts.sourceId}` + : sql``; + const rows = await sql` + SELECT l.link_source, COUNT(*)::int AS count + FROM links l + JOIN pages f ON f.id = l.from_page_id + ${sourceCondition} + GROUP BY l.link_source + ORDER BY count DESC, l.link_source ASC NULLS LAST + `; + return rows as unknown as { link_source: string | null; count: number }[]; + } + async findByTitleFuzzy( name: string, dirPrefix?: string, diff --git a/src/core/schema-embedded.ts b/src/core/schema-embedded.ts index b3be23bcc..a4c291fd4 100644 --- a/src/core/schema-embedded.ts +++ b/src/core/schema-embedded.ts @@ -431,12 +431,14 @@ CREATE TABLE IF NOT EXISTS links ( to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, link_type TEXT NOT NULL DEFAULT '', context TEXT NOT NULL DEFAULT '', - -- 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 IN ('markdown', 'frontmatter', 'manual', 'mentions', 'wikilink-resolved')), + -- 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: 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 diff --git a/src/schema.sql b/src/schema.sql index 4facd491c..38d2c2f55 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -407,12 +407,24 @@ CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to -- ============================================================ -- 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) +-- '' — 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') @@ -420,7 +432,9 @@ 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, @@ -432,7 +446,7 @@ CREATE TABLE IF NOT EXISTS links ( -- 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 IN ('markdown', 'frontmatter', 'manual', 'mentions', 'wikilink-resolved')), + 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 diff --git a/test/brain-allowlist.serial.test.ts b/test/brain-allowlist.serial.test.ts index cb67c40a3..7ed4bcb84 100644 --- a/test/brain-allowlist.serial.test.ts +++ b/test/brain-allowlist.serial.test.ts @@ -44,11 +44,13 @@ describe('BRAIN_TOOL_ALLOWLIST', () => { expect(missing).toEqual([]); }); - test('contains the v0.15 read-only 10 + put_page + v0.29 salience pair', () => { + test('contains the v0.15 read-only 10 + put_page + v0.29 salience pair + v114 list_link_sources', () => { // v0.29 added get_recent_salience + find_anomalies (read-only). // get_recent_transcripts is deliberately excluded — subagent calls always // have ctx.remote=true, and the v0.29 trust gate rejects remote callers. - expect(BRAIN_TOOL_ALLOWLIST.size).toBe(13); + // v114 (#1941) added list_link_sources (read-only provenance discovery); + // the edge-WRITE ops add_link/remove_link stay out (separate trust call). + expect(BRAIN_TOOL_ALLOWLIST.size).toBe(14); expect(BRAIN_TOOL_ALLOWLIST.has('query')).toBe(true); expect(BRAIN_TOOL_ALLOWLIST.has('search')).toBe(true); expect(BRAIN_TOOL_ALLOWLIST.has('get_page')).toBe(true); @@ -56,6 +58,9 @@ describe('BRAIN_TOOL_ALLOWLIST', () => { expect(BRAIN_TOOL_ALLOWLIST.has('put_page')).toBe(true); expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_salience')).toBe(true); expect(BRAIN_TOOL_ALLOWLIST.has('find_anomalies')).toBe(true); + expect(BRAIN_TOOL_ALLOWLIST.has('list_link_sources')).toBe(true); + expect(BRAIN_TOOL_ALLOWLIST.has('add_link')).toBe(false); + expect(BRAIN_TOOL_ALLOWLIST.has('remove_link')).toBe(false); expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_transcripts')).toBe(false); }); diff --git a/test/e2e/engine-parity.test.ts b/test/e2e/engine-parity.test.ts index 76a3e891e..cce3e3ecc 100644 --- a/test/e2e/engine-parity.test.ts +++ b/test/e2e/engine-parity.test.ts @@ -339,6 +339,31 @@ describeBoth('Engine parity — Postgres vs PGLite', () => { } }); + test('v114 (#1941) listLinkSources parity: same ordered provenance counts on both engines', async () => { + const mk = async (eng: BrainEngine) => { + for (const s of ['lsp-a', 'lsp-b', 'lsp-c']) { + await eng.putPage(s, { type: 'note', title: s, compiled_truth: 'b', timeline: '' }); + } + // citation-graph:2, manual:1 — exercises count DESC + the kebab regex. + await eng.addLink('lsp-a', 'lsp-b', '', 'cites', 'citation-graph'); + await eng.addLink('lsp-a', 'lsp-c', '', 'cites', 'citation-graph'); + await eng.addLink('lsp-b', 'lsp-c', '', 'rel', 'manual'); + }; + await mk(pgEngine); + await mk(pgliteEngine); + + const pg = await pgEngine.listLinkSources({ sourceId: 'default' }); + const pglite = await pgliteEngine.listLinkSources({ sourceId: 'default' }); + + const norm = (rows: { link_source: string | null; count: number }[]) => + rows.filter(r => r.link_source === 'citation-graph' || r.link_source === 'manual'); + expect(norm(pg)).toEqual(norm(pglite)); + // citation-graph (2) must order before manual (1) on both engines. + const cgIdx = pg.findIndex(r => r.link_source === 'citation-graph'); + const mIdx = pg.findIndex(r => r.link_source === 'manual'); + expect(cgIdx).toBeLessThan(mIdx); + }); + test('v0.41.19.0 resolveSlugsByPaths parity: same Map on both engines', async () => { const seedSql = ` INSERT INTO pages (source_id, slug, source_path, type, title, compiled_truth, timeline, frontmatter) diff --git a/test/link-source-namespaced-regex.test.ts b/test/link-source-namespaced-regex.test.ts new file mode 100644 index 000000000..81f5bb9cd --- /dev/null +++ b/test/link-source-namespaced-regex.test.ts @@ -0,0 +1,306 @@ +/** + * v114 (#1941): link_source opened from a closed allowlist to a kebab-case + * regex + length cap, provenance exposed on the link ops with a managed- + * built-in guard, a new `list_link_sources` read op, and `link-add`/`link-rm` + * CLI aliases. + * + * Coverage: + * - Migration v114 shape (two engine branches, transaction:false, idempotent) + * - DB CHECK boundary: kebab accepted (incl. all 5 built-ins), garbage rejected + * - Upgrade-path: existing built-in row survives the constraint swap (re-run) + * - add_link op: provenance threaded, default 'manual', managed-built-in guard + * - remove_link op: link_type / link_source / both filters + * - list_link_sources op: counts, deterministic order, scalar + federated scope + * - CLI aliases resolve; printOpHelp shows the invoked alias name; no collisions + * + * Hermetic via PGLite. No DATABASE_URL needed. + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { MIGRATIONS, LATEST_VERSION } from '../src/core/migrate.ts'; +import { + operations, + operationsByName, + MANAGED_LINK_SOURCES, +} from '../src/core/operations.ts'; +import type { OperationContext } from '../src/core/operations.ts'; +import { cliAliases, printOpHelp } from '../src/cli.ts'; + +const V = 114; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}, 60_000); + +afterAll(async () => { + await engine.disconnect(); +}); + +function makeCtx(overrides: Partial = {}): OperationContext { + return { + engine, + remote: false, + config: {}, + logger: console, + dryRun: false, + ...overrides, + } as unknown as OperationContext; +} + +// Two pages every link test reuses. +async function seedPages() { + await engine.putPage('lsrc-a', { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} }); + await engine.putPage('lsrc-b', { type: 'note', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} }); +} + +describe('migration v114 — links_link_source_check_kebab_regex', () => { + test('registered with expected version + name', () => { + const m = MIGRATIONS.find(m => m.version === V); + expect(m).toBeDefined(); + expect(m!.name).toBe('links_link_source_check_kebab_regex'); + }); + + test('LATEST_VERSION >= 114', () => { + expect(LATEST_VERSION).toBeGreaterThanOrEqual(V); + }); + + test('both engine branches present; transaction:false; idempotent', () => { + const m = MIGRATIONS.find(m => m.version === V)!; + expect(m.transaction).toBe(false); + expect(m.idempotent).toBe(true); + expect(m.sqlFor?.postgres).toBeTruthy(); + expect(m.sqlFor?.pglite).toBeTruthy(); + }); + + test('postgres branch uses NOT VALID + VALIDATE (lock-friendly)', () => { + const m = MIGRATIONS.find(m => m.version === V)!; + const pg = m.sqlFor!.postgres!; + expect(pg).toMatch(/DROP CONSTRAINT IF EXISTS links_link_source_check/i); + expect(pg).toContain('NOT VALID'); + expect(pg).toMatch(/VALIDATE CONSTRAINT links_link_source_check/i); + expect(pg).toContain("'^[a-z][a-z0-9]*(-[a-z0-9]+)*$'"); + expect(pg).toContain('char_length(link_source) <= 64'); + }); + + test('pglite branch is plain DROP+ADD with the same regex', () => { + const m = MIGRATIONS.find(m => m.version === V)!; + const pl = m.sqlFor!.pglite!; + expect(pl).toMatch(/DROP CONSTRAINT IF EXISTS links_link_source_check/i); + expect(pl).not.toContain('NOT VALID'); + expect(pl).toContain("'^[a-z][a-z0-9]*(-[a-z0-9]+)*$'"); + }); +}); + +describe('DB CHECK — kebab format gate', () => { + beforeAll(seedPages); + + const ACCEPTED = [ + 'citation-graph', 'derived', 'a', + // OV7 — all 5 reconciliation-managed built-ins must stay DB-valid + 'markdown', 'frontmatter', 'manual', 'mentions', 'wikilink-resolved', + ]; + const REJECTED: Array<[string, string]> = [ + ['UPPER', 'uppercase'], + ['has_underscore', 'underscore'], + ['has space', 'space'], + ['2bad', 'leading digit'], + ['-lead', 'leading dash'], + ['trail-', 'trailing dash'], + ['a--b', 'double dash'], + ['', 'empty'], + ['a'.repeat(65), '65 chars (over cap)'], + ]; + + for (const v of ACCEPTED) { + test(`accepts '${v}'`, async () => { + // engine.addLink writes link_source straight through (no op guard) — this + // is the DB CHECK under test, not the op layer. + await expect( + engine.addLink('lsrc-a', 'lsrc-b', '', `t-${v}`, v), + ).resolves.toBeUndefined(); + }); + } + + for (const [v, why] of REJECTED) { + test(`rejects '${v}' (${why})`, async () => { + await expect( + engine.addLink('lsrc-a', 'lsrc-b', '', `t-rej-${why}`, v), + ).rejects.toThrow(); + }); + } +}); + +describe('upgrade-path — existing rows survive the constraint swap', () => { + test('built-in row present, re-running v114 succeeds, new kebab tag inserts', async () => { + await seedPages(); + // A row tagged with a pre-existing built-in value (would have been valid + // under the old allowlist too). + await engine.addLink('lsrc-a', 'lsrc-b', '', 't-upgrade', 'markdown'); + const m = MIGRATIONS.find(m => m.version === V)!; + // Re-apply the migration against the table that now holds data: ADD/VALIDATE + // must not fail on the existing row. + await expect(engine.runMigration(V, m.sqlFor!.pglite!)).resolves.toBeUndefined(); + // And a brand-new external provenance still inserts afterward. + await expect( + engine.addLink('lsrc-a', 'lsrc-b', '', 't-upgrade', 'citation-graph'), + ).resolves.toBeUndefined(); + }); +}); + +describe('add_link op — provenance + managed-built-in guard (OV4A)', () => { + beforeAll(seedPages); + + test('threads custom provenance through to the row', async () => { + const op = operationsByName['add_link']; + await op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'cites', link_source: 'citation-graph' }); + const links = await engine.getLinks('lsrc-a'); + expect(links.some(l => (l as any).to_slug === 'lsrc-b' && (l as any).link_source === 'citation-graph' && (l as any).link_type === 'cites')).toBe(true); + }); + + test("omitted link_source defaults to 'manual' (not 'markdown')", async () => { + const op = operationsByName['add_link']; + await op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'default-prov' }); + const links = await engine.getLinks('lsrc-a'); + const row = links.find(l => (l as any).link_type === 'default-prov'); + expect((row as any)?.link_source).toBe('manual'); + }); + + for (const managed of MANAGED_LINK_SOURCES) { + test(`rejects managed built-in '${managed}'`, async () => { + const op = operationsByName['add_link']; + await expect( + op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'g', link_source: managed }), + ).rejects.toThrow(/reconciliation-managed/); + }); + } + + test("'manual' is allowed (not in the managed set)", async () => { + const op = operationsByName['add_link']; + await expect( + op.handler(makeCtx(), { from: 'lsrc-a', to: 'lsrc-b', link_type: 'man', link_source: 'manual' }), + ).resolves.toMatchObject({ status: 'ok' }); + }); +}); + +describe('remove_link op — type/source filters', () => { + async function seedTwoProvenances() { + await engine.putPage('rm-a', { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} }); + await engine.putPage('rm-b', { type: 'note', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} }); + await engine.addLink('rm-a', 'rm-b', '', 'cites', 'manual'); + await engine.addLink('rm-a', 'rm-b', '', 'cites', 'citation-graph'); + } + + test('--link-source filter deletes only that provenance', async () => { + await seedTwoProvenances(); + const op = operationsByName['remove_link']; + await op.handler(makeCtx(), { from: 'rm-a', to: 'rm-b', link_source: 'citation-graph' }); + const links = await engine.getLinks('rm-a'); + const sources = links.filter(l => (l as any).to_slug === 'rm-b').map(l => (l as any).link_source); + expect(sources).toContain('manual'); + expect(sources).not.toContain('citation-graph'); + }); + + test('--link-type-only filter deletes every provenance of that type', async () => { + await seedTwoProvenances(); + const op = operationsByName['remove_link']; + await op.handler(makeCtx(), { from: 'rm-a', to: 'rm-b', link_type: 'cites' }); + const links = await engine.getLinks('rm-a'); + expect(links.filter(l => (l as any).to_slug === 'rm-b' && (l as any).link_type === 'cites').length).toBe(0); + }); + + test('both filters delete only the type+source match', async () => { + await seedTwoProvenances(); + const op = operationsByName['remove_link']; + await op.handler(makeCtx(), { from: 'rm-a', to: 'rm-b', link_type: 'cites', link_source: 'manual' }); + const links = await engine.getLinks('rm-a'); + const sources = links.filter(l => (l as any).to_slug === 'rm-b').map(l => (l as any).link_source); + expect(sources).toContain('citation-graph'); + expect(sources).not.toContain('manual'); + }); +}); + +describe('list_link_sources op (B4 + OV8 + OV9)', () => { + test('op declaration: read scope, link-sources CLI name, not localOnly', () => { + const op = operationsByName['list_link_sources']; + expect(op).toBeDefined(); + expect(op.scope).toBe('read'); + expect(op.localOnly).not.toBe(true); + expect(op.cliHints?.name).toBe('link-sources'); + }); + + test('returns distinct provenances with counts, ordered count DESC then name ASC', async () => { + await engine.putPage('ls-a', { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} }); + await engine.putPage('ls-b', { type: 'note', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} }); + await engine.putPage('ls-c', { type: 'note', title: 'C', compiled_truth: 'c', timeline: '', frontmatter: {} }); + // zeta:1, alpha:2 — alpha sorts first by count; within ties, name ASC. + await engine.addLink('ls-a', 'ls-b', '', 'r1', 'alpha-src'); + await engine.addLink('ls-a', 'ls-c', '', 'r2', 'alpha-src'); + await engine.addLink('ls-b', 'ls-c', '', 'r3', 'zeta-src'); + + const op = operationsByName['list_link_sources']; + const rows = (await op.handler(makeCtx(), {})) as Array<{ link_source: string | null; count: number }>; + const map = new Map(rows.map(r => [r.link_source, r.count])); + expect(map.get('alpha-src')).toBe(2); + expect(map.get('zeta-src')).toBe(1); + // Deterministic order: counts non-increasing. + for (let i = 1; i < rows.length; i++) { + expect(rows[i - 1].count >= rows[i].count).toBe(true); + } + }); + + test('scalar ctx.sourceId scopes; a non-matching source returns nothing', async () => { + const op = operationsByName['list_link_sources']; + // Pages above were written to the 'default' source. + const inDefault = (await op.handler(makeCtx({ sourceId: 'default' } as any), {})) as unknown[]; + expect(inDefault.length).toBeGreaterThan(0); + const inOther = (await op.handler(makeCtx({ sourceId: 'no-such-source' } as any), {})) as unknown[]; + expect(inOther.length).toBe(0); + }); + + test('federated allowedSources ({sourceIds}) scopes (OV8)', async () => { + const op = operationsByName['list_link_sources']; + const inDefault = (await op.handler(makeCtx({ auth: { allowedSources: ['default'] } } as any), {})) as unknown[]; + expect(inDefault.length).toBeGreaterThan(0); + const inOther = (await op.handler(makeCtx({ auth: { allowedSources: ['no-such-source'] } } as any), {})) as unknown[]; + expect(inOther.length).toBe(0); + }); +}); + +describe('CLI aliases + help (OV10 + OV11)', () => { + test('link-add resolves to add_link, link-rm to remove_link', () => { + expect(cliAliases.get('link-add')?.name).toBe('add_link'); + expect(cliAliases.get('link-rm')?.name).toBe('remove_link'); + }); + + test('no alias collides with any primary CLI name or another alias', () => { + const primaries = new Set(operations.map(o => o.cliHints?.name).filter(Boolean) as string[]); + const seen = new Set(); + for (const op of operations) { + for (const alias of op.cliHints?.aliases ?? []) { + expect(primaries.has(alias)).toBe(false); + expect(seen.has(alias)).toBe(false); + seen.add(alias); + } + } + }); + + test('printOpHelp shows the invoked alias name, not the primary (OV10)', () => { + const op = operationsByName['add_link']; + const lines: string[] = []; + const orig = console.log; + console.log = (...a: unknown[]) => { lines.push(a.join(' ')); }; + try { + printOpHelp(op, 'link-add'); + } finally { + console.log = orig; + } + const out = lines.join('\n'); + expect(out).toContain('Usage: gbrain link-add'); + expect(out).not.toContain('Usage: gbrain link '); + }); +}); diff --git a/test/schema-migrate-link-source-mentions.test.ts b/test/schema-migrate-link-source-mentions.test.ts index 5b82c1886..7bae1b1c7 100644 --- a/test/schema-migrate-link-source-mentions.test.ts +++ b/test/schema-migrate-link-source-mentions.test.ts @@ -97,19 +97,21 @@ describe('fresh-init brain (post-migration v95) accepts link_source=mentions', ( expect(rows.some(r => r.link_source === 'mentions')).toBe(true); }); - test('CHECK still rejects an unknown source value (widening did not nullify the gate)', async () => { + test('CHECK still rejects a malformed source value (gate not nullified)', async () => { const slugA = `bad-source-a-${Math.random().toString(36).slice(2, 8)}`; const slugB = `bad-source-b-${Math.random().toString(36).slice(2, 8)}`; await engine.putPage(slugA, { type: 'note', title: 'A', compiled_truth: 'a', timeline: '', frontmatter: {} }); await engine.putPage(slugB, { type: 'person', title: 'B', compiled_truth: 'b', timeline: '', frontmatter: {} }); - // 'inferred' is NOT in allow-list ∪ {'mentions'} — must reject. + // v114 (#1941): the closed allowlist became a kebab-case regex, so a once- + // illegal-but-kebab value like 'inferred' is now ACCEPTED. The gate still + // bites on MALFORMED values — 'Inferred_X' has an uppercase + underscore. await expect( engine.addLinksBatch([ { from_slug: slugA, to_slug: slugB, link_type: 'mentions', - link_source: 'inferred' as any, + link_source: 'Inferred_X' as any, context: 'should reject', }, ]), diff --git a/test/supervisor-db-lock.test.ts b/test/supervisor-db-lock.test.ts index d0512d5c8..31829443f 100644 --- a/test/supervisor-db-lock.test.ts +++ b/test/supervisor-db-lock.test.ts @@ -10,6 +10,9 @@ * - refresh-failure past the threshold fails SAFE (exits non-zero) (F1A) */ import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from 'bun:test'; +import { existsSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { tryAcquireDbLock } from '../src/core/db-lock.ts'; import { MinionSupervisor, ExitCodes, supervisorLockId, classifySupervisorSingleton } from '../src/core/minions/supervisor.ts'; @@ -32,12 +35,16 @@ beforeEach(async () => { }); describe('#1849 supervisorLockId', () => { - test('keys on DB identity AND queue', () => { - expect(supervisorLockId('default', 'postgres://x')).toBe('gbrain-supervisor:postgres://x:default'); - expect(supervisorLockId('shell', 'postgres://x')).toBe('gbrain-supervisor:postgres://x:shell'); - // Different DB identity → different lock even for the same queue. - expect(supervisorLockId('default', 'postgres://a')) - .not.toBe(supervisorLockId('default', 'postgres://b')); + test('keys on queue ONLY (DB scoping is physical — the lock row lives in the DB)', () => { + expect(supervisorLockId('default')).toBe('gbrain-supervisor:default'); + expect(supervisorLockId('shell')).toBe('gbrain-supervisor:shell'); + // Different queues → different locks. + expect(supervisorLockId('default')).not.toBe(supervisorLockId('shell')); + // Regression (the bug this fixes): the id must NOT depend on how the same + // physical DB was addressed. Two supervisors on one DB via different URLs + // must compute the SAME id so they collide on the one shared locks table. + // The function takes no DB-identity arg precisely so it can't diverge. + expect(supervisorLockId.length).toBe(1); }); }); @@ -75,7 +82,7 @@ describe('#1849 classifySupervisorSingleton (doctor)', () => { describe('#1849 DB lock is the real singleton', () => { test('second acquire of the same (db, queue) lock is refused', async () => { - const id = supervisorLockId('default', 'pglite:test'); + const id = supervisorLockId('default'); const first = await tryAcquireDbLock(engine, id, 5); expect(first).not.toBeNull(); // A second supervisor (different pidfile, same db+queue) gets null → exit 2. @@ -89,8 +96,8 @@ describe('#1849 DB lock is the real singleton', () => { }); test('different queues on the same DB do not collide', async () => { - const a = await tryAcquireDbLock(engine, supervisorLockId('default', 'pglite:test'), 5); - const b = await tryAcquireDbLock(engine, supervisorLockId('shell', 'pglite:test'), 5); + const a = await tryAcquireDbLock(engine, supervisorLockId('default'), 5); + const b = await tryAcquireDbLock(engine, supervisorLockId('shell'), 5); expect(a).not.toBeNull(); expect(b).not.toBeNull(); await a!.release(); @@ -98,6 +105,48 @@ describe('#1849 DB lock is the real singleton', () => { }); }); +describe('#1849 LOCK_HELD path does not strand the pidfile', () => { + test('the pidfile-cleanup exit listener is installed BEFORE the DB-lock acquire', async () => { + // Supervisor A already holds the queue lock. + const holderA = await tryAcquireDbLock(engine, supervisorLockId('default'), 5); + expect(holderA).not.toBeNull(); + + const pidFile = join(tmpdir(), `gbrain-sup-stranded-${process.pid}-${Math.random().toString(36).slice(2)}.pid`); + const sup = new MinionSupervisor(engine, { cliPath: '/bin/sh', healthInterval: 0, json: true, pidFile }); + + // Capture the 'exit' listener start() registers (if any) and stop execution + // at the first process.exit (the LOCK_HELD path) the way the real exit would. + let exitListener: ((...a: unknown[]) => void) | null = null; + const onSpy = spyOn(process, 'on').mockImplementation(((event: string, cb: (...a: unknown[]) => void) => { + if (event === 'exit') exitListener = cb; + return process; + }) as never); + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit:${code}`); + }) as never); + + try { + try { await sup.start(); } catch { /* exit stub throws at LOCK_HELD */ } + + expect(exitSpy).toHaveBeenCalledWith(ExitCodes.LOCK_HELD); + // The bug: the exit listener was registered AFTER the DB-lock exit, so + // start() threw before reaching it and the pidfile this process created + // is stranded. The fix installs it first → it's captured here. + expect(exitListener).not.toBeNull(); + // And it actually cleans up the pidfile we created (contents match our pid). + expect(existsSync(pidFile)).toBe(true); + exitListener!(); + expect(existsSync(pidFile)).toBe(false); + } finally { + onSpy.mockRestore(); + exitSpy.mockRestore(); + if (existsSync(pidFile)) unlinkSync(pidFile); + await holderA!.release(); + } + }); + +}); + describe('#1849 refresh-failure fails safe (F1A)', () => { test('exits LOCK_LOST after the failure threshold; tolerates a single blip', async () => { const sup = new MinionSupervisor(engine, { cliPath: '/bin/sh', healthInterval: 0, json: true });