mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
220 lines
8.6 KiB
TypeScript
220 lines
8.6 KiB
TypeScript
/**
|
|
* 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 { 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';
|
|
|
|
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
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
}, 60_000);
|
|
|
|
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);
|
|
});
|