Files
gbrain/src/core/import-checkpoint.ts
da1bab532a fix(import): make checkpoints staging-first — canonical dir identity + self-describing metadata (#2935)
Port of #1731 (diazMelgarejo) onto current master. gbrain import wrote
~/.gbrain/import-checkpoint.json with the caller's raw dir argument, so
a checkpoint left behind by an interrupted run (e.g. SIGTERM) could carry
"." or a symlinked spelling — an identity that resolves to whatever CWD
the next consumer happens to run from. Downstream tooling that treated
the checkpoint dir as an owned staging boundary could then act on the
wrong directory.

- runImport captures the import target ONCE via resolveImportTargetDir
  (resolve + realpathSync) and threads that canonical value through
  collection, checkpoint load/save, and resume filtering
- checkpoints are self-describing (schema_version: 1, owner: "gbrain",
  kind: "import"); loadCheckpoint tolerates absent metadata (legacy
  path-based files) but rejects present-and-wrong metadata and any
  relative dir
- checkpoint contract documented in docs/guides/live-sync.md (llms
  bundle regenerated)
- test/import-resume.test.ts fixture now realpaths its tmpdir so planted
  checkpoints match the canonicalized dir (macOS /var -> /private/var)

Fixes #1728

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: Lawrence Melgarejo <Lawrence@cyre.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:17:37 -07:00

178 lines
6.7 KiB
TypeScript

import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'fs';
import { relative, isAbsolute, resolve } 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 {
/** Checkpoint payload schema. v1 is path-based with explicit producer metadata. */
schema_version: 1;
/** Producer marker for downstream consumers that validate before acting. */
owner: 'gbrain';
/** Checkpoint kind. Prevents unrelated checkpoint files from being treated as import state. */
kind: 'import';
/** 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)';
export const IMPORT_CHECKPOINT_SCHEMA_VERSION = 1;
export const IMPORT_CHECKPOINT_OWNER = 'gbrain';
export const IMPORT_CHECKPOINT_KIND = 'import';
/**
* Capture the import target once at run start. `resolve()` removes caller
* spelling such as `.` or `../staging`; `realpathSync()` collapses symlinks
* and proves the target exists. The returned value is the only directory
* identity import checkpoints should ever persist (#1728 — a raw `.` here
* made the checkpoint `dir` resolve to whatever CWD the NEXT consumer ran
* from, which downstream tooling treated as an owned staging directory).
*/
export function resolveImportTargetDir(dir: string): string {
return realpathSync(resolve(dir));
}
/**
* 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 (!isAbsolute(obj.dir)) return null;
if (obj.dir !== currentDir) return null;
// Self-describing metadata (#1728): absent fields are tolerated (legacy
// path-based checkpoints predate them), but present-and-wrong means the
// file was written by something else — don't resume from it.
if (obj.schema_version !== undefined && obj.schema_version !== IMPORT_CHECKPOINT_SCHEMA_VERSION) return null;
if (obj.owner !== undefined && obj.owner !== IMPORT_CHECKPOINT_OWNER) return null;
if (obj.kind !== undefined && obj.kind !== IMPORT_CHECKPOINT_KIND) return null;
if (typeof obj.timestamp !== 'string') return null;
if (!obj.completedPaths.every((p): p is string => typeof p === 'string')) return null;
return {
schema_version: IMPORT_CHECKPOINT_SCHEMA_VERSION,
owner: IMPORT_CHECKPOINT_OWNER,
kind: IMPORT_CHECKPOINT_KIND,
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 = {
schema_version: IMPORT_CHECKPOINT_SCHEMA_VERSION,
owner: IMPORT_CHECKPOINT_OWNER,
kind: IMPORT_CHECKPOINT_KIND,
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 */
}
}