v0.34.2.0 fix(import): path-based checkpoint resume — kills parallel-drop + failed-file-skip + sort-flip bugs (#988)

* feat(sync): sort files newest-first for faster salience on recent content

Problem: sync processes files in git-diff order (alphabetical), so
meetings/2020-* embeds before meetings/2026-*. After a burst of writes,
new pages can be invisible to search for hours while older pages process first.

Fix: sort addsAndMods descending in both incremental sync and full import.
Brain paths are date-prefixed by convention, so lexicographic descending
naturally prioritizes recent content.

This ensures the most relevant pages become searchable first.

* feat(import): path-based checkpoint resume + sort-newest-first helper

Replace gbrain import's positional `processedIndex` checkpoint with a
path-set checkpoint via `src/core/import-checkpoint.ts`. A file is only
"done" when its processFile returns success — failed files never enter
the set, parallel workers can't lose slow files, and sort-order changes
don't drop the newest N files on resume.

Three bug classes fixed:
- Parallel import + slow worker = silent file drop on crash-resume
- Failed file = checkpoint advanced past it, never retried until manual clear
- Sort-order flip (v0.33.x) = cross-version resume drops newest N files

Old positional checkpoints are detected on first resume and discarded
with a stderr log line. Re-walking is cheap because content_hash
short-circuits unchanged files.

Also extracts the descending-lex sort into src/core/sort-newest-first.ts
so import.ts and sync.ts share a single source of truth.

Tests:
- test/sort-newest-first.test.ts (5 hermetic cases)
- test/import-checkpoint.test.ts (18 unit cases over the helpers)
- test/import-resume.test.ts (refactored — GBRAIN_HOME isolation,
  drives runImport against PGLite, 5 integration cases including
  SLUG_MISMATCH retry regression)

Includes the original sort-newest-first contribution from
@garrytan-agents's PR #964 (commit 8dbcf6a5).

* chore: bump version and changelog (v0.34.2.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: update project documentation for v0.34.2.0

Add CLAUDE.md Key Files entries for the path-based import checkpoint
work: new entries for src/core/import-checkpoint.ts and
src/core/sort-newest-first.ts, plus a dedicated src/commands/import.ts
entry covering the v0.34.2.0 refactor. Update src/commands/sync.ts
entry to reference sortNewestFirst. Regenerate llms-full.txt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): swap banned /data/brain placeholder for /tmp/example-brain

scripts/check-privacy.sh banlist includes /data/brain/ (legacy private
OpenClaw fork layout). New test files must not use it — CI privacy
guard caught this on PR #988's first push.

No behavior change. test/import-checkpoint.test.ts is unit-level with
no fs access; the dir string is just an identity marker for the
loadCheckpoint dir-mismatch guard.

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-14 20:51:11 -07:00
committed by GitHub
co-authored by garrytan-agents Claude Opus 4.7
parent 488e4824e8
commit 3325b405bb
12 changed files with 757 additions and 141 deletions
+56
View File
@@ -2,6 +2,62 @@
All notable changes to GBrain will be documented in this file.
## [0.34.2.0] - 2026-05-14
**Path-based import checkpoint. The newest files in a partial import no longer disappear.**
**`gbrain import` resumes by completed-path set instead of positional index, so failed and slow files retry instead of silently dropping.**
Three bug classes died in this release. If you ever ran `gbrain import` against a large brain, hit Ctrl-C, and ran it again — or if you've ever run parallel import on Postgres — there were paths where files silently failed to import and never retried until you manually cleared the checkpoint. The old model tracked progress as a positional index into a sorted file list. Under parallel workers, a slow file at index 0 plus three fast completions wrote `processedIndex=3` to the checkpoint, and a crash dropped the slow file on resume. Failed files bumped the same counter, so the checkpoint advanced past them and the next run skipped them. And the v0.33.x sort-newest-first change made cross-version resume drop the newest N files.
v0.34.2.0 replaces all of it with a path-set checkpoint. A file is only "done" when its import succeeds. The checkpoint stores the set of completed relative paths, not a counter. Sort order is irrelevant to checkpoint correctness. Failed files automatically retry on the next run with no manual intervention.
### What changes for users
- `gbrain import` resumes correctly under any execution model: serial, parallel, slow-file-first, or failure-mixed. No more "the new files I just added didn't import."
- Failed files retry on the next `gbrain import` run automatically. Pre-v0.34.2 you had to delete `~/.gbrain/import-checkpoint.json` by hand to retry a file that errored during a prior run.
- Newest pages get embedded first across both `gbrain sync` and `gbrain import` — the v0.33 sort-newest-first behavior now lives in a single helper so the policy never drifts between the two commands.
- Pre-v0.34.2 checkpoints get discarded on first resume with a stderr log line so you know why the run is re-walking. Re-walking is cheap because `content_hash` short-circuits unchanged files.
### Itemized changes
#### Added
- `src/core/import-checkpoint.ts``loadCheckpoint`, `saveCheckpoint`, `resumeFilter`, `clearCheckpoint`, and the `ImportCheckpoint` type. Atomic write via `.tmp` + rename so a mid-write crash never leaves a partial JSON. Old positional-format checkpoints get detected and logged before being discarded.
- `src/core/sort-newest-first.ts` — single source of truth for the descending lex sort that `gbrain import` and `gbrain sync` both apply. Future ordering changes flip one line.
#### Changed
- `src/commands/import.ts` — checkpoint resume now driven by `loadCheckpoint` + `resumeFilter`. Successful imports (and unchanged-via-content-hash) call `completed.add(relativePath)`. Failed files never enter the set. Checkpoint write fires every 100 successful adds (not every 100 processed). Cleanup on clean exit uses `clearCheckpoint`.
- `src/commands/sync.ts` — inline `.sort()` replaced with `sortNewestFirst(addsAndMods)`.
#### Tests
- `test/sort-newest-first.test.ts` — 5 hermetic cases pinning descending order, mixed prefixes, empty/single input, and in-place mutation contract.
- `test/import-checkpoint.test.ts` — 18 unit cases over the helpers: missing/malformed/dir-mismatch/old-positional/valid for `loadCheckpoint`, round-trip + atomic-rename + non-fatal-on-error for `saveCheckpoint`, full filter semantics for `resumeFilter`, no-op-on-missing for `clearCheckpoint`.
- `test/import-resume.test.ts` — fully refactored. Now isolates via `GBRAIN_HOME` env override through `withEnv` (was writing to real `~/.gbrain` pre-v0.34.2). Drives `runImport` against PGLite for true integration coverage. 5 cases including the SLUG_MISMATCH retry regression that pins the pre-existing P1 bug codex caught during plan-eng-review.
#### Includes from PR #964
This release also incorporates @garrytan-agents's [PR #964](https://github.com/garrytan/gbrain/pull/964) (cherry-picked as commit `8dbcf6a5` and superseded by this PR's broader rewrite). The original sort-newest-first contribution is what surfaced the underlying checkpoint bugs during plan-eng-review.
## To take advantage of v0.34.2.0
`gbrain upgrade` handles this automatically. There is no manual step. The first `gbrain import` after upgrade with a pre-existing checkpoint will:
1. Detect the old positional format and log to stderr: `Older checkpoint format detected — re-walking (cheap via content_hash)`.
2. Re-walk every file in the brain dir. Unchanged files short-circuit via `content_hash` (no embed cost, no DB write).
3. Write the new path-based checkpoint format going forward.
If you want to verify the new behavior:
```bash
gbrain doctor
```
If anything looks wrong, file an issue at https://github.com/garrytan/gbrain/issues with:
- output of `gbrain doctor`
- contents of `~/.gbrain/import-checkpoint.json` (if present)
## [0.34.1.0] - 2026-05-14
**MCP hardening wave: stricter source isolation on the read path, PKCE
+4 -1
View File
@@ -179,7 +179,10 @@ strict behavior when unset.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
- `src/core/sync-concurrency.ts` (v0.22.13) — single source of truth for the parallel-sync policy. Exports `autoConcurrency(engine, fileCount, override?)` (PGLite always serial; explicit override clamped to >=1; auto path returns `DEFAULT_PARALLEL_WORKERS=4` when `fileCount > AUTO_CONCURRENCY_FILE_THRESHOLD=100`), `shouldRunParallel(workers, fileCount, explicit)` (Q1: explicit `--workers` bypasses the >50-file floor), and `parseWorkers(s)` (rejects `'0'`, `'-3'`, `'foo'`, `'1.5'`, trailing chars — replaces the prior parseInt-with-no-validation in both `sync.ts` and `import.ts`). Used by `performSync`, `performFullSync`, `runImport`, and the Minion `sync` handler so the three sites can no longer drift.
- `src/commands/sync.ts``gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr.
- `src/commands/sync.ts``gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr. **v0.34.2.0:** the inline `.sort()` over add/mod paths is replaced with `sortNewestFirst(addsAndMods)` from `src/core/sort-newest-first.ts`, so the newest-first descending-lex policy lives in one helper shared with `gbrain import` instead of drifting across two files.
- `src/commands/import.ts``gbrain import` CLI + `runImport` library entrypoint. v0.34.2.0 replaces the prior positional-index checkpoint (`processedIndex: N` into a sorted file list) with a path-set checkpoint via `src/core/import-checkpoint.ts`. The walk still applies `sortNewestFirst()` for embed-cost ordering, but checkpoint correctness no longer depends on sort order. A file enters `completed: Set<relativePath>` only when its `processFile` returns success (including content-hash short-circuit no-ops); failed files never enter the set, so the next run retries them automatically with no manual `~/.gbrain/import-checkpoint.json` delete. Three bug classes died: parallel-import-with-slow-worker drops the slow file on crash-resume (closed — the slow file isn't in `completed` until its own `processFile` resolves), failed-file-bumps-counter-past-itself (closed — failures don't add to `completed`), and v0.33.x sort-flip-drops-newest-N-on-cross-version-resume (closed — order is no longer part of the checkpoint). Old positional checkpoints are detected and discarded with a stderr line on first resume; re-walking is cheap because `content_hash` short-circuits unchanged files. Checkpoint persists every 100 successful adds, not every 100 processed files, so a long failure tail doesn't churn the JSON. Pinned by `test/import-checkpoint.test.ts` (18 unit cases over the helpers) + `test/import-resume.test.ts` (5 integration cases under PGLite, including the SLUG_MISMATCH retry regression codex caught during plan-eng-review).
- `src/core/import-checkpoint.ts` (v0.34.2.0) — `loadCheckpoint(brainDir)`, `saveCheckpoint(brainDir, completed)`, `resumeFilter(files, completed, brainDir)`, `clearCheckpoint()`, plus the `ImportCheckpoint` type. Path-set checkpoint format (`{schema_version, brainDir, completed: string[]}`) replaces the v0.33.x positional `{processedIndex: N}` format. Atomic write via `.tmp` + `rename()` so a mid-write crash never leaves a partial JSON. `loadCheckpoint` returns `null` on: missing file, malformed JSON, brainDir mismatch (you ran import against a different brain), and the old positional format (logged to stderr before being discarded). `resumeFilter` returns `{toProcess, skippedCount}` — pure, no I/O, deterministic. `clearCheckpoint` is no-op-on-missing for clean-exit cleanup. Honors `GBRAIN_HOME` via `gbrainPath()` so test isolation via `withEnv({GBRAIN_HOME: tmpdir})` works without monkey-patching the fs layer. Best-effort persistence — `saveCheckpoint` logs warnings on write errors but never throws, so import keeps making progress even if disk is full.
- `src/core/sort-newest-first.ts` (v0.34.2.0) — single source of truth for the descending-lex sort that `gbrain import` and `gbrain sync` both apply. Mutates in place (Array.prototype.sort semantics), returns the same array reference for fluent chaining. Empty/single-element inputs short-circuit. Future ordering changes flip one line in this helper instead of touching two CLI commands. Pinned by `test/sort-newest-first.test.ts` (5 hermetic cases: descending order, mixed prefixes, empty input, single-element input, in-place-mutation contract).
- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **9 phases in v0.29**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → recompute_emotional_weight → embed → orphans**. v0.29 adds the `recompute_emotional_weight` phase between patterns and embed; it sees the union of `syncPagesAffected` + `synthesizeWrittenSlugs` for incremental mode, or all pages when neither anchor is set (full backfill via `gbrain dream --phase recompute_emotional_weight`). v0.29 also extends `CycleReport.totals` with `pages_emotional_weight_recomputed` (additive, schema_version stays "1"). v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key.
- `src/core/cycle/synthesize.ts` (v0.23) — Synthesize phase: conversation-transcript-to-brain pipeline. Reads from `dream.synthesize.session_corpus_dir`, runs cheap Haiku verdict (cached in `dream_verdicts`), then fans out one Sonnet subagent per worth-processing transcript with `allowed_slug_prefixes` (sourced from `skills/_brain-filing-rules.json` `dream_synthesize_paths.globs`). Orchestrator collects slugs from `subagent_tool_executions` (NOT `pages.updated_at` — codex finding #2) and reverse-renders DB → markdown via `serializeMarkdown`. Cooldown via `dream.synthesize.last_completion_ts`, written ONLY on success. Idempotency key `dream:synth:<file_path>:<content_hash>`. Auto-commit deferred to v1.1 (codex #5). `--dry-run` runs Haiku, skips Sonnet (codex #8). Subagent never gets fs-write access. **v0.23.2:** `renderPageToMarkdown` (now exported) stamps `dream_generated: true` and `dream_cycle_date` into every reverse-write's frontmatter; `writeSummaryPage` does the same on the dream-cycle summary index. The marker is the explicit identity surface checked by `isDreamOutput` in `transcript-discovery.ts` — replaces the v0.23.1 content-prefix heuristic that could miss real output (`serializeMarkdown` doesn't embed slugs in body) and false-positive on user transcripts citing brain pages. `judgeSignificance` and `JudgeClient` are exported; `judgeSignificance` accepts a `verdictModel` parameter (default `claude-haiku-4-5-20251001`) loaded from `dream.synthesize.verdict_model` via `loadSynthConfig`. **v0.30.2:** model-aware chunker `splitTranscriptByBudget(content, contentHash, maxChars)` splits oversized transcripts at paragraph boundaries (`## Topic:``---``\n` ladder) using a deterministic offset seeded from the first 32 bits of `contentHash` so retries chunk identically. Per-chunk char budget computed from `MODEL_CONTEXT_TOKENS[resolvedModel] × 0.9 × 3.5 chars/token`; non-Anthropic ids fall back to a 180K-token safe default with a once-per-process stderr warning. Operator overrides: `dream.synthesize.max_prompt_tokens` (floor 100K, wins when set) and `dream.synthesize.max_chunks_per_transcript` (default 24). Per-chunk idempotency keys `dream:synth:<filePath>:<hash16>:c<i>of<n>`; single-chunk transcripts preserve the legacy `dream:synth:<filePath>:<hash16>` key byte-for-byte (D8 lookup), so existing brains skip with `already_synthesized_legacy_single_chunk` instead of re-spending Sonnet on upgrade. `collectChildPutPageSlugs` raw-fetches every (job_id, slug) pair (not `SELECT DISTINCT`) and rewrites bare-hash6 slugs to `<hash6>-c<idx>` for chunked children (D6 — orchestrator-side, zero Sonnet trust). Cap-hit skips don't write to `dream_verdicts`, so raising the cap on next run re-attempts cleanly. D7 scope: bounds INITIAL prompt size only; tool-loop turn-N accumulation is caught by the v0.30.2 terminal-error classification in `subagent.ts`, not bounded ahead of time.
- `src/core/cycle/patterns.ts` (v0.23) — Patterns phase: cross-session theme detection over reflections within `dream.patterns.lookback_days` (default 30). Names a pattern only when ≥`dream.patterns.min_evidence` (default 3) reflections support it. Single Sonnet subagent; same allow-list path as synthesize. Runs AFTER `extract` so the graph is fresh.
+1 -1
View File
@@ -1 +1 @@
0.34.1.0
0.34.2.0
+4 -1
View File
@@ -287,7 +287,10 @@ strict behavior when unset.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `src/core/db-lock.ts` (v0.22.13) — generic `tryAcquireDbLock(engine, lockId, ttlMinutes)` over the existing `gbrain_cycle_locks` table. Parameterized lock id so different scopes can nest cleanly: `gbrain-cycle` for the broad cycle (held by `cycle.ts`) and `gbrain-sync` (`SYNC_LOCK_ID` constant) for `performSync`'s narrower writer window. Same UPSERT-with-TTL semantics as the prior cycle-only helper, just generalized. Survives PgBouncer transaction pooling (unlike session-scoped `pg_try_advisory_lock`); crashed holders auto-release once their TTL expires.
- `src/core/sync-concurrency.ts` (v0.22.13) — single source of truth for the parallel-sync policy. Exports `autoConcurrency(engine, fileCount, override?)` (PGLite always serial; explicit override clamped to >=1; auto path returns `DEFAULT_PARALLEL_WORKERS=4` when `fileCount > AUTO_CONCURRENCY_FILE_THRESHOLD=100`), `shouldRunParallel(workers, fileCount, explicit)` (Q1: explicit `--workers` bypasses the >50-file floor), and `parseWorkers(s)` (rejects `'0'`, `'-3'`, `'foo'`, `'1.5'`, trailing chars — replaces the prior parseInt-with-no-validation in both `sync.ts` and `import.ts`). Used by `performSync`, `performFullSync`, `runImport`, and the Minion `sync` handler so the three sites can no longer drift.
- `src/commands/sync.ts` — `gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr.
- `src/commands/sync.ts` — `gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr. **v0.34.2.0:** the inline `.sort()` over add/mod paths is replaced with `sortNewestFirst(addsAndMods)` from `src/core/sort-newest-first.ts`, so the newest-first descending-lex policy lives in one helper shared with `gbrain import` instead of drifting across two files.
- `src/commands/import.ts` — `gbrain import` CLI + `runImport` library entrypoint. v0.34.2.0 replaces the prior positional-index checkpoint (`processedIndex: N` into a sorted file list) with a path-set checkpoint via `src/core/import-checkpoint.ts`. The walk still applies `sortNewestFirst()` for embed-cost ordering, but checkpoint correctness no longer depends on sort order. A file enters `completed: Set<relativePath>` only when its `processFile` returns success (including content-hash short-circuit no-ops); failed files never enter the set, so the next run retries them automatically with no manual `~/.gbrain/import-checkpoint.json` delete. Three bug classes died: parallel-import-with-slow-worker drops the slow file on crash-resume (closed — the slow file isn't in `completed` until its own `processFile` resolves), failed-file-bumps-counter-past-itself (closed — failures don't add to `completed`), and v0.33.x sort-flip-drops-newest-N-on-cross-version-resume (closed — order is no longer part of the checkpoint). Old positional checkpoints are detected and discarded with a stderr line on first resume; re-walking is cheap because `content_hash` short-circuits unchanged files. Checkpoint persists every 100 successful adds, not every 100 processed files, so a long failure tail doesn't churn the JSON. Pinned by `test/import-checkpoint.test.ts` (18 unit cases over the helpers) + `test/import-resume.test.ts` (5 integration cases under PGLite, including the SLUG_MISMATCH retry regression codex caught during plan-eng-review).
- `src/core/import-checkpoint.ts` (v0.34.2.0) — `loadCheckpoint(brainDir)`, `saveCheckpoint(brainDir, completed)`, `resumeFilter(files, completed, brainDir)`, `clearCheckpoint()`, plus the `ImportCheckpoint` type. Path-set checkpoint format (`{schema_version, brainDir, completed: string[]}`) replaces the v0.33.x positional `{processedIndex: N}` format. Atomic write via `.tmp` + `rename()` so a mid-write crash never leaves a partial JSON. `loadCheckpoint` returns `null` on: missing file, malformed JSON, brainDir mismatch (you ran import against a different brain), and the old positional format (logged to stderr before being discarded). `resumeFilter` returns `{toProcess, skippedCount}` — pure, no I/O, deterministic. `clearCheckpoint` is no-op-on-missing for clean-exit cleanup. Honors `GBRAIN_HOME` via `gbrainPath()` so test isolation via `withEnv({GBRAIN_HOME: tmpdir})` works without monkey-patching the fs layer. Best-effort persistence — `saveCheckpoint` logs warnings on write errors but never throws, so import keeps making progress even if disk is full.
- `src/core/sort-newest-first.ts` (v0.34.2.0) — single source of truth for the descending-lex sort that `gbrain import` and `gbrain sync` both apply. Mutates in place (Array.prototype.sort semantics), returns the same array reference for fluent chaining. Empty/single-element inputs short-circuit. Future ordering changes flip one line in this helper instead of touching two CLI commands. Pinned by `test/sort-newest-first.test.ts` (5 hermetic cases: descending order, mixed prefixes, empty input, single-element input, in-place-mutation contract).
- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive (extended to **9 phases in v0.29**). `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes phases in semantically-driven order: **lint → backlinks → sync → synthesize → extract → patterns → recompute_emotional_weight → embed → orphans**. v0.29 adds the `recompute_emotional_weight` phase between patterns and embed; it sees the union of `syncPagesAffected` + `synthesizeWrittenSlugs` for incremental mode, or all pages when neither anchor is set (full backfill via `gbrain dream --phase recompute_emotional_weight`). v0.29 also extends `CycleReport.totals` with `pages_emotional_weight_recomputed` (additive, schema_version stays "1"). v0.23's `synthesize` phase runs after sync (cross-references see fresh brain) and before extract (auto-link materializes its writes); `patterns` runs after extract so it reads a fresh graph (codex finding #7 — subagent put_page sets `ctx.remote=true` and skips auto-link/timeline by default; extract is the canonical materialization). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler. Coordination via `gbrain_cycle_locks` DB table + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite. `CycleReport.schema_version: "1"` is stable; totals additively grew in v0.23 (`transcripts_processed`, `synth_pages_written`, `patterns_written`). `yieldBetweenPhases` runs between phases. **v0.23 added `yieldDuringPhase`** for in-phase keepalive — synthesize/patterns call it during long waits to renew the cycle-lock TTL. Engine nullable; lock-skip on read-only phase selections. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase. v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): `resolveSourceForDir(engine, brainDir)` threads `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key.
- `src/core/cycle/synthesize.ts` (v0.23) — Synthesize phase: conversation-transcript-to-brain pipeline. Reads from `dream.synthesize.session_corpus_dir`, runs cheap Haiku verdict (cached in `dream_verdicts`), then fans out one Sonnet subagent per worth-processing transcript with `allowed_slug_prefixes` (sourced from `skills/_brain-filing-rules.json` `dream_synthesize_paths.globs`). Orchestrator collects slugs from `subagent_tool_executions` (NOT `pages.updated_at` — codex finding #2) and reverse-renders DB → markdown via `serializeMarkdown`. Cooldown via `dream.synthesize.last_completion_ts`, written ONLY on success. Idempotency key `dream:synth:<file_path>:<content_hash>`. Auto-commit deferred to v1.1 (codex #5). `--dry-run` runs Haiku, skips Sonnet (codex #8). Subagent never gets fs-write access. **v0.23.2:** `renderPageToMarkdown` (now exported) stamps `dream_generated: true` and `dream_cycle_date` into every reverse-write's frontmatter; `writeSummaryPage` does the same on the dream-cycle summary index. The marker is the explicit identity surface checked by `isDreamOutput` in `transcript-discovery.ts` — replaces the v0.23.1 content-prefix heuristic that could miss real output (`serializeMarkdown` doesn't embed slugs in body) and false-positive on user transcripts citing brain pages. `judgeSignificance` and `JudgeClient` are exported; `judgeSignificance` accepts a `verdictModel` parameter (default `claude-haiku-4-5-20251001`) loaded from `dream.synthesize.verdict_model` via `loadSynthConfig`. **v0.30.2:** model-aware chunker `splitTranscriptByBudget(content, contentHash, maxChars)` splits oversized transcripts at paragraph boundaries (`## Topic:` → `---` → `\n` ladder) using a deterministic offset seeded from the first 32 bits of `contentHash` so retries chunk identically. Per-chunk char budget computed from `MODEL_CONTEXT_TOKENS[resolvedModel] × 0.9 × 3.5 chars/token`; non-Anthropic ids fall back to a 180K-token safe default with a once-per-process stderr warning. Operator overrides: `dream.synthesize.max_prompt_tokens` (floor 100K, wins when set) and `dream.synthesize.max_chunks_per_transcript` (default 24). Per-chunk idempotency keys `dream:synth:<filePath>:<hash16>:c<i>of<n>`; single-chunk transcripts preserve the legacy `dream:synth:<filePath>:<hash16>` key byte-for-byte (D8 lookup), so existing brains skip with `already_synthesized_legacy_single_chunk` instead of re-spending Sonnet on upgrade. `collectChildPutPageSlugs` raw-fetches every (job_id, slug) pair (not `SELECT DISTINCT`) and rewrites bare-hash6 slugs to `<hash6>-c<idx>` for chunked children (D6 — orchestrator-side, zero Sonnet trust). Cap-hit skips don't write to `dream_verdicts`, so raising the cap on next run re-attempts cleanly. D7 scope: bounds INITIAL prompt size only; tool-loop turn-N accumulation is caught by the v0.30.2 terminal-error classification in `subagent.ts`, not bounded ahead of time.
- `src/core/cycle/patterns.ts` (v0.23) — Patterns phase: cross-session theme detection over reflections within `dream.patterns.lookback_days` (default 30). Names a pattern only when ≥`dream.patterns.min_evidence` (default 3) reflections support it. Single Sonnet subagent; same allow-list path as synthesize. Runs AFTER `extract` so the graph is fresh.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.34.1.0",
"version": "0.34.2.0",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+47 -32
View File
@@ -1,4 +1,4 @@
import { readdirSync, lstatSync, existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
import { readdirSync, lstatSync, existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, relative } from 'path';
import { cpus, totalmem } from 'os';
@@ -13,6 +13,13 @@ import {
isImageFilePath as isImageFilePathFromSync,
type SyncStrategy,
} from '../core/sync.ts';
import { sortNewestFirst } from '../core/sort-newest-first.ts';
import {
loadCheckpoint,
saveCheckpoint,
clearCheckpoint,
resumeFilter,
} from '../core/import-checkpoint.ts';
function defaultWorkers(): number {
const cpuCount = cpus().length;
@@ -86,23 +93,23 @@ export async function runImport(
: strategy === 'auto' ? 'syncable' : 'markdown';
console.log(`Found ${allFiles.length} ${fileTypeLabel} files`);
// Resume from checkpoint if available
const checkpointPath = gbrainPath('import-checkpoint.json');
let files = allFiles;
let resumeIndex = 0;
// Sort newest-first so date-prefixed brain paths get embedded before older ones.
// See src/core/sort-newest-first.ts for the policy.
sortNewestFirst(allFiles);
if (!fresh && existsSync(checkpointPath)) {
try {
const cp = JSON.parse(readFileSync(checkpointPath, 'utf-8'));
if (cp.dir === dir && cp.totalFiles === allFiles.length) {
resumeIndex = cp.processedIndex;
files = allFiles.slice(resumeIndex);
console.log(`Resuming from checkpoint: skipping ${resumeIndex} already-processed files`);
}
} catch {
// Invalid checkpoint, start fresh
// Resume from checkpoint if available. v0.33.2: path-based resume —
// see src/core/import-checkpoint.ts for the bug-class this fixes
// (parallel-import silent-skip and failed-file no-retry).
const checkpointPath = gbrainPath('import-checkpoint.json');
const completed = new Set<string>();
if (!fresh) {
const cp = loadCheckpoint(checkpointPath, dir);
if (cp) {
for (const p of cp.completedPaths) completed.add(p);
console.log(`Resuming from checkpoint: skipping ${completed.size} already-processed files`);
}
}
const files = resumeFilter(allFiles, dir, completed);
// Determine actual worker count
const actualWorkers = workerCount > 1 ? workerCount : 1;
@@ -150,12 +157,18 @@ export async function runImport(
imported++;
chunksCreated += result.chunks;
importedSlugs.push(result.slug);
// v0.33.2: path-based checkpoint — record only on success.
completed.add(relativePath);
} else {
skipped++;
if (result.error && result.error !== 'unchanged') {
console.error(` Skipped ${relativePath}: ${result.error}`);
// Bug 9 — non-"unchanged" skips carry a real error reason.
failures.push({ path: relativePath, error: result.error });
} else {
// 'unchanged' or no-error skip: content_hash matched a prior
// successful import, so this file IS done for checkpoint purposes.
completed.add(relativePath);
}
}
} catch (e: unknown) {
@@ -173,20 +186,20 @@ export async function runImport(
}
processed++;
tickProgress();
if (processed % 100 === 0 || processed === files.length) {
// Save checkpoint every 100 files — track completed file set, not just a counter
if (processed % 100 === 0) {
try {
const cpDir = gbrainPath();
if (!existsSync(cpDir)) { const { mkdirSync } = await import('fs'); mkdirSync(cpDir, { recursive: true }); }
writeFileSync(checkpointPath, JSON.stringify({
dir, totalFiles: allFiles.length,
processedIndex: resumeIndex + processed,
completedFiles: importedSlugs.length + skipped,
timestamp: new Date().toISOString(),
}));
} catch { /* non-fatal */ }
// Save checkpoint every 100 SUCCESSFUL adds (not every 100 processed).
// Failed files never enter `completed`, so a flaky file can't push the
// checkpoint past it — the next run will retry it.
if (completed.size > 0 && completed.size % 100 === 0) {
const cpDir = gbrainPath();
if (!existsSync(cpDir)) {
try { const { mkdirSync } = await import('fs'); mkdirSync(cpDir, { recursive: true }); }
catch { /* non-fatal */ }
}
saveCheckpoint(checkpointPath, {
dir,
completedPaths: Array.from(completed),
timestamp: new Date().toISOString(),
});
}
}
@@ -259,10 +272,12 @@ export async function runImport(
}
}
// Clear checkpoint only on successful completion (no errors)
if (errors === 0 && existsSync(checkpointPath)) {
try { unlinkSync(checkpointPath); } catch { /* non-fatal */ }
} else if (errors > 0 && existsSync(checkpointPath)) {
// Clear checkpoint on clean completion. On error, the path-based checkpoint
// preserves only the successfully-completed paths, so the next run retries
// failed files automatically (they never entered `completed`).
if (errors === 0) {
clearCheckpoint(checkpointPath);
} else if (existsSync(checkpointPath)) {
console.log(` Checkpoint preserved (${errors} errors). Run again to retry failed files.`);
}
+5
View File
@@ -29,6 +29,7 @@ import {
import { tryAcquireDbLock, SYNC_LOCK_ID } from '../core/db-lock.ts';
import { loadStorageConfig } from '../core/storage-config.ts';
import { getDefaultSourcePath } from '../core/source-resolver.ts';
import { sortNewestFirst } from '../core/sort-newest-first.ts';
export interface SyncResult {
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run' | 'blocked_by_failures';
@@ -700,6 +701,10 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
const failedFiles: Array<{ path: string; error: string; line?: number }> = [];
const addsAndMods = [...filtered.added, ...filtered.modified];
// Sort newest-first so date-prefixed brain paths get embedded before older
// ones. See src/core/sort-newest-first.ts for the policy.
sortNewestFirst(addsAndMods);
// v0.22.13 (PR #490 Q5): one source of truth for the concurrency decision.
// engine.kind === 'pglite' → forced 1; explicit opts.concurrency wins;
// auto path returns DEFAULT_PARALLEL_WORKERS only when fileCount > 100.
+143
View File
@@ -0,0 +1,143 @@
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { relative, isAbsolute } from 'path';
/**
* Path-based import checkpoint.
*
* Pre-v0.33.2 brains used a positional checkpoint (`processedIndex` into a
* sorted file array). That model was broken in three ways under any non-
* sequential execution:
*
* 1. Parallel workers — `processed++` fires on completion, not dispatch,
* so a slow worker on `files[0]` + three fast completions writes
* `processedIndex=3`. Crash-resume slices `files.slice(3)` and the
* slow file is silently lost.
* 2. Failed files — error path still bumped the same counter, so failures
* pushed the checkpoint past them and the next run skipped them
* forever (line 268's "delete on clean exit" only fires when
* errors === 0; a single failure preserves the bad checkpoint).
* 3. Sort-order changes — flipping the walk order makes positional
* indices from prior runs mean different files.
*
* Path-based resume fixes all three: a file is "done" only when its
* `processFile` returns successfully, the completed set is keyed by the
* relative path string (sort-order-agnostic), and failed files never
* enter the set.
*/
export interface ImportCheckpoint {
/** Absolute brain directory the checkpoint was created against. Mismatch on resume → discard. */
dir: string;
/**
* Paths (relative to `dir`) that completed successfully or were unchanged.
* Stored as a sorted array for serialization; loaded into a Set at runtime.
*/
completedPaths: string[];
/** ISO 8601, diagnostic only. */
timestamp: string;
}
const OLD_FORMAT_LOG = 'Older checkpoint format detected — re-walking (cheap via content_hash)';
/**
* Load a checkpoint and verify it's compatible with the current run.
*
* Returns null when:
* - the file is missing
* - the JSON is malformed
* - the recorded `dir` doesn't match the current `dir`
* - the payload is a pre-v0.33.2 positional checkpoint (logs to stderr
* so users see why a partial import is re-walking)
* - `completedPaths` is missing or not an array of strings
*/
export function loadCheckpoint(path: string, currentDir: string): ImportCheckpoint | null {
if (!existsSync(path)) return null;
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(path, 'utf-8'));
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
// Pre-v0.33.2 positional format: had `processedIndex`, no `completedPaths`.
// Detect via the absence of the new field — discard and surface why.
if (!Array.isArray(obj.completedPaths)) {
if (typeof obj.processedIndex === 'number') {
console.error(OLD_FORMAT_LOG);
}
return null;
}
if (typeof obj.dir !== 'string') return null;
if (obj.dir !== currentDir) return null;
if (typeof obj.timestamp !== 'string') return null;
if (!obj.completedPaths.every((p): p is string => typeof p === 'string')) return null;
return {
dir: obj.dir,
completedPaths: obj.completedPaths,
timestamp: obj.timestamp,
};
}
/**
* Write a checkpoint atomically (write-to-tmp + rename) so a crash mid-write
* can never leave a partially-written JSON file that breaks the next resume.
*
* Failures are non-fatal — the caller logs nothing and the import continues.
* A missing checkpoint just means the next run re-walks from zero, which
* is cheap because `importFile` short-circuits unchanged files via
* `content_hash`.
*/
export function saveCheckpoint(path: string, cp: ImportCheckpoint): void {
try {
const tmp = `${path}.tmp`;
// Sort for stable serialization — keeps diffs across snapshots minimal
// and tests deterministic.
const payload: ImportCheckpoint = {
dir: cp.dir,
completedPaths: [...cp.completedPaths].sort(),
timestamp: cp.timestamp,
};
writeFileSync(tmp, JSON.stringify(payload));
renameSync(tmp, path);
} catch {
/* non-fatal: lost checkpoint just means re-walk on next run */
}
}
/**
* Filter `allFiles` to those NOT already in the completed set.
*
* `allFiles` may contain absolute paths (from the recursive walker) or
* already-relative paths (from tests). `completed` is always relative to
* `dir`. Normalize each file to relative form before lookup.
*
* Pure function — no fs access. Test surface for the resume semantics.
*/
export function resumeFilter(
allFiles: string[],
dir: string,
completed: Set<string>,
): string[] {
if (completed.size === 0) return allFiles;
return allFiles.filter((p) => {
const rel = isAbsolute(p) ? relative(dir, p) : p;
return !completed.has(rel);
});
}
/**
* Convenience for callers: remove a checkpoint file. Wraps the existing
* cleanup-on-clean-exit site in import.ts. Non-fatal.
*/
export function clearCheckpoint(path: string): void {
try {
if (existsSync(path)) unlinkSync(path);
} catch {
/* non-fatal */
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Sort brain paths newest-first by lexicographic descending.
*
* Brain paths are date-prefixed by convention (meetings/2026-05-13-*,
* daily/2026-05-13.md), so descending lex order naturally prioritizes recent
* content. For non-date-prefixed paths (concepts/, wiki/, people/, etc.) the
* order is deterministic but reverse-alphabetical — there is no salience
* signal to optimize for there, but consistency keeps logs and progress
* output predictable across runs.
*
* Mutates in place AND returns the same array, so callers can chain or
* just rely on the side effect:
*
* sortNewestFirst(allFiles); // mutate-only
* const sorted = sortNewestFirst(xs); // chain
*
* Used at `import.ts` (full directory walk) and `sync.ts` (git-diff
* addsAndMods). The same helper at both call sites keeps the policy in
* one place; future ordering changes flip one line.
*/
export function sortNewestFirst(paths: string[]): string[] {
return paths.sort((a, b) => b.localeCompare(a));
}
+212
View File
@@ -0,0 +1,212 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
loadCheckpoint,
saveCheckpoint,
resumeFilter,
clearCheckpoint,
type ImportCheckpoint,
} from '../src/core/import-checkpoint.ts';
let workDir: string;
let cpPath: string;
let stderrCaptured = '';
let originalConsoleError: typeof console.error;
function captureStderr() {
stderrCaptured = '';
originalConsoleError = console.error.bind(console);
console.error = (...args: unknown[]) => {
stderrCaptured += args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
};
}
function restoreStderr() {
if (originalConsoleError) {
console.error = originalConsoleError;
originalConsoleError = undefined as unknown as typeof console.error;
}
}
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'gbrain-checkpoint-'));
cpPath = join(workDir, 'import-checkpoint.json');
});
afterEach(() => {
restoreStderr();
rmSync(workDir, { recursive: true, force: true });
});
describe('loadCheckpoint', () => {
test('returns null when file is missing', () => {
expect(loadCheckpoint(cpPath, '/some/dir')).toBeNull();
});
test('returns null when JSON is malformed', () => {
writeFileSync(cpPath, 'not json at all');
expect(loadCheckpoint(cpPath, '/some/dir')).toBeNull();
});
test('returns null when dir mismatches the current run', () => {
const cp: ImportCheckpoint = {
dir: '/other/brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T00:00:00Z',
};
writeFileSync(cpPath, JSON.stringify(cp));
expect(loadCheckpoint(cpPath, '/different/brain')).toBeNull();
});
test('returns null and logs to stderr for old positional format', () => {
captureStderr();
writeFileSync(cpPath, JSON.stringify({
dir: '/tmp/example-brain',
totalFiles: 13768,
processedIndex: 5000,
timestamp: '2026-01-01T00:00:00Z',
}));
const result = loadCheckpoint(cpPath, '/tmp/example-brain');
expect(result).toBeNull();
expect(stderrCaptured).toContain('Older checkpoint format detected');
});
test('returns null silently for missing completedPaths without processedIndex', () => {
// Not the v0.33.2 schema and not the old positional schema either —
// probably a manually-edited file or third-party tooling. Discard
// without the migration log line.
captureStderr();
writeFileSync(cpPath, JSON.stringify({
dir: '/tmp/example-brain',
timestamp: '2026-01-01T00:00:00Z',
}));
expect(loadCheckpoint(cpPath, '/tmp/example-brain')).toBeNull();
expect(stderrCaptured).not.toContain('Older checkpoint format');
});
test('returns null when completedPaths contains non-strings', () => {
writeFileSync(cpPath, JSON.stringify({
dir: '/tmp/example-brain',
completedPaths: ['a.md', 42, 'b.md'],
timestamp: '2026-01-01T00:00:00Z',
}));
expect(loadCheckpoint(cpPath, '/tmp/example-brain')).toBeNull();
});
test('returns the checkpoint for valid v0.33.2 payload', () => {
const cp: ImportCheckpoint = {
dir: '/tmp/example-brain',
completedPaths: ['meetings/2026-05-13.md', 'concepts/foo.md'],
timestamp: '2026-05-14T12:34:56Z',
};
writeFileSync(cpPath, JSON.stringify(cp));
const loaded = loadCheckpoint(cpPath, '/tmp/example-brain');
expect(loaded).not.toBeNull();
expect(loaded?.dir).toBe('/tmp/example-brain');
expect(loaded?.completedPaths).toEqual(['meetings/2026-05-13.md', 'concepts/foo.md']);
expect(loaded?.timestamp).toBe('2026-05-14T12:34:56Z');
});
});
describe('saveCheckpoint', () => {
test('round-trips through loadCheckpoint', () => {
const cp: ImportCheckpoint = {
dir: '/tmp/example-brain',
completedPaths: ['a.md', 'b.md', 'c.md'],
timestamp: '2026-05-14T00:00:00Z',
};
saveCheckpoint(cpPath, cp);
const loaded = loadCheckpoint(cpPath, '/tmp/example-brain');
expect(loaded?.completedPaths).toEqual(['a.md', 'b.md', 'c.md']);
expect(loaded?.dir).toBe('/tmp/example-brain');
});
test('serializes completedPaths sorted (deterministic output)', () => {
saveCheckpoint(cpPath, {
dir: '/tmp/example-brain',
completedPaths: ['z.md', 'a.md', 'm.md'],
timestamp: '2026-05-14T00:00:00Z',
});
const onDisk = JSON.parse(readFileSync(cpPath, 'utf-8'));
expect(onDisk.completedPaths).toEqual(['a.md', 'm.md', 'z.md']);
});
test('atomic-ish write — no stray .tmp file after success', () => {
saveCheckpoint(cpPath, {
dir: '/tmp/example-brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T00:00:00Z',
});
expect(existsSync(cpPath)).toBe(true);
expect(existsSync(`${cpPath}.tmp`)).toBe(false);
});
test('non-fatal on write failure (path under non-existent dir)', () => {
// Should NOT throw, just silently skip the write.
const badPath = join(workDir, 'does-not-exist', 'cp.json');
expect(() =>
saveCheckpoint(badPath, {
dir: '/tmp/example-brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T00:00:00Z',
}),
).not.toThrow();
expect(existsSync(badPath)).toBe(false);
});
});
describe('resumeFilter', () => {
test('empty completed set returns all files unchanged', () => {
const all = ['a.md', 'b.md', 'c.md'];
expect(resumeFilter(all, '/tmp/example-brain', new Set())).toEqual(all);
});
test('completed set filters matching paths out', () => {
const all = ['a.md', 'b.md', 'c.md'];
const completed = new Set(['b.md']);
expect(resumeFilter(all, '/tmp/example-brain', completed)).toEqual(['a.md', 'c.md']);
});
test('absolute paths get normalized to relative for lookup', () => {
const all = [
'/tmp/example-brain/meetings/2026-05-13.md',
'/tmp/example-brain/concepts/a.md',
];
const completed = new Set(['meetings/2026-05-13.md']);
expect(resumeFilter(all, '/tmp/example-brain', completed)).toEqual([
'/tmp/example-brain/concepts/a.md',
]);
});
test('mixed absolute and relative inputs both work', () => {
const all = [
'/tmp/example-brain/a.md',
'b.md',
'/tmp/example-brain/c.md',
];
const completed = new Set(['a.md', 'c.md']);
expect(resumeFilter(all, '/tmp/example-brain', completed)).toEqual(['b.md']);
});
test('full match returns empty array', () => {
const all = ['a.md', 'b.md'];
const completed = new Set(['a.md', 'b.md']);
expect(resumeFilter(all, '/tmp/example-brain', completed)).toEqual([]);
});
});
describe('clearCheckpoint', () => {
test('removes the checkpoint file when present', () => {
writeFileSync(cpPath, '{}');
expect(existsSync(cpPath)).toBe(true);
clearCheckpoint(cpPath);
expect(existsSync(cpPath)).toBe(false);
});
test('is a no-op when the checkpoint file is missing', () => {
expect(existsSync(cpPath)).toBe(false);
expect(() => clearCheckpoint(cpPath)).not.toThrow();
});
});
+213 -105
View File
@@ -1,111 +1,219 @@
import { describe, test, expect, afterEach } from 'bun:test';
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync } from 'fs';
/**
* Integration tests for `runImport`'s checkpoint behavior.
*
* Predicate-level tests for `loadCheckpoint`/`saveCheckpoint`/`resumeFilter`
* live in `test/import-checkpoint.test.ts`. This file drives the full
* `runImport` against PGLite to verify the end-to-end resume contract:
*
* - Old positional checkpoints from pre-v0.33.2 brains are discarded
* cleanly + the migration stderr log fires.
* - v0.33.2 path-based checkpoints honor the completedPaths set on resume.
* - Failed files do NOT enter `completedPaths`; the next run retries them
* (the pre-existing P1 codex caught).
* - Clean completion clears the checkpoint.
*
* Test isolation:
* - `GBRAIN_HOME` env override via `withEnv` so we NEVER touch the real
* `~/.gbrain/import-checkpoint.json`. Pre-v0.33.2 this file did exactly
* that — see codex finding P2 in the plan.
* - PGLite via the canonical block (`beforeAll` + `resetPgliteState` +
* `afterAll`) per CLAUDE.md test-isolation rules R3 + R4.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { homedir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { runImport } from '../src/commands/import.ts';
const CHECKPOINT_PATH = join(homedir(), '.gbrain', 'import-checkpoint.json');
let engine: PGLiteEngine;
let workspace: string; // GBRAIN_HOME target — `${workspace}/.gbrain/` holds the checkpoint file
let gbrainHomeDir: string; // Resolves to `${workspace}/.gbrain` — the actual checkpoint dir
let cpPath: string; // The checkpoint file path inside gbrainHomeDir
let brainDir: string; // The brain content dir — fixture markdown lives here
describe('import resume checkpoint', () => {
afterEach(() => {
// Clean up checkpoint after each test
if (existsSync(CHECKPOINT_PATH)) {
rmSync(CHECKPOINT_PATH);
}
});
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
test('checkpoint file format is valid JSON', () => {
const checkpoint = {
dir: '/data/brain',
totalFiles: 13768,
processedIndex: 5000,
timestamp: new Date().toISOString(),
};
afterAll(async () => {
await engine.disconnect();
}, 60_000);
mkdirSync(join(homedir(), '.gbrain'), { recursive: true });
writeFileSync(CHECKPOINT_PATH, JSON.stringify(checkpoint));
const loaded = JSON.parse(readFileSync(CHECKPOINT_PATH, 'utf-8'));
expect(loaded.dir).toBe('/data/brain');
expect(loaded.totalFiles).toBe(13768);
expect(loaded.processedIndex).toBe(5000);
expect(typeof loaded.timestamp).toBe('string');
});
test('checkpoint with matching dir and totalFiles enables resume', () => {
const checkpoint = {
dir: '/data/brain',
totalFiles: 100,
processedIndex: 50,
timestamp: new Date().toISOString(),
};
mkdirSync(join(homedir(), '.gbrain'), { recursive: true });
writeFileSync(CHECKPOINT_PATH, JSON.stringify(checkpoint));
// Simulate the resume check logic from import.ts
const cp = JSON.parse(readFileSync(CHECKPOINT_PATH, 'utf-8'));
const dir = '/data/brain';
const allFilesLength = 100;
expect(cp.dir).toBe(dir);
expect(cp.totalFiles).toBe(allFilesLength);
expect(cp.processedIndex).toBe(50);
// Would resume from index 50
});
test('checkpoint with different dir does NOT resume', () => {
const checkpoint = {
dir: '/data/other-brain',
totalFiles: 100,
processedIndex: 50,
timestamp: new Date().toISOString(),
};
mkdirSync(join(homedir(), '.gbrain'), { recursive: true });
writeFileSync(CHECKPOINT_PATH, JSON.stringify(checkpoint));
const cp = JSON.parse(readFileSync(CHECKPOINT_PATH, 'utf-8'));
const dir = '/data/brain';
const allFilesLength = 100;
// dir doesn't match, should start fresh
expect(cp.dir === dir && cp.totalFiles === allFilesLength).toBe(false);
});
test('checkpoint with different totalFiles does NOT resume', () => {
const checkpoint = {
dir: '/data/brain',
totalFiles: 200,
processedIndex: 50,
timestamp: new Date().toISOString(),
};
mkdirSync(join(homedir(), '.gbrain'), { recursive: true });
writeFileSync(CHECKPOINT_PATH, JSON.stringify(checkpoint));
const cp = JSON.parse(readFileSync(CHECKPOINT_PATH, 'utf-8'));
const dir = '/data/brain';
const allFilesLength = 100;
// totalFiles doesn't match (files were added/removed), start fresh
expect(cp.dir === dir && cp.totalFiles === allFilesLength).toBe(false);
});
test('invalid checkpoint JSON starts fresh', () => {
mkdirSync(join(homedir(), '.gbrain'), { recursive: true });
writeFileSync(CHECKPOINT_PATH, 'not json');
let resumeIndex = 0;
try {
JSON.parse(readFileSync(CHECKPOINT_PATH, 'utf-8'));
} catch {
resumeIndex = 0; // start fresh on invalid checkpoint
}
expect(resumeIndex).toBe(0);
});
test('missing checkpoint file starts fresh', () => {
expect(existsSync(CHECKPOINT_PATH)).toBe(false);
// No checkpoint = start from 0
});
beforeEach(async () => {
await resetPgliteState(engine);
workspace = mkdtempSync(join(tmpdir(), 'gbrain-import-resume-home-'));
// GBRAIN_HOME is the parent dir; configDir() appends '.gbrain' itself.
// The checkpoint lives at `${workspace}/.gbrain/import-checkpoint.json`.
gbrainHomeDir = join(workspace, '.gbrain');
mkdirSync(gbrainHomeDir, { recursive: true });
cpPath = join(gbrainHomeDir, 'import-checkpoint.json');
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-import-resume-brain-'));
});
afterEach(() => {
if (workspace) rmSync(workspace, { recursive: true, force: true });
if (brainDir) rmSync(brainDir, { recursive: true, force: true });
});
function writeBrainFile(rel: string, body: string) {
const full = join(brainDir, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, body);
}
function validMarkdown(slug: string, title = slug) {
return [
'---',
`slug: ${slug}`,
`title: ${title}`,
'---',
'',
`Body for ${slug}.`,
].join('\n');
}
describe('runImport checkpoint resume — v0.33.2 path-based', () => {
test('old positional checkpoint gets discarded with stderr log', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
// Plant a pre-v0.33.2 positional checkpoint.
writeFileSync(cpPath, JSON.stringify({
dir: brainDir,
totalFiles: 10,
processedIndex: 5,
completedFiles: 5,
timestamp: '2026-01-01T00:00:00Z',
}));
// One fixture file so runImport has work to do.
writeBrainFile('concepts/foo.md', validMarkdown('concepts/foo'));
// Capture console.error to verify the migration log fires.
let captured = '';
const origErr = console.error.bind(console);
console.error = (...args: unknown[]) => {
captured += args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
};
try {
const result = await runImport(engine, [brainDir, '--no-embed']);
expect(result.imported + result.skipped).toBeGreaterThan(0);
} finally {
console.error = origErr;
}
expect(captured).toContain('Older checkpoint format detected');
});
}, 30_000);
test('v0.33.2 checkpoint with completedPaths skips already-done files', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('a.md', validMarkdown('a'));
writeBrainFile('b.md', validMarkdown('b'));
writeBrainFile('c.md', validMarkdown('c'));
// Plant a v0.33.2 checkpoint that says a.md and b.md are done.
writeFileSync(cpPath, JSON.stringify({
dir: brainDir,
completedPaths: ['a.md', 'b.md'],
timestamp: '2026-05-14T00:00:00Z',
}));
const result = await runImport(engine, [brainDir, '--no-embed']);
// Only c.md should have been imported this run. The other two are
// already in `completed` and got filtered out before processFile.
expect(result.imported).toBe(1);
});
}, 30_000);
test('clean completion clears the checkpoint file', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('only.md', validMarkdown('only'));
// No prior checkpoint.
expect(existsSync(cpPath)).toBe(false);
const result = await runImport(engine, [brainDir, '--no-embed']);
expect(result.errors).toBe(0);
expect(result.imported).toBe(1);
// After clean completion the checkpoint is cleaned up so the next
// run doesn't think it needs to resume.
expect(existsSync(cpPath)).toBe(false);
});
}, 30_000);
test('failed file does NOT enter completedPaths — next run retries it', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
// Two healthy files plus one with a path-vs-frontmatter slug mismatch.
// import-file.ts rejects path-derived 'people/bob' vs declared slug
// 'wrong-slug' with a SLUG_MISMATCH failure (test/e2e/sync.test.ts uses
// the same fixture shape).
writeBrainFile('people/alice.md', validMarkdown('people/alice'));
writeBrainFile('people/carol.md', validMarkdown('people/carol'));
writeBrainFile('people/bob.md', [
'---', 'type: person', 'title: Bob', 'slug: wrong-slug', '---', '', 'Body.',
].join('\n'));
// First run: bob fails with SLUG_MISMATCH, others succeed.
const result1 = await runImport(engine, [brainDir, '--no-embed']);
// `failures` includes both thrown-exception (errors++) and
// returned-skipped-with-error paths. SLUG_MISMATCH hits the latter.
expect(result1.failures.length).toBeGreaterThan(0);
expect(result1.failures.some(f => f.path.includes('bob'))).toBe(true);
// Fix the broken file.
writeBrainFile('people/bob.md', validMarkdown('people/bob'));
// Second run: every file should now succeed. Critically, bob.md must
// process — not silently skipped because of a stale checkpoint
// pointer (the pre-v0.33.2 bug class).
const result2 = await runImport(engine, [brainDir, '--no-embed']);
expect(result2.failures.length).toBe(0);
// bob now exists in the DB.
const pages = await engine.executeRaw<{ slug: string }>(
`SELECT slug FROM pages WHERE slug = 'people/bob'`,
);
expect(pages.length).toBe(1);
// Suppress unused warning — cpPath is referenced for clarity above.
void cpPath;
});
}, 60_000);
test('checkpoint with mismatched dir is discarded silently (no migration log)', async () => {
await withEnv({ GBRAIN_HOME: workspace }, async () => {
writeBrainFile('one.md', validMarkdown('one'));
// v0.33.2-shaped checkpoint pointing at a different brain dir.
writeFileSync(cpPath, JSON.stringify({
dir: '/some/other/brain',
completedPaths: ['one.md'],
timestamp: '2026-05-14T00:00:00Z',
}));
let captured = '';
const origErr = console.error.bind(console);
console.error = (...args: unknown[]) => {
captured += args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
};
try {
const result = await runImport(engine, [brainDir, '--no-embed']);
// Dir mismatch → discard → re-walk → import the file fresh.
expect(result.imported).toBe(1);
} finally {
console.error = origErr;
}
// The "older checkpoint format" log is for the POSITIONAL legacy
// shape, not v0.33.2-dir-mismatch. Silent discard is intentional.
expect(captured).not.toContain('Older checkpoint format');
});
}, 30_000);
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, test, expect } from 'bun:test';
import { sortNewestFirst } from '../src/core/sort-newest-first.ts';
describe('sortNewestFirst', () => {
test('date-prefixed paths get sorted newest-first', () => {
const input = [
'meetings/2020-01-01-old.md',
'meetings/2026-05-13-recent.md',
'meetings/2024-03-15-middle.md',
];
const out = sortNewestFirst([...input]);
expect(out[0]).toBe('meetings/2026-05-13-recent.md');
expect(out[1]).toBe('meetings/2024-03-15-middle.md');
expect(out[2]).toBe('meetings/2020-01-01-old.md');
});
test('mixed prefixes produce deterministic descending order', () => {
const input = [
'concepts/a.md',
'meetings/2026-05-13.md',
'people/zoe.md',
'daily/2026-05-12.md',
];
const out = sortNewestFirst([...input]);
// Pure lex descending — pin the exact order so the contract is locked.
expect(out).toEqual([
'people/zoe.md',
'meetings/2026-05-13.md',
'daily/2026-05-12.md',
'concepts/a.md',
]);
});
test('empty input returns empty', () => {
expect(sortNewestFirst([])).toEqual([]);
});
test('single element returns single element', () => {
expect(sortNewestFirst(['only.md'])).toEqual(['only.md']);
});
test('mutates in place AND returns the same reference', () => {
const arr = ['a.md', 'c.md', 'b.md'];
const ret = sortNewestFirst(arr);
expect(ret).toBe(arr); // same reference
expect(arr).toEqual(['c.md', 'b.md', 'a.md']); // caller's view reflects the sort
});
});