diff --git a/docs/guides/live-sync.md b/docs/guides/live-sync.md index fbf0fce6c..90d3fffa3 100644 --- a/docs/guides/live-sync.md +++ b/docs/guides/live-sync.md @@ -131,6 +131,16 @@ hashes match. If both a cron and `--watch` fire simultaneously, no conflict. history rewrite still hard-blocks even with `--skip-failed`. Run `gbrain sync --skip-failed` to acknowledge a known-bad set yourself. +5. **Import checkpoints name the import target, not the caller's CWD.** + Interrupted `gbrain import ` runs may leave + `~/.gbrain/import-checkpoint.json` so the next import can resume. The + checkpoint `dir` is the absolute, resolved import target captured when + import starts. It is not a cleanup instruction and it must not be + re-derived from the process working directory. Checkpoints written by + gbrain include `schema_version: 1`, `owner: "gbrain"`, and + `kind: "import"` so downstream tools can validate the contract before + deciding whether to resume. + ## How to Verify 1. **Edit a file and search for the change.** Edit a brain markdown file, diff --git a/llms-full.txt b/llms-full.txt index 1c1dd6834..1a9710038 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2767,6 +2767,16 @@ hashes match. If both a cron and `--watch` fire simultaneously, no conflict. history rewrite still hard-blocks even with `--skip-failed`. Run `gbrain sync --skip-failed` to acknowledge a known-bad set yourself. +5. **Import checkpoints name the import target, not the caller's CWD.** + Interrupted `gbrain import ` runs may leave + `~/.gbrain/import-checkpoint.json` so the next import can resume. The + checkpoint `dir` is the absolute, resolved import target captured when + import starts. It is not a cleanup instruction and it must not be + re-derived from the process working directory. Checkpoints written by + gbrain include `schema_version: 1`, `owner: "gbrain"`, and + `kind: "import"` so downstream tools can validate the contract before + deciding whether to resume. + ## How to Verify 1. **Edit a file and search for the change.** Edit a brain markdown file, diff --git a/src/commands/import.ts b/src/commands/import.ts index 241bcff28..1d2939318 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -20,6 +20,7 @@ import { loadCheckpoint, saveCheckpoint, clearCheckpoint, + resolveImportTargetDir, resumeFilter, } from '../core/import-checkpoint.ts'; @@ -168,7 +169,19 @@ export async function runImport( console.error('Usage: gbrain import [--no-embed] [--workers N] [--fresh] [--source-id ] [--json]'); process.exit(1); } - const dir: string = dirArg; // narrowed; survives closure capture + // #1728: capture the import target ONCE as an absolute real path. Every + // downstream consumer of `dir` (collection, checkpoint load/save, resume + // filtering) sees the same canonical identity — never the caller's `.`/ + // relative spelling, which would make the persisted checkpoint `dir` + // resolve against whatever CWD a later process happens to run from. + let dir: string; + try { + dir = resolveImportTargetDir(dirArg); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`Import target is not readable: ${dirArg} (${msg})`); + process.exit(1); + } // v0.31.2: collect under the right strategy. Pre-fix this called // collectMarkdownFiles unconditionally — code-strategy first sync @@ -288,6 +301,9 @@ export async function runImport( catch { /* non-fatal */ } } saveCheckpoint(checkpointPath, { + schema_version: 1, + owner: 'gbrain', + kind: 'import', dir, completedPaths: Array.from(completed), timestamp: new Date().toISOString(), diff --git a/src/core/import-checkpoint.ts b/src/core/import-checkpoint.ts index 9c7cd677c..64ea03c0f 100644 --- a/src/core/import-checkpoint.ts +++ b/src/core/import-checkpoint.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs'; -import { relative, isAbsolute } from 'path'; +import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'fs'; +import { relative, isAbsolute, resolve } from 'path'; /** * Path-based import checkpoint. @@ -25,6 +25,12 @@ import { relative, isAbsolute } from 'path'; * 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; /** @@ -37,6 +43,21 @@ export interface ImportCheckpoint { } 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. @@ -72,11 +93,21 @@ export function loadCheckpoint(path: string, currentDir: string): ImportCheckpoi } 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, @@ -98,6 +129,9 @@ export function saveCheckpoint(path: string, cp: ImportCheckpoint): void { // 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, diff --git a/test/import-checkpoint.test.ts b/test/import-checkpoint.test.ts index 1b6a6ad75..95208cd96 100644 --- a/test/import-checkpoint.test.ts +++ b/test/import-checkpoint.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync } from 'fs'; +import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, symlinkSync, realpathSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { @@ -7,6 +7,7 @@ import { saveCheckpoint, resumeFilter, clearCheckpoint, + resolveImportTargetDir, type ImportCheckpoint, } from '../src/core/import-checkpoint.ts'; @@ -52,6 +53,9 @@ describe('loadCheckpoint', () => { test('returns null when dir mismatches the current run', () => { const cp: ImportCheckpoint = { + schema_version: 1, + owner: 'gbrain', + kind: 'import', dir: '/other/brain', completedPaths: ['a.md'], timestamp: '2026-05-14T00:00:00Z', @@ -86,6 +90,30 @@ describe('loadCheckpoint', () => { expect(stderrCaptured).not.toContain('Older checkpoint format'); }); + test('returns null when dir is relative (#1728 — CWD-dependent identity)', () => { + writeFileSync(cpPath, JSON.stringify({ + schema_version: 1, + owner: 'gbrain', + kind: 'import', + dir: '.', + completedPaths: ['a.md'], + timestamp: '2026-01-01T00:00:00Z', + })); + expect(loadCheckpoint(cpPath, '.')).toBeNull(); + }); + + test('returns null when self-describing metadata is wrong', () => { + writeFileSync(cpPath, JSON.stringify({ + schema_version: 99, + owner: 'some-tool', + kind: 'other', + dir: '/tmp/example-brain', + completedPaths: ['a.md'], + timestamp: '2026-01-01T00:00:00Z', + })); + expect(loadCheckpoint(cpPath, '/tmp/example-brain')).toBeNull(); + }); + test('returns null when completedPaths contains non-strings', () => { writeFileSync(cpPath, JSON.stringify({ dir: '/tmp/example-brain', @@ -97,6 +125,9 @@ describe('loadCheckpoint', () => { test('returns the checkpoint for valid v0.33.2 payload', () => { const cp: ImportCheckpoint = { + schema_version: 1, + owner: 'gbrain', + kind: 'import', dir: '/tmp/example-brain', completedPaths: ['meetings/2026-05-13.md', 'concepts/foo.md'], timestamp: '2026-05-14T12:34:56Z', @@ -107,12 +138,31 @@ describe('loadCheckpoint', () => { 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'); + expect(loaded?.schema_version).toBe(1); + expect(loaded?.owner).toBe('gbrain'); + expect(loaded?.kind).toBe('import'); + }); + + test('returns legacy path-based checkpoint without metadata as v1 in memory', () => { + writeFileSync(cpPath, JSON.stringify({ + dir: '/tmp/example-brain', + completedPaths: ['a.md'], + timestamp: '2026-05-14T12:34:56Z', + })); + const loaded = loadCheckpoint(cpPath, '/tmp/example-brain'); + expect(loaded?.schema_version).toBe(1); + expect(loaded?.owner).toBe('gbrain'); + expect(loaded?.kind).toBe('import'); + expect(loaded?.dir).toBe('/tmp/example-brain'); }); }); describe('saveCheckpoint', () => { test('round-trips through loadCheckpoint', () => { const cp: ImportCheckpoint = { + schema_version: 1, + owner: 'gbrain', + kind: 'import', dir: '/tmp/example-brain', completedPaths: ['a.md', 'b.md', 'c.md'], timestamp: '2026-05-14T00:00:00Z', @@ -125,16 +175,25 @@ describe('saveCheckpoint', () => { test('serializes completedPaths sorted (deterministic output)', () => { saveCheckpoint(cpPath, { + schema_version: 1, + owner: 'gbrain', + kind: 'import', 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.schema_version).toBe(1); + expect(onDisk.owner).toBe('gbrain'); + expect(onDisk.kind).toBe('import'); expect(onDisk.completedPaths).toEqual(['a.md', 'm.md', 'z.md']); }); test('atomic-ish write — no stray .tmp file after success', () => { saveCheckpoint(cpPath, { + schema_version: 1, + owner: 'gbrain', + kind: 'import', dir: '/tmp/example-brain', completedPaths: ['a.md'], timestamp: '2026-05-14T00:00:00Z', @@ -148,6 +207,9 @@ describe('saveCheckpoint', () => { const badPath = join(workDir, 'does-not-exist', 'cp.json'); expect(() => saveCheckpoint(badPath, { + schema_version: 1, + owner: 'gbrain', + kind: 'import', dir: '/tmp/example-brain', completedPaths: ['a.md'], timestamp: '2026-05-14T00:00:00Z', @@ -157,6 +219,32 @@ describe('saveCheckpoint', () => { }); }); +describe('resolveImportTargetDir', () => { + test('captures a relative import target as an absolute real path', () => { + const target = join(workDir, 'staging'); + mkdirSync(target); + const cwd = process.cwd(); + try { + process.chdir(workDir); + expect(resolveImportTargetDir('staging')).toBe(realpathSync(target)); + } finally { + process.chdir(cwd); + } + }); + + test('collapses symlink spelling to the real import target', () => { + const target = join(workDir, 'real-staging'); + const link = join(workDir, 'linked-staging'); + mkdirSync(target); + symlinkSync(target, link); + expect(resolveImportTargetDir(link)).toBe(realpathSync(target)); + }); + + test('throws when the target does not exist', () => { + expect(() => resolveImportTargetDir(join(workDir, 'nope'))).toThrow(); + }); +}); + describe('resumeFilter', () => { test('empty completed set returns all files unchanged', () => { const all = ['a.md', 'b.md', 'c.md']; diff --git a/test/import-resume.test.ts b/test/import-resume.test.ts index e266ba45b..278f62b4e 100644 --- a/test/import-resume.test.ts +++ b/test/import-resume.test.ts @@ -20,7 +20,7 @@ * `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 { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, realpathSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; @@ -52,7 +52,9 @@ beforeEach(async () => { gbrainHomeDir = join(workspace, '.gbrain'); mkdirSync(gbrainHomeDir, { recursive: true }); cpPath = join(gbrainHomeDir, 'import-checkpoint.json'); - brainDir = mkdtempSync(join(tmpdir(), 'gbrain-import-resume-brain-')); + // #1728: realpath so planted checkpoints match runImport's canonicalized + // dir (macOS tmpdir is a /var → /private/var symlink). + brainDir = realpathSync(mkdtempSync(join(tmpdir(), 'gbrain-import-resume-brain-'))); }); afterEach(() => {