Files
gbrain/src/core/import-checkpoint.ts
T
3325b405bb 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>
2026-05-14 20:51:11 -07:00

144 lines
4.9 KiB
TypeScript

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 */
}
}