mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) Five real production bugs from infiniteGameExp (PostgreSQL onboarding) and foxhoundinc (dream-cycle reproduction), each silent-failure shape where gbrain told the user the operation succeeded when it didn't. * #1422 — `gbrain dream` swallowed connectEngine errors. Bind the caught error and surface `[dream] WARNING: could not connect to DB (...)` on stderr before falling through to filesystem-only phases. runDream(null) no-DB fallback preserved. * #1433 — `gbrain sync` deleted previously-indexed log.md / schema.md / index.md / README.md pages on every re-sync. Refactor isSyncable through private classifySync helper; expose unsyncableReason (companion returning the same tagged reason) and SYNC_SKIP_FILES named export. Cleanup loop guards on reason === 'metafile' before deleting. * #1434 — `gbrain sync` without --source on single-vault brains routed to source_id='default' (zero pages) and silently failed. Add resolver tier 5.5 'sole_non_default' AFTER brain_default (explicit user intent wins). Wire runSync + runImport to call resolveSourceWithTier unconditionally so the tier actually fires. Stderr nudge on tier hit; suppress with GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1. * #1309 — overlapping ingest roots created duplicate pages. New BrainEngine.findDuplicatePage?(sourceId, {hash, frontmatterId}) with identity-based posture: SKIP when frontmatter.id matches (true external duplicate), WARN-ALWAYS on content_hash collision with different/missing fm.id, FAIL CLOSED on lookup error. Migration v95 adds partial index pages_dedup_idx (Postgres CONCURRENTLY, PGLite plain CREATE). * #1436 — MCP fuzzy get_page returned slug candidates from sources outside caller's scope. resolveSlugs signature extended with {sourceId?, sourceIds?} matching the sourceScopeOpts helper output; operations.ts threads it through. Both engines preserve unscoped back-compat for internal CLI callers. Plus a stable tiebreaker on searchVector ORDER BY (score DESC, page_id ASC, chunk_id ASC) in both engines. Caught while wiring the index above — basis-vector eval fixtures with tied scores depend on planner row order, which any new index on pages could flip. Pins eval-replay-gate ranking determinism against future index changes. Per codex review of the original plan: caught 6 load-bearing gaps that the engineering review missed (runSync bypass, #1436 misclassified as fixed, dedup fail-open, content-hash-alone too aggressive, soft-delete filter missing, tier-ordering contradiction). All folded in pre-merge. Tests: 65 new wave cases across 7 new files + 1 extended; all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.13.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
7.0 KiB
TypeScript
161 lines
7.0 KiB
TypeScript
/**
|
|
* v0.41.13 (#1433) — re-sync preserves previously-indexed metafile pages.
|
|
*
|
|
* Bug class (infiniteGameExp): a domain `log.md` page that was indexed by
|
|
* an older gbrain version (back when isSyncable() didn't filter `log.md`)
|
|
* or via a direct put_page was being deleted on every subsequent
|
|
* `gbrain sync --skip-failed` because the cleanup loop at
|
|
* commands/sync.ts:772 treated all unsyncable-modified paths the same.
|
|
*
|
|
* Fix: the cleanup loop skips the delete when `unsyncableReason(path)`
|
|
* returns `'metafile'`. Pages stay in the index for the lifetime they
|
|
* were originally indexed.
|
|
*
|
|
* This is the IRON-RULE regression test for #1433. Marked `.serial.test.ts`
|
|
* because it spawns git subprocesses and shares a single PGLite engine
|
|
* across tests for cold-start amortization.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
function gitInit(repo: string): void {
|
|
execSync('git init', { cwd: repo, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: repo, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: repo, stdio: 'pipe' });
|
|
}
|
|
|
|
describe('#1433 — re-sync preserves previously-indexed metafile pages', () => {
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
}, 60_000);
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-metafile-'));
|
|
gitInit(repoPath);
|
|
// Seed a non-metafile page that DOES get synced — this exercises the
|
|
// happy path so we know sync ran at all.
|
|
mkdirSync(join(repoPath, 'topics'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'topics/foo.md'), [
|
|
'---',
|
|
'type: concept',
|
|
'title: Foo',
|
|
'---',
|
|
'',
|
|
'Baseline content.',
|
|
].join('\n'));
|
|
// The metafile we care about: log.md is filtered by isSyncable.
|
|
mkdirSync(join(repoPath, 'learning'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'learning/log.md'), '## [2026-05-25] domain log entry\n');
|
|
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('log.md indexed via direct putPage survives re-sync after edit', async () => {
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
|
|
// First sync: log.md is filtered, only topics/foo.md lands.
|
|
const first = await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
|
expect(['first_sync', 'synced']).toContain(first.status);
|
|
|
|
// Seed the log page directly — simulate it being indexed by an older
|
|
// gbrain version or via a hand-rolled put_page call. This is the
|
|
// exact pre-condition that triggered infiniteGameExp's bug.
|
|
await engine.putPage('learning/log', {
|
|
type: 'concept',
|
|
title: 'Learning log',
|
|
compiled_truth: 'Pre-existing page that should survive re-sync.',
|
|
timeline: '',
|
|
frontmatter: { type: 'concept', id: 'learning-log' },
|
|
});
|
|
|
|
// Confirm seed worked.
|
|
const seeded = await engine.getPage('learning/log');
|
|
expect(seeded).not.toBeNull();
|
|
|
|
// Edit log.md so it appears in manifest.modified on the next sync.
|
|
writeFileSync(join(repoPath, 'learning/log.md'), '## [2026-05-25] entry\n## [2026-05-26] another\n');
|
|
execSync('git add -A && git commit -m "edit log"', { cwd: repoPath, stdio: 'pipe' });
|
|
|
|
// Second sync: pre-fix would have deleted the learning/log page.
|
|
const second = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
// 'up_to_date' is the most common outcome here — log.md is the only edit,
|
|
// and filtered.modified excludes metafiles, so from the syncable-view
|
|
// nothing changed. But the cleanup loop on unsyncableModified DOES still
|
|
// run and that's the codepath we're testing.
|
|
expect(['synced', 'first_sync', 'blocked_by_failures', 'up_to_date']).toContain(second.status);
|
|
|
|
// IRON RULE: page survives. This is the regression.
|
|
const survivor = await engine.getPage('learning/log');
|
|
expect(survivor).not.toBeNull();
|
|
expect(survivor?.compiled_truth).toContain('Pre-existing page');
|
|
}, 60_000);
|
|
|
|
test('schema.md indexed via direct putPage also survives (covers full SYNC_SKIP_FILES set)', async () => {
|
|
// Write + commit schema.md so it has a path entry in git.
|
|
writeFileSync(join(repoPath, 'topics/schema.md'), '# schema\n');
|
|
execSync('git add -A && git commit -m "add schema"', { cwd: repoPath, stdio: 'pipe' });
|
|
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
|
|
|
await engine.putPage('topics/schema', {
|
|
type: 'concept',
|
|
title: 'Schema',
|
|
compiled_truth: 'Pre-existing schema page.',
|
|
timeline: '',
|
|
frontmatter: { type: 'concept' },
|
|
});
|
|
|
|
writeFileSync(join(repoPath, 'topics/schema.md'), '# schema (edited)\n');
|
|
execSync('git add -A && git commit -m "edit schema"', { cwd: repoPath, stdio: 'pipe' });
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
|
|
const survivor = await engine.getPage('topics/schema');
|
|
expect(survivor).not.toBeNull();
|
|
}, 60_000);
|
|
|
|
test('non-metafile that becomes un-syncable (renamed .md → .txt) IS still cleaned up', async () => {
|
|
// Negative case: prove the guard is narrow — it only protects
|
|
// metafile classification, not the broader "this page used to be
|
|
// sync-eligible but isn't anymore" case.
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
|
|
|
// topics/foo was indexed by the first sync; confirm.
|
|
const before = await engine.getPage('topics/foo');
|
|
expect(before).not.toBeNull();
|
|
|
|
// Rename foo.md → foo.txt so it fails isSyncable with reason='strategy'.
|
|
execSync('git mv topics/foo.md topics/foo.txt', { cwd: repoPath, stdio: 'pipe' });
|
|
// Also edit so it appears in manifest.modified (not just .deleted).
|
|
writeFileSync(join(repoPath, 'topics/foo.txt'), 'now a .txt file');
|
|
execSync('git add -A && git commit -m "rename + edit"', { cwd: repoPath, stdio: 'pipe' });
|
|
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
|
|
// Pre-fix AND post-fix: the page should be cleaned up because the
|
|
// reason is 'strategy' (not 'metafile'), so the guard doesn't fire.
|
|
const after = await engine.getPage('topics/foo');
|
|
expect(after).toBeNull();
|
|
}, 60_000);
|
|
});
|