v0.41.13.0 fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) (#1456)
* fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) Five real production bugs from infiniteGameExp (PostgreSQL onboarding) and foxhoundinc (dream-cycle reproduction), each silent-failure shape where gbrain told the user the operation succeeded when it didn't. * #1422 — `gbrain dream` swallowed connectEngine errors. Bind the caught error and surface `[dream] WARNING: could not connect to DB (...)` on stderr before falling through to filesystem-only phases. runDream(null) no-DB fallback preserved. * #1433 — `gbrain sync` deleted previously-indexed log.md / schema.md / index.md / README.md pages on every re-sync. Refactor isSyncable through private classifySync helper; expose unsyncableReason (companion returning the same tagged reason) and SYNC_SKIP_FILES named export. Cleanup loop guards on reason === 'metafile' before deleting. * #1434 — `gbrain sync` without --source on single-vault brains routed to source_id='default' (zero pages) and silently failed. Add resolver tier 5.5 'sole_non_default' AFTER brain_default (explicit user intent wins). Wire runSync + runImport to call resolveSourceWithTier unconditionally so the tier actually fires. Stderr nudge on tier hit; suppress with GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1. * #1309 — overlapping ingest roots created duplicate pages. New BrainEngine.findDuplicatePage?(sourceId, {hash, frontmatterId}) with identity-based posture: SKIP when frontmatter.id matches (true external duplicate), WARN-ALWAYS on content_hash collision with different/missing fm.id, FAIL CLOSED on lookup error. Migration v95 adds partial index pages_dedup_idx (Postgres CONCURRENTLY, PGLite plain CREATE). * #1436 — MCP fuzzy get_page returned slug candidates from sources outside caller's scope. resolveSlugs signature extended with {sourceId?, sourceIds?} matching the sourceScopeOpts helper output; operations.ts threads it through. Both engines preserve unscoped back-compat for internal CLI callers. Plus a stable tiebreaker on searchVector ORDER BY (score DESC, page_id ASC, chunk_id ASC) in both engines. Caught while wiring the index above — basis-vector eval fixtures with tied scores depend on planner row order, which any new index on pages could flip. Pins eval-replay-gate ranking determinism against future index changes. Per codex review of the original plan: caught 6 load-bearing gaps that the engineering review missed (runSync bypass, #1436 misclassified as fixed, dedup fail-open, content-hash-alone too aggressive, soft-delete filter missing, tier-ordering contradiction). All folded in pre-merge. Tests: 65 new wave cases across 7 new files + 1 extended; all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.13.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
CHANGELOG.md
129
CHANGELOG.md
@@ -2,6 +2,117 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.41.13.0] - 2026-05-25
|
||||
|
||||
**`gbrain sync` and `gbrain dream` stop lying about what they did.**
|
||||
|
||||
Five real production bugs from one user's onboarding day (infiniteGameExp on PostgreSQL, foxhoundinc on a separate `gbrain dream` repro) all surfaced the same shape: gbrain told the user the operation succeeded when it didn't. `gbrain dream` exited 0 with every database phase silently skipped because the engine connection had thrown and the catch block ate the error. `gbrain sync` from a directory outside your vault routed every page to the literal source named `'default'` (which held zero pages) because the resolver didn't fire when you skipped `--source`. `gbrain sync` deleted your previously-indexed `learning/log.md` page on every re-sync because the cleanup loop didn't distinguish "user removed this file" from "this filename was always meant to be filtered." Two ingests of overlapping vault directories created two pages per file. A remote MCP client with `fuzzy: true` could see slug candidates from sources outside its access scope.
|
||||
|
||||
This release fixes all five. If you upgrade and `gbrain doctor` shows your brain at the same score as before, you don't need to do anything — the fixes catch failure modes that were silent. The user-visible improvements you should notice:
|
||||
|
||||
- `gbrain dream` against a broken DATABASE_URL now prints `[dream] WARNING: could not connect to DB (...)` to stderr, then runs filesystem-only phases honestly. No more guessing why your nightly cycle wrote nothing.
|
||||
- `gbrain sync` without `--source` on a brain that has one non-default source registered (the usual single-vault Obsidian / notes-folder shape) auto-routes to that source AND tells you it did. The first line on stderr will be `[gbrain] routing to source 'studiovault' (sole non-default source registered; pass --source to override).` Override with `--source default` if you actually meant `'default'`.
|
||||
- Indexed `log.md` / `schema.md` / `index.md` / `README.md` pages survive every re-sync.
|
||||
- Re-running `gbrain import` against an overlapping vault root no longer doubles your pages. Files with the same external `id:` (granola UUIDs, ULIDs) get an `[import] skipping ...` log; files with the same body text but different external IDs both index with an `[import] WARNING: ... shares content_hash` log so you can investigate.
|
||||
- MCP `get_page` with `fuzzy: true` on a remote-bound client only sees slug candidates from sources the client is scoped to. Closes a source-isolation gap in the fuzzy resolver that mirrored the v0.34.1 P0 seal for exact lookups.
|
||||
|
||||
### How to turn it on
|
||||
|
||||
`gbrain upgrade`. The new partial index `pages_dedup_idx` runs as migration v95 with `CREATE INDEX CONCURRENTLY` on Postgres so the migration is non-blocking; PGLite users get a plain `CREATE INDEX` (no concurrent writers exist there). To suppress the sole-non-default-source nudge in CI / cron scripts, set `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1`.
|
||||
|
||||
### Numbers that matter
|
||||
|
||||
| Bug class | Pre-fix | Post-fix |
|
||||
|---|---|---|
|
||||
| `gbrain dream` against bad DB | Silent skip, exit 0, no log line | Loud `[dream] WARNING` on stderr, filesystem phases still run, exit 0 |
|
||||
| `gbrain sync` on single-vault brain without `--source` | Routes to `'default'` (0 pages), createVersion 404s | Auto-routes to the registered vault source, prints nudge, succeeds |
|
||||
| Re-sync after editing `log.md` (or any other SYNC_SKIP_FILES basename) | Page deleted from index | Page survives indefinitely |
|
||||
| Overlapping ingest of same vault | 2 pages per file, identical content_hash + frontmatter.id | 1 page; second ingest logs `[import] skipping` |
|
||||
| MCP fuzzy `get_page` on federated client | Returns candidates from any source | Returns candidates only from sources in client's scope |
|
||||
|
||||
### What's safe to know about
|
||||
|
||||
- The `pages.frontmatter->>'id'` JSON path is now part of the identity signal for `findDuplicatePage`. Pages without a frontmatter `id:` fall back to content-hash-only matching with a WARN-not-SKIP posture — gbrain will never silently drop pages that share text but have no explicit external identifier.
|
||||
- Vector search now has a stable tiebreaker (`ORDER BY score DESC, page_id ASC, chunk_id ASC`) in both PGLite and Postgres. When two chunks tie on score, the older `page_id` wins; this was non-deterministic before. The eval-replay-gate test fixture relies on this for hermetic reproducibility — without the tiebreaker, adding any new index to the `pages` table could flip ranking on tied scores. v0.41.13 closes that fragility.
|
||||
- `gbrain sync` with `manifest.deleted` still doesn't delete `log.md`-class pages on physical `rm log.md`. That's a known limitation, NOT fixed by this wave — the cleanup guard only covers `manifest.modified`. Filed as v0.42+ TODO for a `gbrain pages remove <slug>` operator surface.
|
||||
- Issues #1437 (CLI source-id default) and #1435 (modified counter inflation) closed without code change: #1437 was already fixed in v0.41.8; #1435 needs a CLI-contract change for the human summary format and is deferred to v0.42+.
|
||||
|
||||
### What we caught and fixed before merging
|
||||
|
||||
Codex's outside-voice review of the original plan caught six load-bearing gaps that the engineering review missed. The original plan would have shipped:
|
||||
|
||||
- The `sole_non_default` resolver tier as dead code (because `runSync` didn't call the resolver in the no-explicit-source case at `commands/sync.ts:1480`).
|
||||
- The MCP fuzzy `get_page` source-scope fix marked "likely already done in v0.41.8" — codex verified at the line numbers it was open.
|
||||
- The dedup pre-check failing OPEN on lookup error (silently masking the very bug it was meant to prevent) instead of failing CLOSED.
|
||||
- The dedup matching on content-hash alone, which would have silently dropped two intentional pages that share template text but have distinct external IDs.
|
||||
- The dedup query missing `deleted_at IS NULL`, which would have blocked legitimate re-imports after soft-delete.
|
||||
- The resolver tier placement contradiction (the plan said "5.5 before 5" AND "user who set sources.default still wins" — can't both be true).
|
||||
|
||||
All six folded into the shipped wave per the codex consult on the plan file. The full plan + review trail at `~/.claude/plans/system-instruction-you-are-working-fluffy-ritchie.md`.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Closes:** [#1422](https://github.com/garrytan/gbrain/issues/1422), [#1433](https://github.com/garrytan/gbrain/issues/1433), [#1434](https://github.com/garrytan/gbrain/issues/1434), [#1436](https://github.com/garrytan/gbrain/issues/1436), [#1309](https://github.com/garrytan/gbrain/issues/1309). Verified-fixed: [#1437](https://github.com/garrytan/gbrain/issues/1437) (closed). Deferred to v0.42+: [#1435](https://github.com/garrytan/gbrain/issues/1435) (closed with TODO), [#1432](https://github.com/garrytan/gbrain/issues/1432) (overlaps PR #1450 ze-switch), [#1438](https://github.com/garrytan/gbrain/issues/1438) (covered by PR #1440's `validateEmbeddingCreds()` preflight).
|
||||
|
||||
**Reports:** infiniteGameExp's seven-issue triage on 2026-05-25 + foxhoundinc's #1422 separately. All from real production deployments hitting the bugs in the first 24 hours of use.
|
||||
|
||||
#### `gbrain dream` no longer swallows engine-connect errors (#1422)
|
||||
|
||||
`src/cli.ts:1063-1080` — bind the caught error and write a `[dream] WARNING:` line to stderr before falling through to filesystem-only phases. `runDream(null, ...)` still runs lint + backlinks + extract — no behavior change for the no-DB-by-design case. New test `test/cli-dream-engine-warn.test.ts` (2 cases) subprocess-runs the CLI against a bad DATABASE_URL and asserts the warning fires.
|
||||
|
||||
#### Cleanup loop preserves indexed metafile pages on re-sync (#1433)
|
||||
|
||||
`src/core/sync.ts:289-374` — `isSyncable` factored into a private `classifySync(path, opts): SyncableReason | null` helper that the public `isSyncable(p): boolean` AND the new `unsyncableReason(p): SyncableReason | null` both call. Single source of truth so they cannot drift. New `SYNC_SKIP_FILES` exported constant (the canonical four metafile basenames). New `SyncableReason` union type: `'metafile' | 'strategy' | 'pruned-dir' | 'include-glob-miss' | 'exclude-glob-hit'`.
|
||||
|
||||
`src/commands/sync.ts:772` — cleanup loop guards on `unsyncableReason(path, syncOpts) === 'metafile'` before deleting a pre-existing page. A `log.md` that was indexed by an older gbrain version (or via direct `put_page`) now survives every subsequent `gbrain sync`.
|
||||
|
||||
New tests: `test/sync-isSyncable-shape.test.ts` (15 cases pinning the duality contract + every reason variant) + `test/sync-metafile-skip.serial.test.ts` (3 PGLite cases: log.md survives re-sync, schema.md survives, AND a renamed-to-be-unsyncable `.md → .txt` IS still cleaned up so the guard is properly narrow).
|
||||
|
||||
#### Resolver tier 5.5: `sole_non_default` + runSync rewiring (#1434)
|
||||
|
||||
`src/core/source-resolver.ts:71-220` — new `pickSoleNonDefaultSource(engine)` private helper queries `SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false` and returns the id when exactly one row matches. Both `resolveSourceId` and `resolveSourceWithTier` route through it AFTER tier 5 (brain_default) so explicit user intent (`sources.default` config) wins. Archived sources excluded.
|
||||
|
||||
`SOURCE_TIER_NAMES` extended to 7-entry tuple: `[flag, env, dotfile, local_path, brain_default, sole_non_default, seed_default]`. New exported `formatSoleNonDefaultNudge(sourceId): string | null` builds the user-facing nudge line; returns null when `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1` (CI / scripted-pipeline ergonomics).
|
||||
|
||||
`src/commands/sync.ts:1497-1519` — `runSync` always calls `resolveSourceWithTier` (was: only when `--source` or `GBRAIN_SOURCE` was set). When the tier is `'sole_non_default'`, prints the nudge to stderr.
|
||||
|
||||
`src/commands/import.ts:96-128` — same wiring on `gbrain import`. When `--source-id` AND `opts.sourceId` AND `GBRAIN_SOURCE` are ALL unset, the resolver fires; auto-route happens only when the tier is `'sole_non_default'` (preserves the v0.30.x explicit-only default for every other case).
|
||||
|
||||
New tests: `test/source-resolver-sole-non-default.test.ts` (14 cases across the full 7-tier matrix + nudge formatter + env-suppress + archived-source-exclusion) + `test/sync-sole-non-default-routing.test.ts` (3 PGLite cases driving the actual `runSync` path with stderr capture: auto-route fires, explicit --source overrides, multi-source brains fall through to default with no nudge).
|
||||
|
||||
Convention doc updated: `skills/conventions/brain-routing.md` now documents the 7-tier chain with the 5.5 placement rationale.
|
||||
|
||||
#### `findDuplicatePage` with identity-based dedup (#1309)
|
||||
|
||||
New `BrainEngine.findDuplicatePage?(sourceId, opts: {hash, frontmatterId?})` method (optional `?` so existing test doubles compile unchanged). Implemented on PGLite (`src/core/pglite-engine.ts:813`) and Postgres (`src/core/postgres-engine.ts:812`) with single-row `SELECT id, slug FROM pages WHERE source_id = $1 AND deleted_at IS NULL AND (content_hash = $2 OR (frontmatter->>'id' = $3 AND $3 IS NOT NULL)) ORDER BY id LIMIT 1`.
|
||||
|
||||
`src/core/import-file.ts:427-490` — dedup pre-check fires AFTER the existing `getPage(slug)` short-circuit. Posture:
|
||||
- True duplicate (matching `frontmatter.id`): SKIP with `[import] skipping ...` log to stderr.
|
||||
- Same content_hash, different (or missing) `frontmatter.id`: WARN with `[import] WARNING: ... shares content_hash` log, BOTH pages index.
|
||||
- Lookup error: throw `[import] dedup pre-check failed for X: Y. Re-run import after DB recovery.` (FAIL CLOSED per codex review — silent fallthrough would mask the very bug this fix exists for).
|
||||
- `--force-rechunk`: bypasses the dedup check.
|
||||
|
||||
New migration v95 `pages_dedup_partial_index`: `CREATE INDEX pages_dedup_idx ON pages (source_id, content_hash) WHERE deleted_at IS NULL` — Postgres path uses CONCURRENTLY with `transaction: false` + pre-drop of any invalid remnant from a prior failed attempt; PGLite uses plain CREATE INDEX.
|
||||
|
||||
New tests: `test/import-dedup-frontmatter-id.test.ts` (11 cases: skip-on-match, warn-on-hash-collision, soft-delete excluded, no-frontmatter fallback, force-rechunk bypass, engine method shape + cross-source isolation).
|
||||
|
||||
#### MCP fuzzy `get_page` honors source scope (#1436)
|
||||
|
||||
`BrainEngine.resolveSlugs(partial, opts?: {sourceId?, sourceIds?})` signature extended. PGLite + Postgres both add a source filter to the exact AND fuzzy SQL paths when the opts are set; back-compatible no-opts path stays unscoped for internal CLI callers (`gbrain query --resolve`). Both paths also add `AND deleted_at IS NULL` so soft-deleted rows don't surface as fuzzy candidates.
|
||||
|
||||
`src/core/operations.ts:475-485` — `get_page` op handler threads `sourceScopeOpts(ctx)` (the canonical helper for federated_read > scalar > nothing precedence) into the `resolveSlugs` call.
|
||||
|
||||
New tests: `test/operations-fuzzy-source-scope.test.ts` (6 PGLite cases: scalar scope, federated array, back-compat, empty result for unmatched source, fuzzy match honors scope, soft-deleted excluded).
|
||||
|
||||
#### Vector-search stable tiebreaker (regression guard for v95)
|
||||
|
||||
`searchVector` in both engines: outer SELECT `ORDER BY score DESC` extended to `ORDER BY score DESC, page_id ASC, chunk_id ASC`. When two chunks tie on score (same source-prefix boost + same cosine distance, common in basis-vector eval fixtures), the older `page_id` wins. Without this, the planner's choice of join-driving index can flip ranking on tied scores between branches that add unrelated indexes to `pages`. This came up while running `test/eval-replay-gate.test.ts` after landing the v95 partial index; the tiebreaker pins ranking determinism going forward.
|
||||
|
||||
#### Release process
|
||||
|
||||
`gbrain upgrade` runs migration v95 automatically. The migration is `CREATE INDEX CONCURRENTLY` on Postgres so it doesn't block other writers; the index is small (`~MB` even on 100K-page brains) and the dedup query becomes O(log n).
|
||||
|
||||
### To take advantage of v0.41.13.0
|
||||
## [0.41.12.0] - 2026-05-25
|
||||
|
||||
**`gbrain ze-switch` no longer silently breaks multimodal search on brains that mix text and image embeddings.**
|
||||
@@ -396,6 +507,24 @@ Five agent-authored PRs (#1378–#1382) surfaced the design and the surrogate-pa
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **Your agent reads `skills/conventions/brain-routing.md` the next time you interact with it** — the 7-tier chain is now documented there with the v0.41.13 `sole_non_default` placement rationale.
|
||||
3. **Verify the outcome:**
|
||||
```bash
|
||||
# Confirm migration v95 ran
|
||||
gbrain doctor --json | jq '.checks[] | select(.name=="schema_version")'
|
||||
# Confirm the resolver picks up your single-source brain
|
||||
gbrain sources current --json
|
||||
# Smoke-test the dream warning
|
||||
GBRAIN_DATABASE_URL=postgresql://wrong:wrong@localhost:9999/x gbrain dream --json 2>&1 | head -2
|
||||
```
|
||||
4. **If any step fails or the numbers look wrong,** please file an issue:
|
||||
https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
- which step broke
|
||||
|
||||
This feedback loop is how the gbrain maintainers find fragile upgrade paths. Thank you.
|
||||
|
||||
This applies schema migration v95 (widens the `link_source` CHECK to admit `'mentions'`).
|
||||
2. **Check your orphan ratio:**
|
||||
```bash
|
||||
|
||||
@@ -166,7 +166,12 @@ strict behavior when unset.
|
||||
- `src/commands/graph-query.ts` — `gbrain graph-query <slug> [--type T] [--depth N] [--direction in|out|both] [--include-foreign]`: typed-edge relationship traversal (renders indented tree). **v0.37.7.0 (#1153):** foreign-edge footer always present (`X foreign edges (use --include-foreign to traverse)`) so cross-source edges never disappear silently; `--include-foreign` widens the SQL filter to walk them. Pinned by `test/graph-query.test.ts`.
|
||||
- `src/commands/sources.ts` — `gbrain sources {list,add,remove,archive,restore,archived,purge,current,status,audit}`. **v0.37.7.0 (#1222):** new `current [--json]` subcommand calls `resolveSourceWithTier()` and prints `source_id`, `tier` (one of `flag | env | dotfile | local_path | brain_default | seed_default`), and optional `detail`. The agent-facing decision table for which tier wins lives in `skills/conventions/brain-routing.md`. **v0.40.3.0 (productionized from PR #1314):** new `status [--json]` subcommand — read-only per-source dashboard (last sync, staleness, page count, embedding coverage, unacked failures). Thin wrapper around `buildSyncStatusReport` + `printSyncStatusReport` exported from `src/commands/sync.ts`. `--json` emits the stable `{schema_version: 1, sources, ...}` envelope on stdout for monitoring pipelines; bare invocation prints the human table to stdout (right-aligned numeric columns, kubectl-style). Filters input sources to `local_path IS NOT NULL AND archived IS NOT TRUE` so archived sources (which have their own `gbrain sources archived` surface) don't muddy the active-sync dashboard. Lives under `sources` (not `sync --status`) per D3 from the v0.40.3.0 plan-eng-review — reads and writes don't share a verb. **v0.40.9.0:** new `audit <id> [--json]` subcommand — read-only dry-run scan of a source repo's disk for size distribution + would-blocks + junk-pattern hits, WITHOUT touching the DB. Catches scraper junk and oversized content BEFORE sync. Walks `sources.local_path`, reads each markdown file, runs `assessContent()` from `src/core/content-sanity.ts`, aggregates by verdict (`ok | warn_oversize | hard_block_junk_pattern`). JSON envelope is stable for monitoring pipelines. Pinned by `test/sources-audit.test.ts` (not present in this wave; covered transitively by `test/content-sanity.test.ts` + `test/import-file-content-sanity.test.ts`).
|
||||
- `src/commands/reindex-frontmatter.ts` — `gbrain reindex-frontmatter`. **v0.37.7.0 (#1225):** wrapped the query path in the standard `withEngine(...)` lifecycle so `engine.connect()` runs before the first SQL call. Pre-fix the command `process.exit(1)`'d with a TypeError on first invocation. Pinned by `test/reindex-frontmatter-connect.test.ts`.
|
||||
- `src/core/source-resolver.ts` — 6-tier source resolution. **v0.37.7.0:** new additive helper `resolveSourceWithTier(engine, explicit, cwd)` returns `{ source_id, tier: SourceTier, detail? }` alongside the existing `resolveSourceId()` (unchanged, no caller breakage). New exported const `SOURCE_TIER_NAMES = ['flag', 'env', 'dotfile', 'local_path', 'brain_default', 'seed_default']` so the JSON shape stays type-stable across releases. Order matches the 1-6 priority of `resolveSourceId()`. Consumed by `gbrain sources current`, `gbrain import --source-id`, `gbrain extract --source-id`, and the v0.37.7.0 `source_routing_health` doctor check. Pinned by `test/source-resolver-with-tier.test.ts` (uses `withEnv()` wrapper per the test-isolation lint).
|
||||
- `src/core/source-resolver.ts` — 6-tier source resolution. **v0.37.7.0:** new additive helper `resolveSourceWithTier(engine, explicit, cwd)` returns `{ source_id, tier: SourceTier, detail? }` alongside the existing `resolveSourceId()` (unchanged, no caller breakage). New exported const `SOURCE_TIER_NAMES = ['flag', 'env', 'dotfile', 'local_path', 'brain_default', 'seed_default']` so the JSON shape stays type-stable across releases. Order matches the 1-6 priority of `resolveSourceId()`. Consumed by `gbrain sources current`, `gbrain import --source-id`, `gbrain extract --source-id`, and the v0.37.7.0 `source_routing_health` doctor check. Pinned by `test/source-resolver-with-tier.test.ts` (uses `withEnv()` wrapper per the test-isolation lint). **v0.41.13.0 (#1434):** new tier 5.5 `sole_non_default` slots between `brain_default` and `seed_default`. When NO `sources.default` config is set AND exactly one registered source has `local_path` AND isn't `'default'`, auto-route to it. `SOURCE_TIER_NAMES` extended to 7 entries. New private `pickSoleNonDefaultSource(engine)` shared by both resolver entry points (cannot drift). Archived sources excluded (try/catch for pre-v34 brains). New exported `formatSoleNonDefaultNudge(sourceId): string | null` builds the user-facing stderr nudge (returns null when `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1`). `src/commands/sync.ts:1497-1519` rewired to call `resolveSourceWithTier` unconditionally so the new tier actually fires (codex caught the original plan shipping dead code). `src/commands/import.ts:96-128` mirrors the same wiring with the tier-gated nudge. Pinned by `test/source-resolver-sole-non-default.test.ts` (14 cases) + `test/sync-sole-non-default-routing.test.ts` (3 PGLite cases driving real `runSync` with stderr capture).
|
||||
- `src/core/sync.ts` extension (v0.41.13.0, #1433) — `isSyncable` factored through a private `classifySync(path, opts): SyncableReason | null` helper. New exported `unsyncableReason(path, opts)` companion returns the same tagged reason or null when syncable. `SYNC_SKIP_FILES` lifted to a named export (the four canonical metafile basenames: `schema.md`, `index.md`, `log.md`, `README.md`). New `SyncableReason` union: `'metafile' | 'strategy' | 'pruned-dir' | 'include-glob-miss' | 'exclude-glob-hit'`. `src/commands/sync.ts:772` cleanup loop now guards on `unsyncableReason(path) === 'metafile'` so previously-indexed metafile pages (typically `log.md` from an older gbrain that didn't filter it) survive every re-sync. Honest scope: does NOT cover `manifest.deleted` (the upstream filter already strips metafiles, so `rm log.md` doesn't delete the page either — v0.42+ TODO for a `gbrain pages remove <slug>` operator surface). Pinned by `test/sync-isSyncable-shape.test.ts` (15 cases pinning the duality contract) + `test/sync-metafile-skip.serial.test.ts` (3 PGLite cases including the negative case — renamed `.md → .txt` still gets cleaned up properly).
|
||||
- `src/core/import-file.ts` extension (v0.41.13.0, #1309) — identity-based dedup pre-check at `:427-490`. Calls the new `engine.findDuplicatePage?.(sourceId, {hash, frontmatterId})` (optional `?` so test doubles compile unchanged). Posture per codex review: SKIP when `frontmatter.id` matches (true external duplicate from overlapping ingest roots), WARN-ALWAYS on content_hash collision with different/missing `frontmatter.id` (templates and daily logs may legitimately share text), FAIL CLOSED on lookup error (silent fallthrough would mask the very bug this fixes), bypass via `--force-rechunk`. Soft-deleted pages excluded at the engine layer so tombstones don't block legitimate re-imports under new slugs. Pinned by `test/import-dedup-frontmatter-id.test.ts` (11 cases).
|
||||
- `src/core/engine.ts` extension (v0.41.13.0) — two interface changes: (1) #1309 — new optional `findDuplicatePage?(sourceId, {hash, frontmatterId?}): Promise<{slug, id} | null>` method; identity precedence is content_hash OR frontmatter->>'id' both with `deleted_at IS NULL`; (2) #1436 — `resolveSlugs(partial, opts?)` signature extended with `{sourceId?, sourceIds?}` so the MCP fuzzy `get_page` path scopes by source (closes the source-bleed bug class that mirrored the v0.34.1 P0 seal for exact lookups). Field names match `sourceScopeOpts(ctx)` output so handlers can spread directly. Both signatures back-compatible — callers passing no opts get the pre-fix behavior. Plus a stable tiebreaker `ORDER BY score DESC, page_id ASC, chunk_id ASC` added to `searchVector` in both engines: when two chunks tie on score (basis-vector eval fixtures), older `page_id` wins. Closes the planner-non-determinism regression class — adding any new index to `pages` could previously flip ranking on tied scores between branches.
|
||||
- `src/core/migrate.ts` v95 (v0.41.13.0, #1309) — `pages_dedup_partial_index` adds `CREATE INDEX pages_dedup_idx ON pages (source_id, content_hash) WHERE deleted_at IS NULL`. Postgres path uses `CREATE INDEX CONCURRENTLY` with `transaction: false` + pre-drops any invalid remnant from a prior failed attempt; PGLite uses plain `CREATE INDEX`. Powers `findDuplicatePage` hot path on 100K-page brains — query becomes O(log n) instead of O(n).
|
||||
- `src/cli.ts` extension (v0.41.13.0, #1422) — `dream` dispatch at lines 1063-1080 binds the caught engine-connect error and emits `[dream] WARNING: could not connect to DB (...)` to stderr before falling through to filesystem-only phases. Pre-fix the catch swallowed silently and the user saw `gbrain dream` exit 0 with no clue why DB phases were skipped. The `runDream(null, ...)` no-DB fallback is preserved — filesystem phases still run honestly. Pinned by `test/cli-dream-engine-warn.test.ts` (2 subprocess cases against good + bad DATABASE_URL).
|
||||
- `src/commands/autopilot.ts` extension (v0.37.7.0) — three changes for federated-brain co-existence and launchd hygiene. (1) **#1226 lockfile scope:** `LOCK_PATH` resolves via `gbrainPath('autopilot.lock')` so it honors `GBRAIN_HOME`. Two brains can run autopilot simultaneously without lock-stealing. Lock file now stores PID; startup checks `kill -0 <pid>` before refusing to start (codex CF11 PID-safety fix — stale lock from a crashed process no longer blocks a healthy autopilot). (2) **#1162 reconnect classifier:** new exported `classifyReconnectError(err)` returns `'recoverable' | 'unrecoverable'`. Unrecoverable causes the daemon to `process.exit(0)` and let launchd back off instead of the v0.37.6 loop that logged `config.database_url undefined` every 5s forever. (3) **launchd plist generator:** new exported pure function `generateLaunchdPlist(wrapperPath, home)` sets `ThrottleInterval=300` so launchd respects the exit-0 backoff. Both helpers pinned by `test/autopilot-lock-path.test.ts` + `test/autopilot-reconnect-classifier.test.ts`.
|
||||
- `src/core/oauth-provider.ts` + `src/commands/serve-http.ts` extension (v0.37.7.0, #1166) — custom `/token` middleware that runs BEFORE the MCP SDK's `clientAuth`. The SDK does plaintext compare against the request's `client_secret`; gbrain stores SHA-256 hashes only, so every confidential-client `/token` request failed in v0.37.0–v0.37.6. The new middleware detects confidential auth via `Authorization: Basic` header OR `client_secret_post` form body (both shapes per RFC 6749 §2.3.1), verifies via `verifyClient(client_id, presented_secret)` (SHA-256 hash compare), and falls through to the SDK for public PKCE clients. Public clients (Claude Code, Cursor, every other PKCE-first MCP client) are unaffected — the SDK's clientAuth path still accepts them via the v0.34.1.0 NULL-`client_secret_hash` normalization. Pinned by `test/oauth-confidential-client.test.ts` (both `client_secret_basic` and `client_secret_post` paths).
|
||||
- `src/core/sync.ts:pruneDir` extension (v0.37.7.0, #1169) — `pruneDir(name, parentDir?)` signature extended with optional `parentDir`. When provided, the helper additionally rejects directories containing `.git` as a FILE — the git submodule gitfile pattern (regular repos have `.git` as a DIRECTORY; submodules have it as a file pointing into the parent's `.git/modules/`). Sync + extract walkers thread `parentDir` through so the gitfile-as-FILE check fires per descend step. Best-effort: `statSync` failures (cross-platform permission edge) fall through and treat as a normal dir. Closes the phantom-import bug class where syncing a worktree-with-submodules silently walked into submodule trees. Pinned by `test/sync-walker-submodule.test.ts`.
|
||||
|
||||
@@ -308,7 +308,12 @@ strict behavior when unset.
|
||||
- `src/commands/graph-query.ts` — `gbrain graph-query <slug> [--type T] [--depth N] [--direction in|out|both] [--include-foreign]`: typed-edge relationship traversal (renders indented tree). **v0.37.7.0 (#1153):** foreign-edge footer always present (`X foreign edges (use --include-foreign to traverse)`) so cross-source edges never disappear silently; `--include-foreign` widens the SQL filter to walk them. Pinned by `test/graph-query.test.ts`.
|
||||
- `src/commands/sources.ts` — `gbrain sources {list,add,remove,archive,restore,archived,purge,current,status,audit}`. **v0.37.7.0 (#1222):** new `current [--json]` subcommand calls `resolveSourceWithTier()` and prints `source_id`, `tier` (one of `flag | env | dotfile | local_path | brain_default | seed_default`), and optional `detail`. The agent-facing decision table for which tier wins lives in `skills/conventions/brain-routing.md`. **v0.40.3.0 (productionized from PR #1314):** new `status [--json]` subcommand — read-only per-source dashboard (last sync, staleness, page count, embedding coverage, unacked failures). Thin wrapper around `buildSyncStatusReport` + `printSyncStatusReport` exported from `src/commands/sync.ts`. `--json` emits the stable `{schema_version: 1, sources, ...}` envelope on stdout for monitoring pipelines; bare invocation prints the human table to stdout (right-aligned numeric columns, kubectl-style). Filters input sources to `local_path IS NOT NULL AND archived IS NOT TRUE` so archived sources (which have their own `gbrain sources archived` surface) don't muddy the active-sync dashboard. Lives under `sources` (not `sync --status`) per D3 from the v0.40.3.0 plan-eng-review — reads and writes don't share a verb. **v0.40.9.0:** new `audit <id> [--json]` subcommand — read-only dry-run scan of a source repo's disk for size distribution + would-blocks + junk-pattern hits, WITHOUT touching the DB. Catches scraper junk and oversized content BEFORE sync. Walks `sources.local_path`, reads each markdown file, runs `assessContent()` from `src/core/content-sanity.ts`, aggregates by verdict (`ok | warn_oversize | hard_block_junk_pattern`). JSON envelope is stable for monitoring pipelines. Pinned by `test/sources-audit.test.ts` (not present in this wave; covered transitively by `test/content-sanity.test.ts` + `test/import-file-content-sanity.test.ts`).
|
||||
- `src/commands/reindex-frontmatter.ts` — `gbrain reindex-frontmatter`. **v0.37.7.0 (#1225):** wrapped the query path in the standard `withEngine(...)` lifecycle so `engine.connect()` runs before the first SQL call. Pre-fix the command `process.exit(1)`'d with a TypeError on first invocation. Pinned by `test/reindex-frontmatter-connect.test.ts`.
|
||||
- `src/core/source-resolver.ts` — 6-tier source resolution. **v0.37.7.0:** new additive helper `resolveSourceWithTier(engine, explicit, cwd)` returns `{ source_id, tier: SourceTier, detail? }` alongside the existing `resolveSourceId()` (unchanged, no caller breakage). New exported const `SOURCE_TIER_NAMES = ['flag', 'env', 'dotfile', 'local_path', 'brain_default', 'seed_default']` so the JSON shape stays type-stable across releases. Order matches the 1-6 priority of `resolveSourceId()`. Consumed by `gbrain sources current`, `gbrain import --source-id`, `gbrain extract --source-id`, and the v0.37.7.0 `source_routing_health` doctor check. Pinned by `test/source-resolver-with-tier.test.ts` (uses `withEnv()` wrapper per the test-isolation lint).
|
||||
- `src/core/source-resolver.ts` — 6-tier source resolution. **v0.37.7.0:** new additive helper `resolveSourceWithTier(engine, explicit, cwd)` returns `{ source_id, tier: SourceTier, detail? }` alongside the existing `resolveSourceId()` (unchanged, no caller breakage). New exported const `SOURCE_TIER_NAMES = ['flag', 'env', 'dotfile', 'local_path', 'brain_default', 'seed_default']` so the JSON shape stays type-stable across releases. Order matches the 1-6 priority of `resolveSourceId()`. Consumed by `gbrain sources current`, `gbrain import --source-id`, `gbrain extract --source-id`, and the v0.37.7.0 `source_routing_health` doctor check. Pinned by `test/source-resolver-with-tier.test.ts` (uses `withEnv()` wrapper per the test-isolation lint). **v0.41.13.0 (#1434):** new tier 5.5 `sole_non_default` slots between `brain_default` and `seed_default`. When NO `sources.default` config is set AND exactly one registered source has `local_path` AND isn't `'default'`, auto-route to it. `SOURCE_TIER_NAMES` extended to 7 entries. New private `pickSoleNonDefaultSource(engine)` shared by both resolver entry points (cannot drift). Archived sources excluded (try/catch for pre-v34 brains). New exported `formatSoleNonDefaultNudge(sourceId): string | null` builds the user-facing stderr nudge (returns null when `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1`). `src/commands/sync.ts:1497-1519` rewired to call `resolveSourceWithTier` unconditionally so the new tier actually fires (codex caught the original plan shipping dead code). `src/commands/import.ts:96-128` mirrors the same wiring with the tier-gated nudge. Pinned by `test/source-resolver-sole-non-default.test.ts` (14 cases) + `test/sync-sole-non-default-routing.test.ts` (3 PGLite cases driving real `runSync` with stderr capture).
|
||||
- `src/core/sync.ts` extension (v0.41.13.0, #1433) — `isSyncable` factored through a private `classifySync(path, opts): SyncableReason | null` helper. New exported `unsyncableReason(path, opts)` companion returns the same tagged reason or null when syncable. `SYNC_SKIP_FILES` lifted to a named export (the four canonical metafile basenames: `schema.md`, `index.md`, `log.md`, `README.md`). New `SyncableReason` union: `'metafile' | 'strategy' | 'pruned-dir' | 'include-glob-miss' | 'exclude-glob-hit'`. `src/commands/sync.ts:772` cleanup loop now guards on `unsyncableReason(path) === 'metafile'` so previously-indexed metafile pages (typically `log.md` from an older gbrain that didn't filter it) survive every re-sync. Honest scope: does NOT cover `manifest.deleted` (the upstream filter already strips metafiles, so `rm log.md` doesn't delete the page either — v0.42+ TODO for a `gbrain pages remove <slug>` operator surface). Pinned by `test/sync-isSyncable-shape.test.ts` (15 cases pinning the duality contract) + `test/sync-metafile-skip.serial.test.ts` (3 PGLite cases including the negative case — renamed `.md → .txt` still gets cleaned up properly).
|
||||
- `src/core/import-file.ts` extension (v0.41.13.0, #1309) — identity-based dedup pre-check at `:427-490`. Calls the new `engine.findDuplicatePage?.(sourceId, {hash, frontmatterId})` (optional `?` so test doubles compile unchanged). Posture per codex review: SKIP when `frontmatter.id` matches (true external duplicate from overlapping ingest roots), WARN-ALWAYS on content_hash collision with different/missing `frontmatter.id` (templates and daily logs may legitimately share text), FAIL CLOSED on lookup error (silent fallthrough would mask the very bug this fixes), bypass via `--force-rechunk`. Soft-deleted pages excluded at the engine layer so tombstones don't block legitimate re-imports under new slugs. Pinned by `test/import-dedup-frontmatter-id.test.ts` (11 cases).
|
||||
- `src/core/engine.ts` extension (v0.41.13.0) — two interface changes: (1) #1309 — new optional `findDuplicatePage?(sourceId, {hash, frontmatterId?}): Promise<{slug, id} | null>` method; identity precedence is content_hash OR frontmatter->>'id' both with `deleted_at IS NULL`; (2) #1436 — `resolveSlugs(partial, opts?)` signature extended with `{sourceId?, sourceIds?}` so the MCP fuzzy `get_page` path scopes by source (closes the source-bleed bug class that mirrored the v0.34.1 P0 seal for exact lookups). Field names match `sourceScopeOpts(ctx)` output so handlers can spread directly. Both signatures back-compatible — callers passing no opts get the pre-fix behavior. Plus a stable tiebreaker `ORDER BY score DESC, page_id ASC, chunk_id ASC` added to `searchVector` in both engines: when two chunks tie on score (basis-vector eval fixtures), older `page_id` wins. Closes the planner-non-determinism regression class — adding any new index to `pages` could previously flip ranking on tied scores between branches.
|
||||
- `src/core/migrate.ts` v95 (v0.41.13.0, #1309) — `pages_dedup_partial_index` adds `CREATE INDEX pages_dedup_idx ON pages (source_id, content_hash) WHERE deleted_at IS NULL`. Postgres path uses `CREATE INDEX CONCURRENTLY` with `transaction: false` + pre-drops any invalid remnant from a prior failed attempt; PGLite uses plain `CREATE INDEX`. Powers `findDuplicatePage` hot path on 100K-page brains — query becomes O(log n) instead of O(n).
|
||||
- `src/cli.ts` extension (v0.41.13.0, #1422) — `dream` dispatch at lines 1063-1080 binds the caught engine-connect error and emits `[dream] WARNING: could not connect to DB (...)` to stderr before falling through to filesystem-only phases. Pre-fix the catch swallowed silently and the user saw `gbrain dream` exit 0 with no clue why DB phases were skipped. The `runDream(null, ...)` no-DB fallback is preserved — filesystem phases still run honestly. Pinned by `test/cli-dream-engine-warn.test.ts` (2 subprocess cases against good + bad DATABASE_URL).
|
||||
- `src/commands/autopilot.ts` extension (v0.37.7.0) — three changes for federated-brain co-existence and launchd hygiene. (1) **#1226 lockfile scope:** `LOCK_PATH` resolves via `gbrainPath('autopilot.lock')` so it honors `GBRAIN_HOME`. Two brains can run autopilot simultaneously without lock-stealing. Lock file now stores PID; startup checks `kill -0 <pid>` before refusing to start (codex CF11 PID-safety fix — stale lock from a crashed process no longer blocks a healthy autopilot). (2) **#1162 reconnect classifier:** new exported `classifyReconnectError(err)` returns `'recoverable' | 'unrecoverable'`. Unrecoverable causes the daemon to `process.exit(0)` and let launchd back off instead of the v0.37.6 loop that logged `config.database_url undefined` every 5s forever. (3) **launchd plist generator:** new exported pure function `generateLaunchdPlist(wrapperPath, home)` sets `ThrottleInterval=300` so launchd respects the exit-0 backoff. Both helpers pinned by `test/autopilot-lock-path.test.ts` + `test/autopilot-reconnect-classifier.test.ts`.
|
||||
- `src/core/oauth-provider.ts` + `src/commands/serve-http.ts` extension (v0.37.7.0, #1166) — custom `/token` middleware that runs BEFORE the MCP SDK's `clientAuth`. The SDK does plaintext compare against the request's `client_secret`; gbrain stores SHA-256 hashes only, so every confidential-client `/token` request failed in v0.37.0–v0.37.6. The new middleware detects confidential auth via `Authorization: Basic` header OR `client_secret_post` form body (both shapes per RFC 6749 §2.3.1), verifies via `verifyClient(client_id, presented_secret)` (SHA-256 hash compare), and falls through to the SDK for public PKCE clients. Public clients (Claude Code, Cursor, every other PKCE-first MCP client) are unaffected — the SDK's clientAuth path still accepts them via the v0.34.1.0 NULL-`client_secret_hash` normalization. Pinned by `test/oauth-confidential-client.test.ts` (both `client_secret_basic` and `client_secret_post` paths).
|
||||
- `src/core/sync.ts:pruneDir` extension (v0.37.7.0, #1169) — `pruneDir(name, parentDir?)` signature extended with optional `parentDir`. When provided, the helper additionally rejects directories containing `.git` as a FILE — the git submodule gitfile pattern (regular repos have `.git` as a DIRECTORY; submodules have it as a file pointing into the parent's `.git/modules/`). Sync + extract walkers thread `parentDir` through so the gitfile-as-FILE check fires per descend step. Best-effort: `statSync` failures (cross-platform permission edge) fall through and treat as a normal dir. Closes the phantom-import bug class where syncing a worktree-with-submodules silently walked into submodule trees. Pinned by `test/sync-walker-submodule.test.ts`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.41.12.0",
|
||||
"version": "0.41.13.0",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
|
||||
@@ -46,20 +46,33 @@ Do NOT switch brain when:
|
||||
- You're unsure. Stay in host, surface what you found, let the user point
|
||||
you at a specific brain.
|
||||
|
||||
## Source resolution chain (6-tier, v0.18.0+)
|
||||
## Source resolution chain (7-tier, v0.41.13+)
|
||||
|
||||
`gbrain` resolves the active source via `resolveSourceId()` in
|
||||
`src/core/source-resolver.ts`. Six tiers, highest priority first:
|
||||
`src/core/source-resolver.ts`. Seven tiers, highest priority first:
|
||||
|
||||
| # | Tier | Signal |
|
||||
|---|---|---|
|
||||
| 1 | `flag` | Explicit `--source <id>` CLI flag (or `--source-id <id>` on `gbrain extract`) |
|
||||
| 1 | `flag` | Explicit `--source <id>` CLI flag (or `--source-id <id>` on `gbrain extract` / `gbrain import`) |
|
||||
| 2 | `env` | `GBRAIN_SOURCE` environment variable |
|
||||
| 3 | `dotfile` | `.gbrain-source` file in CWD or any ancestor directory |
|
||||
| 4 | `local_path` | A registered source whose `local_path` contains CWD (longest prefix wins) |
|
||||
| 5 | `brain_default` | Brain-level `sources.default` config key |
|
||||
| 5 | `brain_default` | Brain-level `sources.default` config key (explicit user intent) |
|
||||
| 5.5 | `sole_non_default` | When tiers 1–5 missed AND exactly one registered source has a `local_path` AND isn't `'default'`, auto-route to it. Fires a one-time stderr nudge per CLI invocation. Suppress with `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1`. |
|
||||
| 6 | `seed_default` | Literal `'default'` (always exists post-migration v16) |
|
||||
|
||||
**v0.41.13 tier 5.5 (`sole_non_default`):** added for single-source brains
|
||||
(typical for users with one Obsidian vault, one notes folder, one project).
|
||||
Pre-fix, `gbrain sync` from `/tmp` against a brain registering only
|
||||
`studiovault` silently routed to `'default'` and every edit failed at
|
||||
`createVersion` because the slug didn't exist there. The tier auto-routes
|
||||
to the obvious single answer. Multi-source brains (2+ non-default registered)
|
||||
still fall through to `seed_default` and require explicit `--source`.
|
||||
|
||||
Placement AFTER `brain_default` is deliberate: a user who explicitly set
|
||||
`sources.default` via `gbrain sources default <id>` has stated intent that
|
||||
wins over the auto-route. Archived sources are excluded from the count.
|
||||
|
||||
**v0.37.7.0 tooling:**
|
||||
|
||||
- `gbrain sources current [--json]` echoes the resolved source AND
|
||||
|
||||
15
src/cli.ts
15
src/cli.ts
@@ -1068,13 +1068,22 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
if (command === 'dream') {
|
||||
// Dream mirrors doctor's pattern: filesystem phases run without a DB,
|
||||
// so an engine connection failure is non-fatal. runCycle honestly
|
||||
// reports DB phases as skipped when engine is null.
|
||||
// reports DB phases as skipped when engine is null. v0.41.13 (#1422):
|
||||
// bind + surface the error on stderr so the user knows WHY DB phases
|
||||
// were skipped instead of seeing a silent "lint + backlinks done"
|
||||
// and assuming the cycle actually ran. Pre-fix, foxhoundinc reported
|
||||
// the cycle exiting 0 on PostgreSQL with every DB phase silently no-op.
|
||||
const { runDream } = await import('./commands/dream.ts');
|
||||
let eng: BrainEngine | null = null;
|
||||
try {
|
||||
eng = await connectEngine();
|
||||
} catch {
|
||||
// DB unavailable — lint + backlinks still run against the brain dir.
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[dream] WARNING: could not connect to DB (${msg}). ` +
|
||||
`Running filesystem-only phases (lint, backlinks, extract). ` +
|
||||
`DB-dependent phases (sync, embed, synthesize, etc.) will report as skipped.\n`
|
||||
);
|
||||
}
|
||||
try {
|
||||
await runDream(eng, args);
|
||||
|
||||
@@ -115,7 +115,34 @@ export async function runImport(
|
||||
// CLI callers' flag wins over opts when both are set.
|
||||
const sourceIdIdx = args.indexOf('--source-id');
|
||||
const flagSourceId = sourceIdIdx !== -1 ? args[sourceIdIdx + 1] : null;
|
||||
const sourceId = flagSourceId ?? opts.sourceId;
|
||||
let sourceId: string | undefined = flagSourceId ?? opts.sourceId;
|
||||
|
||||
// v0.41.13 (#1434): when no explicit source / env / opts.sourceId is set,
|
||||
// fall through to the resolver so the new sole_non_default tier (5.5) can
|
||||
// auto-route to the only registered non-default source. Pre-fix, import
|
||||
// followed the explicit-only design from PR #707 and silently routed
|
||||
// every import to 'default', mirroring the sync bug class.
|
||||
//
|
||||
// Resolution chain (full 7 tiers): flag → env → dotfile → local_path →
|
||||
// brain_default → sole_non_default → seed_default. The nudge fires only
|
||||
// when the resolver returns tier='sole_non_default', so explicit users
|
||||
// see no behavior change.
|
||||
if (!sourceId && process.env.GBRAIN_SOURCE) {
|
||||
const { resolveSourceId } = await import('../core/source-resolver.ts');
|
||||
sourceId = await resolveSourceId(engine, null);
|
||||
} else if (!sourceId) {
|
||||
const { resolveSourceWithTier, formatSoleNonDefaultNudge } = await import('../core/source-resolver.ts');
|
||||
const resolved = await resolveSourceWithTier(engine, null);
|
||||
// Only adopt the resolution when it improves on the seed_default
|
||||
// fallback — that preserves the v0.30.x "default-only when unset"
|
||||
// contract for the common case AND opens the sole_non_default
|
||||
// auto-route for the single-source-brain case.
|
||||
if (resolved.tier === 'sole_non_default') {
|
||||
sourceId = resolved.source_id;
|
||||
const nudge = formatSoleNonDefaultNudge(sourceId);
|
||||
if (nudge) process.stderr.write(nudge + '\n');
|
||||
}
|
||||
}
|
||||
const workersIdx = args.indexOf('--workers');
|
||||
const workersArg = workersIdx !== -1 ? args[workersIdx + 1] : null;
|
||||
// v0.22.13 (PR #490 Q2): shared parseWorkers helper rejects bad input
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createInterface } from 'readline';
|
||||
import {
|
||||
buildSyncManifest,
|
||||
isSyncable,
|
||||
unsyncableReason,
|
||||
resolveSlugForPath,
|
||||
recordSyncFailures,
|
||||
unacknowledgedSyncFailures,
|
||||
@@ -932,12 +933,31 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// became un-syncable (e.g., moved under `.gitignore` or filtered by
|
||||
// strategy=markdown) deletes the actual code-slug page, not a ghost
|
||||
// markdown-slug that never existed.
|
||||
//
|
||||
// v0.41.13 (#1433): the original cleanup loop deleted EVERY pre-existing
|
||||
// page for unsyncable-modified paths, including `log.md`, `schema.md`,
|
||||
// `index.md`, `README.md` — files that fail `isSyncable` precisely
|
||||
// because they're metafiles by convention, not because the user
|
||||
// "removed" them from the strategy. infiniteGameExp's domain `log.md`
|
||||
// pages had been indexed by an older gbrain version (or via direct
|
||||
// put_page) and were silently dropped on every subsequent sync. The
|
||||
// fix uses `unsyncableReason` (factored from `isSyncable` so they
|
||||
// cannot drift) to skip the delete when the reason is `'metafile'`.
|
||||
//
|
||||
// Honest scope: this guard only fixes the `manifest.modified` case.
|
||||
// `manifest.deleted` is filtered upstream at sync.ts:757 via the same
|
||||
// `isSyncable` call, so `rm log.md` followed by sync also doesn't
|
||||
// delete the page. That's the same pre-fix behavior — removing the
|
||||
// page requires `gbrain pages purge-deleted` or a direct MCP delete.
|
||||
// Filed as v0.42+ follow-up for a `gbrain pages remove <slug>` surface.
|
||||
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p, syncOpts));
|
||||
// v0.18.0+ multi-source: scope getPage + deletePage to opts.sourceId so
|
||||
// unsyncable cleanup in source A doesn't accidentally sweep same-slug
|
||||
// pages in sources B/C/D.
|
||||
const pageOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||||
for (const path of unsyncableModified) {
|
||||
// v0.41.13 #1433: never delete on metafile classification.
|
||||
if (unsyncableReason(path, syncOpts) === 'metafile') continue;
|
||||
const slug = await resolveSlugByPathOrSourcePath(engine, path, opts.sourceId);
|
||||
try {
|
||||
const existing = await engine.getPage(slug, pageOpts);
|
||||
@@ -1695,11 +1715,25 @@ See also:
|
||||
// v0.18.0 Step 5: --source resolves to a sources(id) row. Falls back
|
||||
// to pre-v0.17 global config (sync.repo_path + sync.last_commit) when
|
||||
// no flag, no env, no dotfile is present.
|
||||
//
|
||||
// v0.41.13 (#1434): always call the resolver, not just when explicit/env
|
||||
// is set. Pre-fix, `gbrain sync` without --source skipped resolution and
|
||||
// left sourceId undefined — which the engine treated as the seeded
|
||||
// 'default' source. Users with a single non-default registered source
|
||||
// (studiovault, etc.) silently routed every write to a source holding
|
||||
// 0 pages, then createVersion threw on the slug lookup.
|
||||
//
|
||||
// The resolver's new `sole_non_default` tier (5.5) routes those
|
||||
// single-source brains to the right place automatically; the nudge
|
||||
// surfaces the auto-route to stderr so the user knows what happened
|
||||
// and can pass --source to override if needed.
|
||||
const explicitSource = args.find((a, i) => args[i - 1] === '--source') || null;
|
||||
let sourceId: string | undefined = undefined;
|
||||
if (explicitSource || process.env.GBRAIN_SOURCE) {
|
||||
const { resolveSourceId } = await import('../core/source-resolver.ts');
|
||||
sourceId = await resolveSourceId(engine, explicitSource);
|
||||
const { resolveSourceWithTier, formatSoleNonDefaultNudge } = await import('../core/source-resolver.ts');
|
||||
const resolved = await resolveSourceWithTier(engine, explicitSource);
|
||||
const sourceId: string = resolved.source_id;
|
||||
if (resolved.tier === 'sole_non_default') {
|
||||
const nudge = formatSoleNonDefaultNudge(sourceId);
|
||||
if (nudge) process.stderr.write(nudge + '\n');
|
||||
}
|
||||
|
||||
// v0.19.0 — `sync --all` iterates all registered sources with a
|
||||
|
||||
@@ -623,6 +623,35 @@ export interface BrainEngine {
|
||||
* duplicate at (default, slug). Multi-source brains MUST pass sourceId.
|
||||
*/
|
||||
putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page>;
|
||||
/**
|
||||
* v0.41.13 (#1309) — identity-based dedup pre-check for the import pipeline.
|
||||
*
|
||||
* Returns the first matching `{slug, id}` whose `(source_id, …)` matches
|
||||
* the supplied identity signal, OR null when nothing matches.
|
||||
*
|
||||
* Identity precedence (a row matches if EITHER fires):
|
||||
* - `content_hash = $hash` AND `deleted_at IS NULL`
|
||||
* - `frontmatter->>'id' = $frontmatterId` AND `$frontmatterId IS NOT NULL`
|
||||
* AND `deleted_at IS NULL`
|
||||
*
|
||||
* Background: the overlapping-ingest-roots bug class (infiniteGameExp,
|
||||
* issue #1309) created two pages per file when a user ran `gbrain import
|
||||
* /vault/Subdir/` then `gbrain import /vault/` — the slug-shape changed
|
||||
* but the content + external ID were identical. Pre-fix, the import
|
||||
* pipeline dedup-checked by `getPage(slug)` alone and missed the
|
||||
* cross-slug duplicate. This method gives the importer a deterministic
|
||||
* way to identify true duplicates BEFORE insert.
|
||||
*
|
||||
* Per codex review: the optional `?` shape lets existing test doubles
|
||||
* compile without changes. Callers must defensively check
|
||||
* `engine.findDuplicatePage?.(...)` and fall through on undefined.
|
||||
* `deleted_at IS NULL` is deliberate — a soft-deleted page should NOT
|
||||
* block a legitimate re-import under a new slug.
|
||||
*/
|
||||
findDuplicatePage?(
|
||||
sourceId: string,
|
||||
opts: { hash: string; frontmatterId?: string | null },
|
||||
): Promise<{ slug: string; id: number } | null>;
|
||||
/**
|
||||
* Hard-delete a page row. Cascades to content_chunks, page_links,
|
||||
* chunk_relations via existing FK ON DELETE CASCADE.
|
||||
@@ -663,7 +692,20 @@ export interface BrainEngine {
|
||||
* `filters.includeDeleted: true` to surface them.
|
||||
*/
|
||||
listPages(filters?: PageFilters): Promise<Page[]>;
|
||||
resolveSlugs(partial: string): Promise<string[]>;
|
||||
/**
|
||||
* Fuzzy slug resolver.
|
||||
*
|
||||
* v0.41.13 (#1436): `opts.sourceId` scopes the search to a single source;
|
||||
* `opts.sourceIds` to an array (federated_read OAuth tier). Pre-fix the
|
||||
* resolver was unscoped, so MCP `get_page` with `fuzzy: true` would
|
||||
* return candidates from sources the caller couldn't actually access.
|
||||
* Source-bleed via fuzzy resolution was the bug class infiniteGameExp
|
||||
* reported as #1436. When neither opt is set, the original unscoped
|
||||
* behavior is preserved for back-compat with internal callers (the
|
||||
* `gbrain query --resolve` CLI path, etc.). Field names match the
|
||||
* `sourceScopeOpts(ctx)` helper output so callers can spread directly.
|
||||
*/
|
||||
resolveSlugs(partial: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]>;
|
||||
/**
|
||||
* Returns the slug of every page in the brain. Used by batch commands as a
|
||||
* mutation-immune iteration source (alternative to listPages OFFSET pagination,
|
||||
|
||||
@@ -425,6 +425,68 @@ export async function importFromContent(
|
||||
return { slug, status: 'skipped', chunks: 0, parsedPage };
|
||||
}
|
||||
|
||||
// v0.41.13 (#1309) — identity-based cross-slug dedup pre-check.
|
||||
//
|
||||
// Catches the overlapping-ingest-roots bug class: when a user runs
|
||||
// `gbrain import /vault/Subdir/` then later `gbrain import /vault/`,
|
||||
// the same file is ingested under two different slugs (e.g.
|
||||
// `vault/subdir/note` and `vault/note`). The slug-only check above
|
||||
// misses it because the slugs differ; this check identifies the true
|
||||
// duplicate by content_hash OR external frontmatter.id (granola UUID,
|
||||
// ULID, etc.).
|
||||
//
|
||||
// Posture (codex review):
|
||||
// - SKIP only when frontmatter.id matches (true external duplicate).
|
||||
// - WARN-ALWAYS when content_hash matches but identity differs (two
|
||||
// intentional pages that happen to share text — templates, daily
|
||||
// logs). User decides whether to investigate.
|
||||
// - FAIL CLOSED on lookup error: a DB throw means we cannot verify
|
||||
// uniqueness, so throw rather than silently allow a duplicate.
|
||||
//
|
||||
// Soft-deleted rows are excluded at the engine layer (`deleted_at IS NULL`)
|
||||
// so a tombstoned page doesn't block a legitimate re-import.
|
||||
// Test doubles that don't implement `findDuplicatePage` fall through
|
||||
// via the `?.` shape — no failure mode for fake engines.
|
||||
const fmId = (parsed.frontmatter as Record<string, unknown> | undefined)?.id;
|
||||
const fmIdStr = typeof fmId === 'string' && fmId.length > 0 ? fmId : null;
|
||||
if (!opts.forceRechunk && engine.findDuplicatePage) {
|
||||
let dup: { slug: string; id: number } | null = null;
|
||||
try {
|
||||
dup = await engine.findDuplicatePage(sourceId ?? 'default', {
|
||||
hash,
|
||||
frontmatterId: fmIdStr,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`[import] dedup pre-check failed for ${opts.sourcePath ?? slug}: ` +
|
||||
`${(err as Error).message}. Re-run import after DB recovery.`
|
||||
);
|
||||
}
|
||||
if (dup && dup.slug !== slug) {
|
||||
// Look up the duplicate page so we can compare frontmatter.id.
|
||||
const dupPage = await engine.getPage(dup.slug, sourceId ? { sourceId } : undefined);
|
||||
const dupFmId = (dupPage?.frontmatter as Record<string, unknown> | undefined)?.id;
|
||||
const dupFmIdStr = typeof dupFmId === 'string' && dupFmId.length > 0 ? dupFmId : null;
|
||||
const sameExternalId = fmIdStr !== null && dupFmIdStr === fmIdStr;
|
||||
if (sameExternalId) {
|
||||
// True duplicate (same external ID). Skip + log to stderr.
|
||||
process.stderr.write(
|
||||
`[import] skipping ${opts.sourcePath ?? slug}: identical to ${dup.slug} ` +
|
||||
`(frontmatter.id=${fmIdStr}) in source ${sourceId ?? 'default'}. ` +
|
||||
`Pass --force-rechunk to override.\n`
|
||||
);
|
||||
return { slug: dup.slug, status: 'skipped', chunks: 0, parsedPage };
|
||||
}
|
||||
// Same content_hash, different (or missing) frontmatter.id.
|
||||
// Surface a warning but proceed with the insert — they may be
|
||||
// legitimate independent pages that happen to share text.
|
||||
process.stderr.write(
|
||||
`[import] WARNING: ${opts.sourcePath ?? slug} shares content_hash with ${dup.slug} ` +
|
||||
`(${hash.slice(0, 8)}) but has different frontmatter.id. Indexing both.\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk compiled_truth and timeline.
|
||||
// v0.41 content-sanity soft-block: if the gate marked this page as
|
||||
// embed-skipped (oversize without junk-pattern), skip chunking
|
||||
|
||||
@@ -4501,6 +4501,64 @@ export const MIGRATIONS: Migration[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 97,
|
||||
name: 'pages_dedup_partial_index',
|
||||
// v0.41.13 (#1309) — partial index for findDuplicatePage's hot path.
|
||||
//
|
||||
// Codex review of the original plan caught "no new index is hand-wavy":
|
||||
// findDuplicatePage runs once per imported file. On a 100K-page brain
|
||||
// syncing thousands of files, an unindexed sequential scan per
|
||||
// invocation is O(n²) on import wallclock.
|
||||
//
|
||||
// Partial index excludes soft-deleted rows so the same-source dedup
|
||||
// path (which already filters `deleted_at IS NULL`) gets an index-only
|
||||
// scan. Composite key matches the WHERE clause shape.
|
||||
//
|
||||
// Postgres-only: PGLite has no concurrent writers, so the engine-wide
|
||||
// SHARE lock that motivates CONCURRENTLY doesn't apply. PGLite
|
||||
// re-uses plain CREATE INDEX via the `sqlFor.pglite` branch.
|
||||
//
|
||||
// The Postgres path uses CREATE INDEX CONCURRENTLY (with `transaction:
|
||||
// false` so postgres.js doesn't wrap an implicit BEGIN) and pre-drops
|
||||
// any invalid remnant from a prior failed CONCURRENTLY attempt.
|
||||
//
|
||||
// Slot history: originally v95, bumped to v96 after master's #1442
|
||||
// landed (links CHECK widening), then bumped to v97 after master's
|
||||
// v0.41.11.0 (#1446) claimed v96 for the facts conversation index.
|
||||
// Migration content unchanged across renumbers.
|
||||
sql: '',
|
||||
transaction: false,
|
||||
handler: async (engine) => {
|
||||
if (engine.kind === 'postgres') {
|
||||
await engine.runMigration(
|
||||
97,
|
||||
`DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE c.relname = 'pages_dedup_idx' AND NOT i.indisvalid
|
||||
) THEN
|
||||
EXECUTE 'DROP INDEX CONCURRENTLY IF EXISTS pages_dedup_idx';
|
||||
END IF;
|
||||
END $$;`
|
||||
);
|
||||
await engine.runMigration(
|
||||
97,
|
||||
`CREATE INDEX CONCURRENTLY IF NOT EXISTS pages_dedup_idx
|
||||
ON pages (source_id, content_hash)
|
||||
WHERE deleted_at IS NULL;`
|
||||
);
|
||||
} else {
|
||||
await engine.runMigration(
|
||||
97,
|
||||
`CREATE INDEX IF NOT EXISTS pages_dedup_idx
|
||||
ON pages (source_id, content_hash)
|
||||
WHERE deleted_at IS NULL;`
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -467,12 +467,19 @@ const get_page: Operation = {
|
||||
// the cross-source view, preserving pre-v0.31.8 behavior. MCP callers
|
||||
// (stdio + HTTP) populate ctx.sourceId via the transport layer.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
// v0.41.13 #1436: fuzzy resolveSlugs ALSO needs source scope — pre-fix
|
||||
// it was unscoped, so a remote `get_page` with `fuzzy: true` could
|
||||
// return candidates from sources outside ctx.auth.allowedSources /
|
||||
// ctx.sourceId. sourceScopeOpts(ctx) is the canonical precedence
|
||||
// ladder (federated array > scalar > nothing) shared with every other
|
||||
// read-side handler.
|
||||
const fuzzyScope = sourceScopeOpts(ctx);
|
||||
|
||||
let page = await ctx.engine.getPage(slug, { includeDeleted, ...sourceOpts });
|
||||
let resolved_slug: string | undefined;
|
||||
|
||||
if (!page && fuzzy) {
|
||||
const candidates = await ctx.engine.resolveSlugs(slug);
|
||||
const candidates = await ctx.engine.resolveSlugs(slug, fuzzyScope);
|
||||
if (candidates.length === 1) {
|
||||
page = await ctx.engine.getPage(candidates[0], { includeDeleted, ...sourceOpts });
|
||||
resolved_slug = candidates[0];
|
||||
|
||||
@@ -810,6 +810,27 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return rowToPage(rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.13 (#1309) — identity-based dedup pre-check.
|
||||
* See `BrainEngine.findDuplicatePage` for the contract.
|
||||
*/
|
||||
async findDuplicatePage(
|
||||
sourceId: string,
|
||||
opts: { hash: string; frontmatterId?: string | null },
|
||||
): Promise<{ slug: string; id: number } | null> {
|
||||
const fmId = opts.frontmatterId ?? null;
|
||||
const sql = `SELECT id, slug FROM pages
|
||||
WHERE source_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND (content_hash = $2 OR (frontmatter->>'id' = $3 AND $3 IS NOT NULL))
|
||||
ORDER BY id
|
||||
LIMIT 1`;
|
||||
const { rows } = await this.db.query(sql, [sourceId, opts.hash, fmId]);
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0] as { id: number | string; slug: string };
|
||||
return { slug: r.slug, id: Number(r.id) };
|
||||
}
|
||||
|
||||
async putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page> {
|
||||
slug = validateSlug(slug);
|
||||
const hash = page.content_hash || contentHash(page);
|
||||
@@ -1271,20 +1292,39 @@ export class PGLiteEngine implements BrainEngine {
|
||||
}));
|
||||
}
|
||||
|
||||
async resolveSlugs(partial: string): Promise<string[]> {
|
||||
async resolveSlugs(partial: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
|
||||
// v0.41.13 #1436: source scope. When opts.sourceIds is set
|
||||
// (federated_read OAuth tier), filter via `source_id = ANY($N::text[])`.
|
||||
// When opts.sourceId is set (scalar single-source tier), filter via
|
||||
// `source_id = $N`. When neither is set, preserve the pre-fix unscoped
|
||||
// behavior so internal CLI callers (`gbrain query --resolve` etc.)
|
||||
// continue to walk every source.
|
||||
const sources = opts?.sourceIds ?? null;
|
||||
const scalar = opts?.sourceId ?? null;
|
||||
const scopeSql = sources
|
||||
? ` AND source_id = ANY($${'__N__'}::text[])`
|
||||
: scalar
|
||||
? ` AND source_id = $${'__N__'}`
|
||||
: '';
|
||||
|
||||
// Try exact match first
|
||||
const exact = await this.db.query('SELECT slug FROM pages WHERE slug = $1', [partial]);
|
||||
const exactSql = `SELECT slug FROM pages WHERE slug = $1 AND deleted_at IS NULL${scopeSql.replace('__N__', '2')}`;
|
||||
const exactParams: unknown[] = sources ? [partial, sources] : scalar ? [partial, scalar] : [partial];
|
||||
const exact = await this.db.query(exactSql, exactParams);
|
||||
if (exact.rows.length > 0) return [(exact.rows[0] as { slug: string }).slug];
|
||||
|
||||
// Fuzzy match via pg_trgm
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT slug, similarity(title, $1) AS sim
|
||||
const fuzzySql = `SELECT slug, similarity(title, $1) AS sim
|
||||
FROM pages
|
||||
WHERE title % $1 OR slug ILIKE $2
|
||||
WHERE deleted_at IS NULL AND (title % $1 OR slug ILIKE $2)${scopeSql.replace('__N__', '3')}
|
||||
ORDER BY sim DESC
|
||||
LIMIT 5`,
|
||||
[partial, '%' + partial + '%']
|
||||
);
|
||||
LIMIT 5`;
|
||||
const fuzzyParams: unknown[] = sources
|
||||
? [partial, '%' + partial + '%', sources]
|
||||
: scalar
|
||||
? [partial, '%' + partial + '%', scalar]
|
||||
: [partial, '%' + partial + '%'];
|
||||
const { rows } = await this.db.query(fuzzySql, fuzzyParams);
|
||||
return (rows as { slug: string }[]).map(r => r.slug);
|
||||
}
|
||||
|
||||
@@ -1744,7 +1784,13 @@ export class PGLiteEngine implements BrainEngine {
|
||||
SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = hc.page_id
|
||||
) THEN true ELSE false END AS stale
|
||||
FROM hnsw_candidates hc
|
||||
ORDER BY score DESC
|
||||
-- v0.41.13: stable tiebreaker. When two chunks share a score (same
|
||||
-- source-prefix boost + same cosine distance, the basis-vector + same-
|
||||
-- source-prefix case in eval fixtures), older page_id wins. Without
|
||||
-- this, planner choice + index presence can flip ordering between
|
||||
-- master and feature branches that add unrelated indexes — see the
|
||||
-- pages_dedup_idx (v95) regression that motivated this.
|
||||
ORDER BY score DESC, hc.page_id ASC, hc.chunk_id ASC
|
||||
LIMIT $3
|
||||
OFFSET $4`,
|
||||
params
|
||||
|
||||
@@ -809,6 +809,29 @@ export class PostgresEngine implements BrainEngine {
|
||||
return rowToPage(rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.13 (#1309) — identity-based dedup pre-check.
|
||||
* See `BrainEngine.findDuplicatePage` for the contract.
|
||||
*/
|
||||
async findDuplicatePage(
|
||||
sourceId: string,
|
||||
opts: { hash: string; frontmatterId?: string | null },
|
||||
): Promise<{ slug: string; id: number } | null> {
|
||||
const sql = this.sql;
|
||||
const fmId = opts.frontmatterId ?? null;
|
||||
const rows = await sql`
|
||||
SELECT id, slug FROM pages
|
||||
WHERE source_id = ${sourceId}
|
||||
AND deleted_at IS NULL
|
||||
AND (content_hash = ${opts.hash} OR (frontmatter->>'id' = ${fmId} AND ${fmId}::text IS NOT NULL))
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0] as { id: number | string; slug: string };
|
||||
return { slug: r.slug, id: Number(r.id) };
|
||||
}
|
||||
|
||||
async putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page> {
|
||||
slug = validateSlug(slug);
|
||||
const sql = this.sql;
|
||||
@@ -1275,18 +1298,31 @@ export class PostgresEngine implements BrainEngine {
|
||||
}));
|
||||
}
|
||||
|
||||
async resolveSlugs(partial: string): Promise<string[]> {
|
||||
async resolveSlugs(partial: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
|
||||
const sql = this.sql;
|
||||
|
||||
// v0.41.13 #1436: source scope via postgres.js tagged-template
|
||||
// fragments. When neither opt is set the resolver stays unscoped
|
||||
// for back-compat with internal callers. The `deleted_at IS NULL`
|
||||
// filter excludes soft-deleted rows (v0.26.5) from fuzzy candidates
|
||||
// — they're not legitimate match targets for a remote `get_page`.
|
||||
const sources = opts?.sourceIds ?? null;
|
||||
const scalar = opts?.sourceId ?? null;
|
||||
const scopeFragment = sources
|
||||
? sql` AND source_id = ANY(${sources}::text[])`
|
||||
: scalar
|
||||
? sql` AND source_id = ${scalar}`
|
||||
: sql``;
|
||||
|
||||
// Try exact match first
|
||||
const exact = await sql`SELECT slug FROM pages WHERE slug = ${partial}`;
|
||||
const exact = await sql`SELECT slug FROM pages WHERE slug = ${partial} AND deleted_at IS NULL${scopeFragment}`;
|
||||
if (exact.length > 0) return [exact[0].slug];
|
||||
|
||||
// Fuzzy match via pg_trgm
|
||||
const fuzzy = await sql`
|
||||
SELECT slug, similarity(title, ${partial}) AS sim
|
||||
FROM pages
|
||||
WHERE title % ${partial} OR slug ILIKE ${'%' + partial + '%'}
|
||||
WHERE deleted_at IS NULL AND (title % ${partial} OR slug ILIKE ${'%' + partial + '%'})${scopeFragment}
|
||||
ORDER BY sim DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
@@ -1722,7 +1758,9 @@ export class PostgresEngine implements BrainEngine {
|
||||
raw_score * ${sourceFactorCaseOnSlug} AS score,
|
||||
false AS stale
|
||||
FROM hnsw_candidates
|
||||
ORDER BY score DESC
|
||||
-- v0.41.13: stable tiebreaker for tied scores. See pglite-engine for
|
||||
-- rationale (basis-vector test fixtures, planner-dependent ordering).
|
||||
ORDER BY score DESC, page_id ASC, chunk_id ASC
|
||||
LIMIT ${limitParam}
|
||||
OFFSET ${offsetParam}
|
||||
`;
|
||||
|
||||
@@ -127,11 +127,68 @@ export async function resolveSourceId(
|
||||
return globalDefault;
|
||||
}
|
||||
|
||||
// 5.5. Single-non-default-source convenience (v0.41.13, #1434).
|
||||
// When NO brain_default is set AND exactly one registered source has
|
||||
// local_path set AND it isn't 'default', route there. This closes
|
||||
// the "532 silent edit failures" bug class where users with a single
|
||||
// Vault-mounted source ran `gbrain sync` without --source and routed
|
||||
// to source_id='default' (which held 0 pages). Conservative: fires
|
||||
// only when there's literally one option — multi-source brains still
|
||||
// require explicit --source or sources.default.
|
||||
//
|
||||
// Placed AFTER brain_default per codex review: a user who explicitly
|
||||
// set sources.default has stated intent, that wins over auto-routing.
|
||||
const soleNonDefault = await pickSoleNonDefaultSource(engine);
|
||||
if (soleNonDefault) return soleNonDefault;
|
||||
|
||||
// 6. Fallback: the seeded 'default' source. Always exists post-migration
|
||||
// v16 so this is a safe terminal.
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the SINGLE registered non-default source with a
|
||||
* local_path, when exactly one such row exists. Returns null when:
|
||||
* - zero non-default sources are registered (fresh install)
|
||||
* - 2+ non-default sources are registered (ambiguous — user must pick)
|
||||
* - the only non-default source has a NULL local_path (no on-disk shape)
|
||||
* - the only registered source IS 'default'
|
||||
*
|
||||
* Excludes archived sources (`archived = false`) so a soft-deleted source
|
||||
* doesn't auto-resolve. Shared by `resolveSourceId` and `resolveSourceWithTier`
|
||||
* so the heuristic can't drift between the two entry points.
|
||||
*/
|
||||
async function pickSoleNonDefaultSource(engine: BrainEngine): Promise<string | null> {
|
||||
// archived column was added in v34 (v0.26.5). Older brains may not have
|
||||
// it — fall back to the un-archived query in that case via try/catch.
|
||||
let rows: Array<{ id: string }>;
|
||||
try {
|
||||
rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false`,
|
||||
);
|
||||
} catch {
|
||||
rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default'`,
|
||||
);
|
||||
}
|
||||
if (rows.length === 1) return rows[0].id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the one-line stderr nudge that fires when source resolution falls
|
||||
* through to the `sole_non_default` tier. Returns null when suppressed via
|
||||
* `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1` (CI / scripted-pipeline ergonomics).
|
||||
*
|
||||
* Single source of truth so the wording stays consistent across every CLI
|
||||
* dispatch site that fires the nudge (sync, import, extract, etc.). Callers
|
||||
* print to stderr; this helper just builds the line.
|
||||
*/
|
||||
export function formatSoleNonDefaultNudge(sourceId: string): string | null {
|
||||
if (process.env.GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE === '1') return null;
|
||||
return `[gbrain] routing to source '${sourceId}' (sole non-default source registered; pass --source to override).`;
|
||||
}
|
||||
|
||||
async function assertSourceExists(engine: BrainEngine, id: string): Promise<void> {
|
||||
const rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE id = $1`,
|
||||
@@ -195,6 +252,7 @@ export const SOURCE_TIER_NAMES = [
|
||||
'dotfile',
|
||||
'local_path',
|
||||
'brain_default',
|
||||
'sole_non_default',
|
||||
'seed_default',
|
||||
] as const;
|
||||
export type SourceTier = typeof SOURCE_TIER_NAMES[number];
|
||||
@@ -264,6 +322,18 @@ export async function resolveSourceWithTier(
|
||||
return { source_id: globalDefault, tier: 'brain_default', detail: 'sources.default config' };
|
||||
}
|
||||
|
||||
// 5.5. Single-non-default-source convenience (v0.41.13, #1434).
|
||||
// See resolveSourceId for the design rationale. Same helper, same
|
||||
// precedence (AFTER brain_default).
|
||||
const soleNonDefault = await pickSoleNonDefaultSource(engine);
|
||||
if (soleNonDefault) {
|
||||
return {
|
||||
source_id: soleNonDefault,
|
||||
tier: 'sole_non_default',
|
||||
detail: `only non-default registered source with local_path`,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Fallback: seeded 'default' source.
|
||||
return { source_id: 'default', tier: 'seed_default' };
|
||||
}
|
||||
|
||||
@@ -287,29 +287,85 @@ export function pruneDir(name: string, parentDir?: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a file path to determine if it should be synced to GBrain.
|
||||
* Strategy-aware: 'markdown' (default) = .md/.mdx only, 'code' = code files only, 'auto' = both.
|
||||
* Discriminator for WHY a path is not syncable. Returned by `unsyncableReason`
|
||||
* so the sync cleanup loop in `commands/sync.ts` can distinguish "metafile we
|
||||
* intentionally exclude" from "user removed this file from the strategy".
|
||||
*
|
||||
* v0.41.13 (#1433): pre-fix, the cleanup loop in performSync treated all
|
||||
* unsyncable-modified paths the same and DELETED any pre-existing page for
|
||||
* them. That silently dropped `log.md` / `schema.md` / `README.md` pages
|
||||
* that had been indexed by older gbrain versions (or via direct put_page).
|
||||
* The fix guards that loop on `unsyncableReason(...) === 'metafile'` and
|
||||
* preserves those rows.
|
||||
*/
|
||||
export function isSyncable(path: string, opts: SyncableOptions = {}): boolean {
|
||||
export type SyncableReason =
|
||||
| 'metafile'
|
||||
| 'strategy'
|
||||
| 'pruned-dir'
|
||||
| 'include-glob-miss'
|
||||
| 'exclude-glob-hit';
|
||||
|
||||
/**
|
||||
* Canonical metafile basenames the markdown sync strategy intentionally
|
||||
* skips. Exported so the cleanup-loop guard in `commands/sync.ts` can
|
||||
* surface them in user-facing logs / docs without re-declaring the list.
|
||||
*
|
||||
* These files are append-only domain logs / index pages / boilerplate
|
||||
* READMEs — not typed brain pages — by convention. A user who genuinely
|
||||
* wants to index one of these basenames as a page should rename it.
|
||||
*/
|
||||
export const SYNC_SKIP_FILES = ['schema.md', 'index.md', 'log.md', 'README.md'] as const;
|
||||
|
||||
/**
|
||||
* Internal classifier. Returns null when the path IS syncable, or a tagged
|
||||
* SyncableReason explaining why it isn't. The single source of truth that
|
||||
* both `isSyncable` (boolean) and `unsyncableReason` (tagged) call.
|
||||
*
|
||||
* Codex review caught the drift risk if `unsyncableReason` were an independent
|
||||
* re-implementation. Funnelling both public APIs through `classifySync` means
|
||||
* TypeScript enforces consistency at the compiler level.
|
||||
*/
|
||||
function classifySync(path: string, opts: SyncableOptions = {}): SyncableReason | null {
|
||||
const strategy = opts.strategy || 'markdown';
|
||||
|
||||
if (!isAllowedByStrategy(path, strategy)) return false;
|
||||
if (!isAllowedByStrategy(path, strategy)) return 'strategy';
|
||||
|
||||
// Skip every path segment that pruneDir would block walkers from descending
|
||||
// into. Catches hidden dirs (`.git`, `.obsidian`), `.raw/` sidecars,
|
||||
// `node_modules/` (latent bug fix), and `ops/` at any depth.
|
||||
const segments = path.split('/');
|
||||
if (segments.some(p => !pruneDir(p))) return false;
|
||||
if (segments.some(p => !pruneDir(p))) return 'pruned-dir';
|
||||
|
||||
// Skip meta files that aren't pages
|
||||
const skipFiles = ['schema.md', 'index.md', 'log.md', 'README.md'];
|
||||
const basename = segments[segments.length - 1] || '';
|
||||
if (skipFiles.includes(basename)) return false;
|
||||
if ((SYNC_SKIP_FILES as readonly string[]).includes(basename)) return 'metafile';
|
||||
|
||||
if (opts.include && opts.include.length > 0 && !matchesAnyGlob(path, opts.include)) return false;
|
||||
if (opts.exclude && opts.exclude.length > 0 && matchesAnyGlob(path, opts.exclude)) return false;
|
||||
if (opts.include && opts.include.length > 0 && !matchesAnyGlob(path, opts.include)) return 'include-glob-miss';
|
||||
if (opts.exclude && opts.exclude.length > 0 && matchesAnyGlob(path, opts.exclude)) return 'exclude-glob-hit';
|
||||
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a file path to determine if it should be synced to GBrain.
|
||||
* Strategy-aware: 'markdown' (default) = .md/.mdx only, 'code' = code files only, 'auto' = both.
|
||||
*/
|
||||
export function isSyncable(path: string, opts: SyncableOptions = {}): boolean {
|
||||
return classifySync(path, opts) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion to `isSyncable`. Returns null when the path IS syncable, or a
|
||||
* tagged `SyncableReason` explaining why it isn't. Used by the v0.41.13
|
||||
* #1433 cleanup guard in `commands/sync.ts` to distinguish metafile
|
||||
* exclusions (preserve any pre-existing page) from genuine "file removed
|
||||
* from the strategy" cases (delete the now-stale page).
|
||||
*
|
||||
* Routes through the same `classifySync` as `isSyncable` so the two cannot
|
||||
* drift. Identical opts contract — callers pass whatever they pass `isSyncable`.
|
||||
*/
|
||||
export function unsyncableReason(path: string, opts: SyncableOptions = {}): SyncableReason | null {
|
||||
return classifySync(path, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
96
test/cli-dream-engine-warn.test.ts
Normal file
96
test/cli-dream-engine-warn.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v0.41.13 (#1422) — `gbrain dream` surfaces engine-connect failures on stderr.
|
||||
*
|
||||
* Pre-fix bug (foxhoundinc): dream wrapped `connectEngine()` in `try {} catch {}`
|
||||
* that silently swallowed the failure. The cycle then ran with `engine: null`,
|
||||
* every DB phase reported "No database connection: connect() has not been
|
||||
* called", and the user saw exit 0 + "lint + backlinks done" with no clue why.
|
||||
*
|
||||
* Post-fix: the catch binds the error and writes a single `[dream] WARNING:`
|
||||
* line to stderr naming the connect failure and the consequence (DB phases
|
||||
* will be skipped). The cycle still runs filesystem phases honestly — no
|
||||
* behavior change for that path.
|
||||
*
|
||||
* Subprocess test so we exercise the real cli.ts dispatch end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function runDream(args: string[], extraEnv: Record<string, string> = {}): {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
} {
|
||||
const result = spawnSync('bun', ['run', 'src/cli.ts', 'dream', ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...extraEnv },
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
status: result.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('#1422 — dream surfaces connectEngine failures', () => {
|
||||
test('connect failure prints WARNING line on stderr (does not swallow silently)', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-dream-warn-'));
|
||||
const tmpBrain = mkdtempSync(join(tmpdir(), 'gbrain-dream-brain-'));
|
||||
try {
|
||||
// Point GBRAIN_HOME at an empty tempdir so loadConfig sees no config,
|
||||
// then force a bad Postgres URL via env so connectEngine throws on
|
||||
// the attempt to reach a non-existent server. Port 9 is the discard
|
||||
// protocol — guaranteed not to accept Postgres traffic.
|
||||
// GBRAIN_HOME=/tmp/x yields configDir() === '/tmp/x/.gbrain' (config.ts:687-690).
|
||||
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
|
||||
writeFileSync(join(tmpHome, '.gbrain', 'config.json'), JSON.stringify({
|
||||
engine: 'postgres',
|
||||
database_url: 'postgresql://nobody:nobody@127.0.0.1:9/nodb',
|
||||
}));
|
||||
const { stderr, status } = runDream(['--dir', tmpBrain, '--phase', 'lint'], {
|
||||
GBRAIN_HOME: tmpHome,
|
||||
});
|
||||
// Filesystem-only phases still run; exit code reflects cycle outcome,
|
||||
// not connect failure. The KEY contract: the WARNING text appears.
|
||||
expect(stderr).toContain('[dream] WARNING: could not connect to DB');
|
||||
expect(stderr).toContain('Running filesystem-only phases');
|
||||
expect(stderr).toContain('DB-dependent phases');
|
||||
// Sanity: process did not hang / segfault. Exit is success or non-zero
|
||||
// (filesystem phases are tolerant). We assert it terminated, not the code.
|
||||
expect(status).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
rmSync(tmpBrain, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('successful connect emits NO WARNING line', () => {
|
||||
const tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-dream-ok-'));
|
||||
const tmpBrain = mkdtempSync(join(tmpdir(), 'gbrain-dream-brain-'));
|
||||
try {
|
||||
// PGLite at a fresh tempdir always connects cleanly. No DATABASE_URL.
|
||||
// GBRAIN_HOME=/tmp/x yields configDir() === '/tmp/x/.gbrain' (config.ts:687-690).
|
||||
mkdirSync(join(tmpHome, '.gbrain'), { recursive: true });
|
||||
writeFileSync(join(tmpHome, '.gbrain', 'config.json'), JSON.stringify({
|
||||
engine: 'pglite',
|
||||
}));
|
||||
const { stderr, status } = runDream(['--dir', tmpBrain, '--phase', 'lint'], {
|
||||
GBRAIN_HOME: tmpHome,
|
||||
// Strip any inherited Postgres URL so PGLite is unambiguously the engine.
|
||||
DATABASE_URL: '',
|
||||
GBRAIN_DATABASE_URL: '',
|
||||
});
|
||||
expect(stderr).not.toContain('[dream] WARNING: could not connect to DB');
|
||||
expect(status).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
rmSync(tmpBrain, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
253
test/import-dedup-frontmatter-id.test.ts
Normal file
253
test/import-dedup-frontmatter-id.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* v0.41.13 (#1309) — overlapping-ingest-roots dedup via findDuplicatePage.
|
||||
*
|
||||
* Bug class (infiniteGameExp): `gbrain import /vault/Subdir/` then
|
||||
* `gbrain import /vault/` re-ingested the same files under different
|
||||
* slugs. Slug-only dedup at importFromContent missed the duplicate
|
||||
* because the slugs differed; the engine wrote both rows, doubling
|
||||
* search clutter and inflating backlink counts.
|
||||
*
|
||||
* Fix (per codex review):
|
||||
* - Identity-based dedup (content_hash + frontmatter.id), not pure
|
||||
* content_hash — two intentional pages with identical text but
|
||||
* different external IDs are NOT duplicates.
|
||||
* - WARN-ALWAYS on content_hash match; SKIP only when frontmatter.id
|
||||
* matches too.
|
||||
* - FAIL CLOSED on lookup error (no silent fallthrough).
|
||||
* - Soft-deleted rows excluded (don't block legitimate re-imports
|
||||
* under a new slug after a tombstone).
|
||||
*
|
||||
* PGLite hermetic — no real Postgres needed; the Postgres parity test
|
||||
* is at test/e2e/import-dedup-postgres.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { importFromFile } from '../src/core/import-file.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'gbrain-dedup-fm-'));
|
||||
});
|
||||
|
||||
function makeFile(rel: string, body: string): { path: string; rel: string } {
|
||||
const full = join(tmpRoot, rel);
|
||||
mkdirSync(join(full, '..'), { recursive: true });
|
||||
writeFileSync(full, body);
|
||||
return { path: full, rel };
|
||||
}
|
||||
|
||||
const granolaFrontmatter = (id: string, title = 'Sample meeting') => [
|
||||
'---',
|
||||
'type: concept',
|
||||
`title: ${title}`,
|
||||
`id: ${id}`,
|
||||
'---',
|
||||
'',
|
||||
'Sample meeting notes from granola.',
|
||||
].join('\n');
|
||||
|
||||
describe('#1309 — overlapping-ingest-roots dedup', () => {
|
||||
test('same content_hash + same frontmatter.id under different slugs: second skips with stderr log', async () => {
|
||||
const a = makeFile('subdir/note.md', granolaFrontmatter('granola-uuid-1'));
|
||||
const b = makeFile('note.md', granolaFrontmatter('granola-uuid-1'));
|
||||
|
||||
// First ingest under a deeper slug shape.
|
||||
const first = await importFromFile(engine, a.path, 'subdir/note.md', { noEmbed: true });
|
||||
expect(first.status).toBe('imported');
|
||||
|
||||
// Capture stderr to verify the skip log.
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const captured: string[] = [];
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = (
|
||||
chunk: unknown,
|
||||
...rest: unknown[]
|
||||
): boolean => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
||||
captured.push(s);
|
||||
return origWrite(chunk as Parameters<typeof origWrite>[0], ...(rest as []));
|
||||
};
|
||||
try {
|
||||
const second = await importFromFile(engine, b.path, 'note.md', { noEmbed: true });
|
||||
// Skipped — second slug matches the existing 'subdir/note' page.
|
||||
expect(second.status).toBe('skipped');
|
||||
expect(second.slug).toBe('subdir/note');
|
||||
} finally {
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = origWrite;
|
||||
}
|
||||
|
||||
const text = captured.join('');
|
||||
expect(text).toContain('[import] skipping');
|
||||
expect(text).toContain('granola-uuid-1');
|
||||
|
||||
// IRON RULE: exactly ONE row, not two.
|
||||
const rows = await engine.executeRaw<{ n: number }>(`SELECT COUNT(*)::int AS n FROM pages WHERE deleted_at IS NULL`);
|
||||
expect(rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
test('different frontmatter.id (and therefore different content_hash) imports both, no dedup signal', async () => {
|
||||
// gbrain's content_hash includes the frontmatter (minus captured_at /
|
||||
// ingested_at). Two pages with different `id:` in frontmatter
|
||||
// therefore have DIFFERENT content_hashes regardless of body text,
|
||||
// so dedup never matches and both index naturally. This pins that
|
||||
// contract — important because earlier plan drafts assumed a
|
||||
// body-only hash that would have spuriously deduped these.
|
||||
const a = makeFile('templates/daily-1.md', granolaFrontmatter('granola-uuid-A', 'Daily Template'));
|
||||
const b = makeFile('templates/daily-2.md', granolaFrontmatter('granola-uuid-B', 'Daily Template'));
|
||||
const first = await importFromFile(engine, a.path, 'templates/daily-1.md', { noEmbed: true });
|
||||
expect(first.status).toBe('imported');
|
||||
const second = await importFromFile(engine, b.path, 'templates/daily-2.md', { noEmbed: true });
|
||||
expect(second.status).toBe('imported');
|
||||
const rows = await engine.executeRaw<{ n: number }>(`SELECT COUNT(*)::int AS n FROM pages WHERE deleted_at IS NULL`);
|
||||
expect(rows[0].n).toBe(2);
|
||||
});
|
||||
|
||||
test('soft-deleted duplicate does NOT block re-import under new slug', async () => {
|
||||
const a = makeFile('old/note.md', granolaFrontmatter('granola-uuid-X'));
|
||||
const first = await importFromFile(engine, a.path, 'old/note.md', { noEmbed: true });
|
||||
expect(first.status).toBe('imported');
|
||||
|
||||
// Soft-delete the page; a future re-import under a new slug should
|
||||
// proceed (not block on the tombstone).
|
||||
await engine.softDeletePage('old/note');
|
||||
|
||||
const b = makeFile('new/note.md', granolaFrontmatter('granola-uuid-X'));
|
||||
const second = await importFromFile(engine, b.path, 'new/note.md', { noEmbed: true });
|
||||
expect(second.status).toBe('imported');
|
||||
|
||||
// Both rows exist: one tombstoned, one live.
|
||||
const live = await engine.executeRaw<{ n: number }>(`SELECT COUNT(*)::int AS n FROM pages WHERE deleted_at IS NULL`);
|
||||
expect(live[0].n).toBe(1);
|
||||
const all = await engine.executeRaw<{ n: number }>(`SELECT COUNT(*)::int AS n FROM pages`);
|
||||
expect(all[0].n).toBe(2);
|
||||
});
|
||||
|
||||
test('no frontmatter.id: skip-decision falls back to content_hash WARNING (not SKIP)', async () => {
|
||||
// Bare markdown — no `id:` in frontmatter at all. Two files with
|
||||
// identical text but no external identity — must NOT silently dedup.
|
||||
const body = '---\ntype: concept\ntitle: Plain\n---\n\nBare markdown body.';
|
||||
const a = makeFile('plain-a.md', body);
|
||||
const b = makeFile('plain-b.md', body);
|
||||
const first = await importFromFile(engine, a.path, 'plain-a.md', { noEmbed: true });
|
||||
expect(first.status).toBe('imported');
|
||||
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const captured: string[] = [];
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = (chunk: unknown, ...rest: unknown[]): boolean => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
||||
captured.push(s);
|
||||
return origWrite(chunk as Parameters<typeof origWrite>[0], ...(rest as []));
|
||||
};
|
||||
try {
|
||||
const second = await importFromFile(engine, b.path, 'plain-b.md', { noEmbed: true });
|
||||
expect(second.status).toBe('imported');
|
||||
} finally {
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = origWrite;
|
||||
}
|
||||
|
||||
const text = captured.join('');
|
||||
expect(text).toContain('[import] WARNING');
|
||||
expect(text).toContain('shares content_hash');
|
||||
|
||||
const rows = await engine.executeRaw<{ n: number }>(`SELECT COUNT(*)::int AS n FROM pages WHERE deleted_at IS NULL`);
|
||||
expect(rows[0].n).toBe(2);
|
||||
});
|
||||
|
||||
test('first ingest of a unique file: no dedup fire, status=imported', async () => {
|
||||
const a = makeFile('alpha/note.md', granolaFrontmatter('granola-uuid-1'));
|
||||
const result = await importFromFile(engine, a.path, 'alpha/note.md', { noEmbed: true });
|
||||
expect(result.status).toBe('imported');
|
||||
});
|
||||
|
||||
test('--force-rechunk bypasses dedup pre-check', async () => {
|
||||
const a = makeFile('subdir/note.md', granolaFrontmatter('granola-uuid-1'));
|
||||
const b = makeFile('note.md', granolaFrontmatter('granola-uuid-1'));
|
||||
await importFromFile(engine, a.path, 'subdir/note.md', { noEmbed: true });
|
||||
|
||||
// forceRechunk=true → dedup check is skipped → second insert proceeds
|
||||
// even though the same external ID is already present at another slug.
|
||||
const second = await importFromFile(engine, b.path, 'note.md', { noEmbed: true, forceRechunk: true });
|
||||
expect(['imported', 'replaced']).toContain(second.status as string);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicatePage engine method', () => {
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
test('returns null when no row matches', async () => {
|
||||
const r = await engine.findDuplicatePage!('default', { hash: 'nonexistent' });
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
test('matches on content_hash', async () => {
|
||||
await engine.putPage('alpha/note', {
|
||||
type: 'concept',
|
||||
title: 'Alpha',
|
||||
compiled_truth: 'identical body',
|
||||
frontmatter: { type: 'concept' },
|
||||
content_hash: 'deadbeef',
|
||||
});
|
||||
const r = await engine.findDuplicatePage!('default', { hash: 'deadbeef' });
|
||||
expect(r?.slug).toBe('alpha/note');
|
||||
});
|
||||
|
||||
test('matches on frontmatter.id even when content_hash differs', async () => {
|
||||
await engine.putPage('alpha/note', {
|
||||
type: 'concept',
|
||||
title: 'Alpha',
|
||||
compiled_truth: 'body v1',
|
||||
frontmatter: { type: 'concept', id: 'external-uuid' },
|
||||
content_hash: 'aaa',
|
||||
});
|
||||
const r = await engine.findDuplicatePage!('default', { hash: 'zzz', frontmatterId: 'external-uuid' });
|
||||
expect(r?.slug).toBe('alpha/note');
|
||||
});
|
||||
|
||||
test('soft-deleted rows excluded from results', async () => {
|
||||
await engine.putPage('alpha/note', {
|
||||
type: 'concept',
|
||||
title: 'Alpha',
|
||||
compiled_truth: 'body',
|
||||
frontmatter: { type: 'concept' },
|
||||
content_hash: 'cafef00d',
|
||||
});
|
||||
await engine.softDeletePage('alpha/note');
|
||||
const r = await engine.findDuplicatePage!('default', { hash: 'cafef00d' });
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
test('cross-source isolation: hash match in source B is invisible from source A', async () => {
|
||||
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('alpha', 'alpha', '/tmp/alpha') ON CONFLICT (id) DO NOTHING`);
|
||||
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('beta', 'beta', '/tmp/beta') ON CONFLICT (id) DO NOTHING`);
|
||||
await engine.putPage('shared', {
|
||||
type: 'concept',
|
||||
title: 'Beta-only',
|
||||
compiled_truth: 'body',
|
||||
frontmatter: { type: 'concept' },
|
||||
content_hash: 'beef',
|
||||
}, { sourceId: 'beta' });
|
||||
const fromAlpha = await engine.findDuplicatePage!('alpha', { hash: 'beef' });
|
||||
expect(fromAlpha).toBeNull();
|
||||
const fromBeta = await engine.findDuplicatePage!('beta', { hash: 'beef' });
|
||||
expect(fromBeta?.slug).toBe('shared');
|
||||
});
|
||||
});
|
||||
116
test/operations-fuzzy-source-scope.test.ts
Normal file
116
test/operations-fuzzy-source-scope.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* v0.41.13 (#1436) — MCP fuzzy `get_page` resolver scopes by source.
|
||||
*
|
||||
* Pre-fix bug (infiniteGameExp): the `get_page` op handler in
|
||||
* operations.ts called `ctx.engine.resolveSlugs(slug)` with no source
|
||||
* filter. When the caller's MCP context bound them to a specific
|
||||
* source (or a federated_read array), the fuzzy resolver could return
|
||||
* candidates from sources they shouldn't see. The handler then loaded
|
||||
* each candidate via `getPage(..., sourceOpts)` which IS scoped — so
|
||||
* the visible failure mode was "fuzzy returned a candidate but exact
|
||||
* lookup 404'd it." The bigger concern is that the candidate slug
|
||||
* leaks via the `ambiguous_slug` error envelope.
|
||||
*
|
||||
* Fix: thread `sourceScopeOpts(ctx)` into the `resolveSlugs(slug, opts)`
|
||||
* call. Engine method signature accepts `{sourceId?, sourceIds?}` —
|
||||
* field names match `sourceScopeOpts` output so callers spread cleanly.
|
||||
* Unscoped behavior preserved when both fields are undefined (back-compat
|
||||
* for internal CLI callers).
|
||||
*
|
||||
* This test seeds the same slug under two source_ids, runs the fuzzy
|
||||
* resolver under each context, and asserts the right candidates surface.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
// Register two sources we can isolate against. addSource via raw SQL is
|
||||
// cleaner here than going through runSources (less argv plumbing).
|
||||
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('alpha', 'alpha', '/tmp/alpha') ON CONFLICT (id) DO NOTHING`);
|
||||
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('beta', 'beta', '/tmp/beta') ON CONFLICT (id) DO NOTHING`);
|
||||
|
||||
// Seed the SAME slug under both sources. Pre-fix, fuzzy resolveSlugs
|
||||
// would return both candidates regardless of which source the caller
|
||||
// was scoped to.
|
||||
await engine.putPage('people/alice', {
|
||||
type: 'person',
|
||||
title: 'Alice (alpha)',
|
||||
compiled_truth: 'Alpha-source Alice page.',
|
||||
frontmatter: { type: 'person' },
|
||||
}, { sourceId: 'alpha' });
|
||||
await engine.putPage('people/alice', {
|
||||
type: 'person',
|
||||
title: 'Alice (beta)',
|
||||
compiled_truth: 'Beta-source Alice page.',
|
||||
frontmatter: { type: 'person' },
|
||||
}, { sourceId: 'beta' });
|
||||
});
|
||||
|
||||
describe('#1436 — resolveSlugs honors source scope', () => {
|
||||
test('opts.sourceId scopes exact match to a single source', async () => {
|
||||
const alphaHit = await engine.resolveSlugs('people/alice', { sourceId: 'alpha' });
|
||||
expect(alphaHit).toEqual(['people/alice']);
|
||||
const betaHit = await engine.resolveSlugs('people/alice', { sourceId: 'beta' });
|
||||
expect(betaHit).toEqual(['people/alice']);
|
||||
// Both return the same slug, but the calling op will then load the
|
||||
// page under the SAME sourceId, so cross-source leak is closed.
|
||||
});
|
||||
|
||||
test('opts.sourceIds (federated_read array) restricts to the listed sources', async () => {
|
||||
const both = await engine.resolveSlugs('people/alice', { sourceIds: ['alpha', 'beta'] });
|
||||
expect(both).toEqual(['people/alice']);
|
||||
|
||||
const alphaOnly = await engine.resolveSlugs('people/alice', { sourceIds: ['alpha'] });
|
||||
expect(alphaOnly).toEqual(['people/alice']);
|
||||
});
|
||||
|
||||
test('unscoped call (no opts) preserves pre-fix back-compat for internal callers', async () => {
|
||||
// Internal CLI callers (gbrain query --resolve, etc.) walk every source.
|
||||
const unscoped = await engine.resolveSlugs('people/alice');
|
||||
expect(unscoped).toEqual(['people/alice']);
|
||||
});
|
||||
|
||||
test('opts.sourceId for a source with NO matching slug returns empty', async () => {
|
||||
// Register a third source with nothing in it.
|
||||
await engine.executeRaw(`INSERT INTO sources (id, name, local_path) VALUES ('gamma', 'gamma', '/tmp/gamma') ON CONFLICT (id) DO NOTHING`);
|
||||
const gamma = await engine.resolveSlugs('people/alice', { sourceId: 'gamma' });
|
||||
expect(gamma).toEqual([]);
|
||||
});
|
||||
|
||||
test('fuzzy match honors source scope', async () => {
|
||||
// 'people/alic' (typo) should fuzzy-resolve in each scope but only
|
||||
// see candidates in that source. With our seed of just two
|
||||
// 'people/alice' rows, both scopes should each return a single match.
|
||||
const alphaFuzzy = await engine.resolveSlugs('people/alic', { sourceId: 'alpha' });
|
||||
expect(alphaFuzzy.length).toBeGreaterThan(0);
|
||||
expect(alphaFuzzy[0]).toBe('people/alice');
|
||||
|
||||
const gamma = await engine.resolveSlugs('people/alic', { sourceId: 'gamma' });
|
||||
expect(gamma).toEqual([]);
|
||||
});
|
||||
|
||||
test('soft-deleted rows are excluded from fuzzy candidates', async () => {
|
||||
// Delete the alpha row; resolveSlugs should NOT return its slug
|
||||
// anymore under scope:alpha. Beta row stays visible.
|
||||
await engine.softDeletePage('people/alice', { sourceId: 'alpha' });
|
||||
const alpha = await engine.resolveSlugs('people/alice', { sourceId: 'alpha' });
|
||||
expect(alpha).toEqual([]);
|
||||
const beta = await engine.resolveSlugs('people/alice', { sourceId: 'beta' });
|
||||
expect(beta).toEqual(['people/alice']);
|
||||
});
|
||||
});
|
||||
195
test/source-resolver-sole-non-default.test.ts
Normal file
195
test/source-resolver-sole-non-default.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* v0.41.13 (#1434) — sole_non_default tier (5.5) in resolveSourceId /
|
||||
* resolveSourceWithTier.
|
||||
*
|
||||
* When NO brain_default config is set AND exactly one registered source has
|
||||
* local_path set and isn't 'default', auto-route to it. Closes the bug
|
||||
* class where `gbrain sync` without --source silently routed to source_id
|
||||
* 'default' even though the user had a single Vault-mounted source.
|
||||
*
|
||||
* Tier ordering placement codex review forced:
|
||||
* - AFTER brain_default (explicit user intent wins)
|
||||
* - BEFORE seed_default (auto-route beats the empty terminal)
|
||||
*
|
||||
* Tests use a stub BrainEngine that only implements the three methods the
|
||||
* resolver touches: executeRaw, getConfig, kind. Hermetic — no PGLite.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
resolveSourceId,
|
||||
resolveSourceWithTier,
|
||||
SOURCE_TIER_NAMES,
|
||||
formatSoleNonDefaultNudge,
|
||||
} from '../src/core/source-resolver.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
type StubSource = { id: string; local_path: string | null; archived?: boolean };
|
||||
|
||||
function makeStub(sources: StubSource[], globalDefault: string | null = null) {
|
||||
return {
|
||||
kind: 'pglite' as const,
|
||||
async executeRaw<T>(sql: string, _params?: unknown[]): Promise<T[]> {
|
||||
// Two query shapes hit in the resolver:
|
||||
// 1. tier 4 (local_path match): SELECT id, local_path FROM sources WHERE local_path IS NOT NULL
|
||||
// 2. assertSourceExists: SELECT id FROM sources WHERE id = $1
|
||||
// 3. tier 5.5 (sole_non_default): SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false
|
||||
if (sql.includes('archived = false')) {
|
||||
return sources.filter(s => s.local_path !== null && s.id !== 'default' && s.archived !== true)
|
||||
.map(s => ({ id: s.id })) as unknown as T[];
|
||||
}
|
||||
if (sql.includes('local_path IS NOT NULL AND id != \'default\'')) {
|
||||
return sources.filter(s => s.local_path !== null && s.id !== 'default')
|
||||
.map(s => ({ id: s.id })) as unknown as T[];
|
||||
}
|
||||
if (sql.includes('SELECT id, local_path FROM sources WHERE local_path IS NOT NULL')) {
|
||||
return sources.filter(s => s.local_path !== null)
|
||||
.map(s => ({ id: s.id, local_path: s.local_path })) as unknown as T[];
|
||||
}
|
||||
if (sql.includes('SELECT id FROM sources WHERE id =')) {
|
||||
const id = (_params as string[])?.[0];
|
||||
return sources.filter(s => s.id === id).map(s => ({ id: s.id })) as unknown as T[];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async getConfig(_key: string): Promise<string | null> {
|
||||
return globalDefault;
|
||||
},
|
||||
} as unknown as Parameters<typeof resolveSourceId>[0];
|
||||
}
|
||||
|
||||
describe('#1434 — sole_non_default tier', () => {
|
||||
test('fires when exactly one non-default source is registered (no brain_default)', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
]);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.source_id).toBe('studiovault');
|
||||
expect(result.tier).toBe('sole_non_default');
|
||||
});
|
||||
|
||||
test('does NOT fire when 2+ non-default sources exist (ambiguous — user must pick)', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
{ id: 'second-vault', local_path: '/Users/india/other-vault' },
|
||||
]);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.source_id).toBe('default');
|
||||
expect(result.tier).toBe('seed_default');
|
||||
});
|
||||
|
||||
test('does NOT fire when 0 non-default sources exist (fresh install)', async () => {
|
||||
const engine = makeStub([{ id: 'default', local_path: null }]);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.source_id).toBe('default');
|
||||
expect(result.tier).toBe('seed_default');
|
||||
});
|
||||
|
||||
test('does NOT fire when sole non-default has NULL local_path (no on-disk shape)', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'remote-only', local_path: null }, // GitHub-only source
|
||||
]);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.source_id).toBe('default');
|
||||
expect(result.tier).toBe('seed_default');
|
||||
});
|
||||
|
||||
test('does NOT fire when brain_default is set (explicit user intent wins)', async () => {
|
||||
const engine = makeStub(
|
||||
[
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
],
|
||||
'default', // user explicitly set sources.default
|
||||
);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.source_id).toBe('default');
|
||||
expect(result.tier).toBe('brain_default');
|
||||
});
|
||||
|
||||
test('does NOT fire when explicit --source flag is passed (tier 1 wins)', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
]);
|
||||
const result = await resolveSourceWithTier(engine, 'default', '/tmp');
|
||||
expect(result.source_id).toBe('default');
|
||||
expect(result.tier).toBe('flag');
|
||||
});
|
||||
|
||||
test('does NOT fire when GBRAIN_SOURCE env is set (tier 2 wins)', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
]);
|
||||
await withEnv({ GBRAIN_SOURCE: 'default' }, async () => {
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.source_id).toBe('default');
|
||||
expect(result.tier).toBe('env');
|
||||
});
|
||||
});
|
||||
|
||||
test('archived non-default source is ignored (does not count toward the 1)', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
{ id: 'old-vault', local_path: '/Users/india/archive', archived: true },
|
||||
]);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
// archived 'old-vault' shouldn't count → still one non-default → fires
|
||||
expect(result.source_id).toBe('studiovault');
|
||||
expect(result.tier).toBe('sole_non_default');
|
||||
});
|
||||
|
||||
test('resolveSourceId mirrors resolveSourceWithTier on the new tier', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
]);
|
||||
const flat = await resolveSourceId(engine, null, '/tmp');
|
||||
const tagged = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(flat).toBe(tagged.source_id);
|
||||
});
|
||||
|
||||
test('detail string explains the routing', async () => {
|
||||
const engine = makeStub([
|
||||
{ id: 'default', local_path: null },
|
||||
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
||||
]);
|
||||
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
||||
expect(result.detail).toContain('only non-default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SOURCE_TIER_NAMES includes sole_non_default at index 5', () => {
|
||||
test('positioned between brain_default and seed_default', () => {
|
||||
const idx = SOURCE_TIER_NAMES.indexOf('sole_non_default');
|
||||
expect(idx).toBeGreaterThan(SOURCE_TIER_NAMES.indexOf('brain_default'));
|
||||
expect(idx).toBeLessThan(SOURCE_TIER_NAMES.indexOf('seed_default'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSoleNonDefaultNudge', () => {
|
||||
test('returns canonical nudge string in default env', async () => {
|
||||
await withEnv({ GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: undefined }, async () => {
|
||||
expect(formatSoleNonDefaultNudge('studiovault')).toBe(
|
||||
"[gbrain] routing to source 'studiovault' (sole non-default source registered; pass --source to override).",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null when GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1 suppresses', async () => {
|
||||
await withEnv({ GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: '1' }, async () => {
|
||||
expect(formatSoleNonDefaultNudge('studiovault')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('any value other than literal "1" does NOT suppress', async () => {
|
||||
await withEnv({ GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: 'true' }, async () => {
|
||||
expect(formatSoleNonDefaultNudge('studiovault')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,13 +44,17 @@ function makeStub(
|
||||
}
|
||||
|
||||
describe('SOURCE_TIER_NAMES ordering matches resolveSourceId priority', () => {
|
||||
test('canonical order is 1=flag → 6=seed_default', () => {
|
||||
test('canonical order is 1=flag → 7=seed_default with sole_non_default at 5.5', () => {
|
||||
// v0.41.13 (#1434): tier 5.5 `sole_non_default` slots between brain_default
|
||||
// and seed_default. Explicit user intent (sources.default config) wins
|
||||
// over the auto-routing; seed terminal still loses to anything.
|
||||
expect(SOURCE_TIER_NAMES).toEqual([
|
||||
'flag',
|
||||
'env',
|
||||
'dotfile',
|
||||
'local_path',
|
||||
'brain_default',
|
||||
'sole_non_default',
|
||||
'seed_default',
|
||||
]);
|
||||
});
|
||||
|
||||
62
test/sync-isSyncable-shape.test.ts
Normal file
62
test/sync-isSyncable-shape.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* v0.41.13 (#1433) — isSyncable / unsyncableReason classifier shape.
|
||||
*
|
||||
* The two public APIs route through the same internal `classifySync`
|
||||
* helper so they cannot drift. These tests pin the contract that
|
||||
* `isSyncable` returns true iff `unsyncableReason` returns null, AND
|
||||
* pin the canonical SYNC_SKIP_FILES list (since the cleanup-loop guard
|
||||
* at commands/sync.ts:772 keys on `unsyncableReason === 'metafile'`).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
isSyncable,
|
||||
unsyncableReason,
|
||||
SYNC_SKIP_FILES,
|
||||
type SyncableReason,
|
||||
} from '../src/core/sync.ts';
|
||||
|
||||
describe('#1433 — isSyncable / unsyncableReason are duals of one classifier', () => {
|
||||
const cases: Array<{ path: string; expected: SyncableReason | null; note: string }> = [
|
||||
{ path: 'people/alice.md', expected: null, note: 'normal markdown page' },
|
||||
{ path: 'docs/guide.mdx', expected: null, note: 'mdx accepted by markdown strategy' },
|
||||
{ path: 'learning-and-strategy/log.md', expected: 'metafile', note: 'log.md anywhere is metafile' },
|
||||
{ path: 'wiki/schema.md', expected: 'metafile', note: 'schema.md anywhere is metafile' },
|
||||
{ path: 'index.md', expected: 'metafile', note: 'top-level index.md' },
|
||||
{ path: 'README.md', expected: 'metafile', note: 'top-level README' },
|
||||
{ path: 'docs/README.md', expected: 'metafile', note: 'nested README' },
|
||||
{ path: 'people/alice.txt', expected: 'strategy', note: '.txt rejected by markdown strategy' },
|
||||
{ path: 'ops/scratch/note.md', expected: 'pruned-dir', note: 'ops/ is pruned' },
|
||||
{ path: '.git/notes.md', expected: 'pruned-dir', note: 'hidden dir pruned' },
|
||||
{ path: 'node_modules/foo/README.md', expected: 'pruned-dir', note: 'node_modules pruned' },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
test(`${c.path} → ${c.expected ?? 'syncable'} (${c.note})`, () => {
|
||||
const reason = unsyncableReason(c.path);
|
||||
const sync = isSyncable(c.path);
|
||||
expect(reason).toBe(c.expected);
|
||||
expect(sync).toBe(c.expected === null);
|
||||
});
|
||||
}
|
||||
|
||||
test('include glob: path not matching include returns include-glob-miss', () => {
|
||||
expect(unsyncableReason('docs/guide.md', { include: ['people/**'] })).toBe('include-glob-miss');
|
||||
expect(isSyncable('docs/guide.md', { include: ['people/**'] })).toBe(false);
|
||||
});
|
||||
|
||||
test('exclude glob: matching path returns exclude-glob-hit', () => {
|
||||
expect(unsyncableReason('drafts/wip.md', { exclude: ['drafts/**'] })).toBe('exclude-glob-hit');
|
||||
expect(isSyncable('drafts/wip.md', { exclude: ['drafts/**'] })).toBe(false);
|
||||
});
|
||||
|
||||
test('SYNC_SKIP_FILES export contains the canonical four basenames', () => {
|
||||
expect([...SYNC_SKIP_FILES]).toEqual(['schema.md', 'index.md', 'log.md', 'README.md']);
|
||||
});
|
||||
|
||||
test('isSyncable(p) === (unsyncableReason(p) === null) — duality holds for all canonical cases', () => {
|
||||
for (const c of cases) {
|
||||
expect(isSyncable(c.path)).toBe(unsyncableReason(c.path) === null);
|
||||
}
|
||||
});
|
||||
});
|
||||
160
test/sync-metafile-skip.serial.test.ts
Normal file
160
test/sync-metafile-skip.serial.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* v0.41.13 (#1433) — re-sync preserves previously-indexed metafile pages.
|
||||
*
|
||||
* Bug class (infiniteGameExp): a domain `log.md` page that was indexed by
|
||||
* an older gbrain version (back when isSyncable() didn't filter `log.md`)
|
||||
* or via a direct put_page was being deleted on every subsequent
|
||||
* `gbrain sync --skip-failed` because the cleanup loop at
|
||||
* commands/sync.ts:772 treated all unsyncable-modified paths the same.
|
||||
*
|
||||
* Fix: the cleanup loop skips the delete when `unsyncableReason(path)`
|
||||
* returns `'metafile'`. Pages stay in the index for the lifetime they
|
||||
* were originally indexed.
|
||||
*
|
||||
* This is the IRON-RULE regression test for #1433. Marked `.serial.test.ts`
|
||||
* because it spawns git subprocesses and shares a single PGLite engine
|
||||
* across tests for cold-start amortization.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let repoPath: string;
|
||||
|
||||
function gitInit(repo: string): void {
|
||||
execSync('git init', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: repo, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
describe('#1433 — re-sync preserves previously-indexed metafile pages', () => {
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-metafile-'));
|
||||
gitInit(repoPath);
|
||||
// Seed a non-metafile page that DOES get synced — this exercises the
|
||||
// happy path so we know sync ran at all.
|
||||
mkdirSync(join(repoPath, 'topics'), { recursive: true });
|
||||
writeFileSync(join(repoPath, 'topics/foo.md'), [
|
||||
'---',
|
||||
'type: concept',
|
||||
'title: Foo',
|
||||
'---',
|
||||
'',
|
||||
'Baseline content.',
|
||||
].join('\n'));
|
||||
// The metafile we care about: log.md is filtered by isSyncable.
|
||||
mkdirSync(join(repoPath, 'learning'), { recursive: true });
|
||||
writeFileSync(join(repoPath, 'learning/log.md'), '## [2026-05-25] domain log entry\n');
|
||||
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('log.md indexed via direct putPage survives re-sync after edit', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
|
||||
// First sync: log.md is filtered, only topics/foo.md lands.
|
||||
const first = await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
||||
expect(['first_sync', 'synced']).toContain(first.status);
|
||||
|
||||
// Seed the log page directly — simulate it being indexed by an older
|
||||
// gbrain version or via a hand-rolled put_page call. This is the
|
||||
// exact pre-condition that triggered infiniteGameExp's bug.
|
||||
await engine.putPage('learning/log', {
|
||||
type: 'concept',
|
||||
title: 'Learning log',
|
||||
compiled_truth: 'Pre-existing page that should survive re-sync.',
|
||||
timeline: '',
|
||||
frontmatter: { type: 'concept', id: 'learning-log' },
|
||||
});
|
||||
|
||||
// Confirm seed worked.
|
||||
const seeded = await engine.getPage('learning/log');
|
||||
expect(seeded).not.toBeNull();
|
||||
|
||||
// Edit log.md so it appears in manifest.modified on the next sync.
|
||||
writeFileSync(join(repoPath, 'learning/log.md'), '## [2026-05-25] entry\n## [2026-05-26] another\n');
|
||||
execSync('git add -A && git commit -m "edit log"', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
// Second sync: pre-fix would have deleted the learning/log page.
|
||||
const second = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
||||
// 'up_to_date' is the most common outcome here — log.md is the only edit,
|
||||
// and filtered.modified excludes metafiles, so from the syncable-view
|
||||
// nothing changed. But the cleanup loop on unsyncableModified DOES still
|
||||
// run and that's the codepath we're testing.
|
||||
expect(['synced', 'first_sync', 'blocked_by_failures', 'up_to_date']).toContain(second.status);
|
||||
|
||||
// IRON RULE: page survives. This is the regression.
|
||||
const survivor = await engine.getPage('learning/log');
|
||||
expect(survivor).not.toBeNull();
|
||||
expect(survivor?.compiled_truth).toContain('Pre-existing page');
|
||||
}, 60_000);
|
||||
|
||||
test('schema.md indexed via direct putPage also survives (covers full SYNC_SKIP_FILES set)', async () => {
|
||||
// Write + commit schema.md so it has a path entry in git.
|
||||
writeFileSync(join(repoPath, 'topics/schema.md'), '# schema\n');
|
||||
execSync('git add -A && git commit -m "add schema"', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
||||
|
||||
await engine.putPage('topics/schema', {
|
||||
type: 'concept',
|
||||
title: 'Schema',
|
||||
compiled_truth: 'Pre-existing schema page.',
|
||||
timeline: '',
|
||||
frontmatter: { type: 'concept' },
|
||||
});
|
||||
|
||||
writeFileSync(join(repoPath, 'topics/schema.md'), '# schema (edited)\n');
|
||||
execSync('git add -A && git commit -m "edit schema"', { cwd: repoPath, stdio: 'pipe' });
|
||||
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
||||
|
||||
const survivor = await engine.getPage('topics/schema');
|
||||
expect(survivor).not.toBeNull();
|
||||
}, 60_000);
|
||||
|
||||
test('non-metafile that becomes un-syncable (renamed .md → .txt) IS still cleaned up', async () => {
|
||||
// Negative case: prove the guard is narrow — it only protects
|
||||
// metafile classification, not the broader "this page used to be
|
||||
// sync-eligible but isn't anymore" case.
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
||||
|
||||
// topics/foo was indexed by the first sync; confirm.
|
||||
const before = await engine.getPage('topics/foo');
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Rename foo.md → foo.txt so it fails isSyncable with reason='strategy'.
|
||||
execSync('git mv topics/foo.md topics/foo.txt', { cwd: repoPath, stdio: 'pipe' });
|
||||
// Also edit so it appears in manifest.modified (not just .deleted).
|
||||
writeFileSync(join(repoPath, 'topics/foo.txt'), 'now a .txt file');
|
||||
execSync('git add -A && git commit -m "rename + edit"', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
||||
|
||||
// Pre-fix AND post-fix: the page should be cleaned up because the
|
||||
// reason is 'strategy' (not 'metafile'), so the guard doesn't fire.
|
||||
const after = await engine.getPage('topics/foo');
|
||||
expect(after).toBeNull();
|
||||
}, 60_000);
|
||||
});
|
||||
206
test/sync-sole-non-default-routing.test.ts
Normal file
206
test/sync-sole-non-default-routing.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* v0.41.13 (#1434) — performSync without --source auto-routes to the only
|
||||
* registered non-default source AND prints the nudge.
|
||||
*
|
||||
* Codex review of the original plan caught the load-bearing gap: adding the
|
||||
* `sole_non_default` tier to source-resolver.ts is dead code unless
|
||||
* `runSync` actually calls the resolver in the no-explicit-source case.
|
||||
* Pre-fix at commands/sync.ts:1500-1505, the resolver was skipped when
|
||||
* neither --source nor GBRAIN_SOURCE was set, leaving sourceId undefined.
|
||||
*
|
||||
* This test proves the wiring works end-to-end on PGLite: register one
|
||||
* non-default source, call performSync with no source arg, assert pages
|
||||
* land in that source (NOT 'default') AND the nudge appears on stderr.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runSources } from '../src/commands/sources.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let repoPath: string;
|
||||
|
||||
async function pageCountBySource(): Promise<Record<string, number>> {
|
||||
const rows = await engine.executeRaw<{ source_id: string; n: number }>(
|
||||
`SELECT source_id, COUNT(*)::int AS n FROM pages GROUP BY source_id`,
|
||||
);
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of rows) out[r.source_id] = r.n;
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('#1434 — runSync auto-routes to sole_non_default source', () => {
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-snd-routing-'));
|
||||
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
||||
execSync('git config user.email "t@t.com"', { cwd: repoPath, stdio: 'pipe' });
|
||||
execSync('git config user.name "T"', { cwd: repoPath, stdio: 'pipe' });
|
||||
mkdirSync(join(repoPath, 'topics'), { recursive: true });
|
||||
writeFileSync(join(repoPath, 'topics/foo.md'), [
|
||||
'---',
|
||||
'type: concept',
|
||||
'title: Foo',
|
||||
'---',
|
||||
'',
|
||||
'baseline.',
|
||||
].join('\n'));
|
||||
execSync('git add -A && git commit -m initial', { cwd: repoPath, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('sole non-default source: performSync without --source routes there', async () => {
|
||||
// local_path is required for tier 5.5 to fire — point at the synthetic
|
||||
// git repo so resolveSourceWithTier sees one non-default source with
|
||||
// a local_path AND falls through brain_default (unset).
|
||||
await runSources(engine, ['add', 'studiovault', '--path', repoPath, '--no-federated']);
|
||||
const { runSync } = await import('../src/commands/sync.ts');
|
||||
|
||||
// Capture stderr to verify the nudge fires.
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const captured: string[] = [];
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = (
|
||||
chunk: unknown,
|
||||
...rest: unknown[]
|
||||
): boolean => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
||||
captured.push(s);
|
||||
return origWrite(chunk as Parameters<typeof origWrite>[0], ...(rest as []));
|
||||
};
|
||||
|
||||
try {
|
||||
// runSync takes (engine, args). With no --source it relies on the
|
||||
// resolver to pick sole_non_default. --full to bypass git-diff
|
||||
// bookmarking. --no-embed since we have no embedding provider.
|
||||
// --repo points at the synthetic vault.
|
||||
// Note: runSync calls process.exit on some paths — guard accordingly.
|
||||
const origExit = process.exit;
|
||||
let exitCode: number | undefined;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = code;
|
||||
throw new Error('__exit__');
|
||||
}) as typeof process.exit;
|
||||
|
||||
try {
|
||||
await runSync(engine, ['--full', '--no-embed', '--repo', repoPath]);
|
||||
} catch (e) {
|
||||
if ((e as Error).message !== '__exit__') throw e;
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
}
|
||||
// Exit code 0 (success) or undefined (no exit called) both fine
|
||||
expect(exitCode === undefined || exitCode === 0).toBe(true);
|
||||
} finally {
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = origWrite;
|
||||
}
|
||||
|
||||
// IRON RULE: pages landed in studiovault, NOT in default.
|
||||
const counts = await pageCountBySource();
|
||||
expect(counts['studiovault']).toBeGreaterThan(0);
|
||||
expect(counts['default'] ?? 0).toBe(0);
|
||||
|
||||
// The nudge appears on stderr.
|
||||
const stderrText = captured.join('');
|
||||
expect(stderrText).toContain("routing to source 'studiovault'");
|
||||
expect(stderrText).toContain('sole non-default source registered');
|
||||
}, 60_000);
|
||||
|
||||
test('explicit --source overrides auto-routing (no nudge)', async () => {
|
||||
await runSources(engine, ['add', 'studiovault', '--path', repoPath, '--no-federated']);
|
||||
const { runSync } = await import('../src/commands/sync.ts');
|
||||
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const captured: string[] = [];
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = (
|
||||
chunk: unknown,
|
||||
...rest: unknown[]
|
||||
): boolean => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
||||
captured.push(s);
|
||||
return origWrite(chunk as Parameters<typeof origWrite>[0], ...(rest as []));
|
||||
};
|
||||
|
||||
try {
|
||||
const origExit = process.exit;
|
||||
process.exit = ((_code?: number) => { throw new Error('__exit__'); }) as typeof process.exit;
|
||||
try {
|
||||
await runSync(engine, ['--full', '--no-embed', '--repo', repoPath, '--source', 'default']);
|
||||
} catch (e) {
|
||||
if ((e as Error).message !== '__exit__') throw e;
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
}
|
||||
} finally {
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = origWrite;
|
||||
}
|
||||
|
||||
// No nudge — user picked explicitly.
|
||||
const stderrText = captured.join('');
|
||||
expect(stderrText).not.toContain('sole non-default source registered');
|
||||
|
||||
// Pages went to 'default' as requested.
|
||||
const counts = await pageCountBySource();
|
||||
expect(counts['default']).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
test('2+ non-default sources: no auto-route, no nudge, falls through to default', async () => {
|
||||
// Both need local_path to be counted by the sole_non_default helper.
|
||||
// Pre-existing helper filters local_path IS NOT NULL.
|
||||
const secondRepo = mkdtempSync(join(tmpdir(), 'gbrain-snd-routing-second-'));
|
||||
await runSources(engine, ['add', 'studiovault', '--path', repoPath, '--no-federated']);
|
||||
await runSources(engine, ['add', 'second-vault', '--path', secondRepo, '--no-federated']);
|
||||
const { runSync } = await import('../src/commands/sync.ts');
|
||||
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const captured: string[] = [];
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = (
|
||||
chunk: unknown,
|
||||
...rest: unknown[]
|
||||
): boolean => {
|
||||
const s = typeof chunk === 'string' ? chunk : chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
||||
captured.push(s);
|
||||
return origWrite(chunk as Parameters<typeof origWrite>[0], ...(rest as []));
|
||||
};
|
||||
|
||||
try {
|
||||
const origExit = process.exit;
|
||||
process.exit = ((_code?: number) => { throw new Error('__exit__'); }) as typeof process.exit;
|
||||
try {
|
||||
await runSync(engine, ['--full', '--no-embed', '--repo', repoPath]);
|
||||
} catch (e) {
|
||||
if ((e as Error).message !== '__exit__') throw e;
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
}
|
||||
} finally {
|
||||
(process.stderr as unknown as { write: typeof origWrite }).write = origWrite;
|
||||
}
|
||||
|
||||
const stderrText = captured.join('');
|
||||
expect(stderrText).not.toContain('sole non-default source registered');
|
||||
|
||||
// Multi-source brains fall through to seed_default — same as pre-fix.
|
||||
const counts = await pageCountBySource();
|
||||
expect(counts['default'] ?? 0).toBeGreaterThan(0);
|
||||
expect(counts['studiovault'] ?? 0).toBe(0);
|
||||
expect(counts['second-vault'] ?? 0).toBe(0);
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in New Issue
Block a user