mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
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:
co-authored by
garrytan-agents
Claude Opus 4.7
parent
488e4824e8
commit
3325b405bb
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-1
@@ -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
@@ -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
@@ -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.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user