mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +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>
196 lines
8.1 KiB
TypeScript
196 lines
8.1 KiB
TypeScript
/**
|
|
* v0.41.13 (#1434) — sole_non_default tier (5.5) in resolveSourceId /
|
|
* resolveSourceWithTier.
|
|
*
|
|
* When NO brain_default config is set AND exactly one registered source has
|
|
* local_path set and isn't 'default', auto-route to it. Closes the bug
|
|
* class where `gbrain sync` without --source silently routed to source_id
|
|
* 'default' even though the user had a single Vault-mounted source.
|
|
*
|
|
* Tier ordering placement codex review forced:
|
|
* - AFTER brain_default (explicit user intent wins)
|
|
* - BEFORE seed_default (auto-route beats the empty terminal)
|
|
*
|
|
* Tests use a stub BrainEngine that only implements the three methods the
|
|
* resolver touches: executeRaw, getConfig, kind. Hermetic — no PGLite.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
resolveSourceId,
|
|
resolveSourceWithTier,
|
|
SOURCE_TIER_NAMES,
|
|
formatSoleNonDefaultNudge,
|
|
} from '../src/core/source-resolver.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
type StubSource = { id: string; local_path: string | null; archived?: boolean };
|
|
|
|
function makeStub(sources: StubSource[], globalDefault: string | null = null) {
|
|
return {
|
|
kind: 'pglite' as const,
|
|
async executeRaw<T>(sql: string, _params?: unknown[]): Promise<T[]> {
|
|
// Two query shapes hit in the resolver:
|
|
// 1. tier 4 (local_path match): SELECT id, local_path FROM sources WHERE local_path IS NOT NULL
|
|
// 2. assertSourceExists: SELECT id FROM sources WHERE id = $1
|
|
// 3. tier 5.5 (sole_non_default): SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false
|
|
if (sql.includes('archived = false')) {
|
|
return sources.filter(s => s.local_path !== null && s.id !== 'default' && s.archived !== true)
|
|
.map(s => ({ id: s.id })) as unknown as T[];
|
|
}
|
|
if (sql.includes('local_path IS NOT NULL AND id != \'default\'')) {
|
|
return sources.filter(s => s.local_path !== null && s.id !== 'default')
|
|
.map(s => ({ id: s.id })) as unknown as T[];
|
|
}
|
|
if (sql.includes('SELECT id, local_path FROM sources WHERE local_path IS NOT NULL')) {
|
|
return sources.filter(s => s.local_path !== null)
|
|
.map(s => ({ id: s.id, local_path: s.local_path })) as unknown as T[];
|
|
}
|
|
if (sql.includes('SELECT id FROM sources WHERE id =')) {
|
|
const id = (_params as string[])?.[0];
|
|
return sources.filter(s => s.id === id).map(s => ({ id: s.id })) as unknown as T[];
|
|
}
|
|
return [];
|
|
},
|
|
async getConfig(_key: string): Promise<string | null> {
|
|
return globalDefault;
|
|
},
|
|
} as unknown as Parameters<typeof resolveSourceId>[0];
|
|
}
|
|
|
|
describe('#1434 — sole_non_default tier', () => {
|
|
test('fires when exactly one non-default source is registered (no brain_default)', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
]);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('studiovault');
|
|
expect(result.tier).toBe('sole_non_default');
|
|
});
|
|
|
|
test('does NOT fire when 2+ non-default sources exist (ambiguous — user must pick)', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
{ id: 'second-vault', local_path: '/Users/india/other-vault' },
|
|
]);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('seed_default');
|
|
});
|
|
|
|
test('does NOT fire when 0 non-default sources exist (fresh install)', async () => {
|
|
const engine = makeStub([{ id: 'default', local_path: null }]);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('seed_default');
|
|
});
|
|
|
|
test('does NOT fire when sole non-default has NULL local_path (no on-disk shape)', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'remote-only', local_path: null }, // GitHub-only source
|
|
]);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('seed_default');
|
|
});
|
|
|
|
test('does NOT fire when brain_default is set (explicit user intent wins)', async () => {
|
|
const engine = makeStub(
|
|
[
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
],
|
|
'default', // user explicitly set sources.default
|
|
);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('brain_default');
|
|
});
|
|
|
|
test('does NOT fire when explicit --source flag is passed (tier 1 wins)', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
]);
|
|
const result = await resolveSourceWithTier(engine, 'default', '/tmp');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('flag');
|
|
});
|
|
|
|
test('does NOT fire when GBRAIN_SOURCE env is set (tier 2 wins)', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
]);
|
|
await withEnv({ GBRAIN_SOURCE: 'default' }, async () => {
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.source_id).toBe('default');
|
|
expect(result.tier).toBe('env');
|
|
});
|
|
});
|
|
|
|
test('archived non-default source is ignored (does not count toward the 1)', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
{ id: 'old-vault', local_path: '/Users/india/archive', archived: true },
|
|
]);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
// archived 'old-vault' shouldn't count → still one non-default → fires
|
|
expect(result.source_id).toBe('studiovault');
|
|
expect(result.tier).toBe('sole_non_default');
|
|
});
|
|
|
|
test('resolveSourceId mirrors resolveSourceWithTier on the new tier', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
]);
|
|
const flat = await resolveSourceId(engine, null, '/tmp');
|
|
const tagged = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(flat).toBe(tagged.source_id);
|
|
});
|
|
|
|
test('detail string explains the routing', async () => {
|
|
const engine = makeStub([
|
|
{ id: 'default', local_path: null },
|
|
{ id: 'studiovault', local_path: '/Users/india/vault' },
|
|
]);
|
|
const result = await resolveSourceWithTier(engine, null, '/tmp');
|
|
expect(result.detail).toContain('only non-default');
|
|
});
|
|
});
|
|
|
|
describe('SOURCE_TIER_NAMES includes sole_non_default at index 5', () => {
|
|
test('positioned between brain_default and seed_default', () => {
|
|
const idx = SOURCE_TIER_NAMES.indexOf('sole_non_default');
|
|
expect(idx).toBeGreaterThan(SOURCE_TIER_NAMES.indexOf('brain_default'));
|
|
expect(idx).toBeLessThan(SOURCE_TIER_NAMES.indexOf('seed_default'));
|
|
});
|
|
});
|
|
|
|
describe('formatSoleNonDefaultNudge', () => {
|
|
test('returns canonical nudge string in default env', async () => {
|
|
await withEnv({ GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: undefined }, async () => {
|
|
expect(formatSoleNonDefaultNudge('studiovault')).toBe(
|
|
"[gbrain] routing to source 'studiovault' (sole non-default source registered; pass --source to override).",
|
|
);
|
|
});
|
|
});
|
|
|
|
test('returns null when GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1 suppresses', async () => {
|
|
await withEnv({ GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: '1' }, async () => {
|
|
expect(formatSoleNonDefaultNudge('studiovault')).toBeNull();
|
|
});
|
|
});
|
|
|
|
test('any value other than literal "1" does NOT suppress', async () => {
|
|
await withEnv({ GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: 'true' }, async () => {
|
|
expect(formatSoleNonDefaultNudge('studiovault')).not.toBeNull();
|
|
});
|
|
});
|
|
});
|