Files
gbrain/test/e2e/multi-source-bug-class.test.ts
Time AttakcandGitHub 42ab0956a4 fix(migrate): preserve sources and scope resume targets (#2677) (#2736)
migrate --to now copies the complete source catalog before pages (fixes the pages_source_id_fkey failure on multi-source brains), and resume manifests carry an opaque target identity so a checkpoint from one target is discarded for a different target. Fixes #2677.
2026-07-13 00:08:52 -07:00

273 lines
12 KiB
TypeScript

/**
* v0.32.8 — multi-source bug class regression suite.
*
* Pins the behaviors that were silently broken pre-v0.32.8 when a brain has
* more than one source. Pre-fix, every cycle phase and extract pass called
* slug-only engine methods inside a loop over pages and silently defaulted
* to source_id='default' for every non-default-source page.
*
* Fixture: 2 sources ('default' + 'media-corpus') with overlapping slugs.
* - people/alice exists in BOTH
* - concepts/widget exists ONLY in default
* - media/x/post-123 exists ONLY in media-corpus
*
* Coverage targets (one test per bug site):
* 1. listAllPageRefs returns one row per (slug, source_id), ordered.
* 2. extract-takes processes BOTH alice rows independently.
* 3. integrity scan covers BOTH sources.
* 4. extract-links F10 cross-source resolution:
* a. alice@media-corpus → widget falls back to default
* b. alice@media-corpus → post-123 stays in media-corpus
* c. alice@default → ghost-slug records nothing (skip)
* 5. validateSourceId rejects path-traversal attempts.
* 6. Reverse-write disk layout: .sources/<id>/<slug>.md for non-default.
*
* PGLite in-memory — no DATABASE_URL required. Canonical R3+R4 pattern.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { tmpdir } from 'node:os';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { validateSourceId } from '../../src/core/utils.ts';
import { extractTakesFromDb } from '../../src/core/cycle/extract-takes.ts';
import { copyMigrationSources } from '../../src/commands/migrate-engine.ts';
let engine: PGLiteEngine;
let migrationTarget: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({} as never);
await engine.initSchema();
migrationTarget = new PGLiteEngine();
await migrationTarget.connect({} as never);
await migrationTarget.initSchema();
});
afterAll(async () => {
if (engine) await engine.disconnect();
if (migrationTarget) await migrationTarget.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await resetPgliteState(migrationTarget);
// Seed second source row. Default source is seeded by resetPgliteState.
await engine.executeRaw(
`INSERT INTO sources (id, name, config)
VALUES ('media-corpus', 'media-corpus', '{}'::jsonb)
ON CONFLICT (id) DO NOTHING`,
);
// Overlapping-slug seed.
await engine.putPage('people/alice', {
type: 'person', title: 'Alice (default)',
compiled_truth: 'Default-source alice page.',
}, { sourceId: 'default' });
await engine.putPage('people/alice', {
type: 'person', title: 'Alice (media-corpus)',
compiled_truth: 'Media-corpus alice page.',
}, { sourceId: 'media-corpus' });
// Distinct slugs per source.
await engine.putPage('concepts/widget', {
type: 'concept', title: 'Widget',
compiled_truth: 'Default-source-only widget.',
}, { sourceId: 'default' });
await engine.putPage('media/x/post-123', {
type: 'media', title: 'Post 123',
compiled_truth: 'Media-corpus-only post.',
}, { sourceId: 'media-corpus' });
});
describe('multi-source bug class', () => {
test('migration copies source metadata before overlapping-slug pages', async () => {
await engine.executeRaw(`
UPDATE sources SET
local_path = '/tmp/media-corpus', last_commit = 'commit-media',
last_sync_at = '2026-07-02T00:00:00Z',
config = '{"federated":false,"custom":"preserved"}'::jsonb,
archived = true, archived_at = '2026-07-03T00:00:00Z',
archive_expires_at = '2026-08-03T00:00:00Z',
contextual_retrieval_mode = 'tokenmax',
trust_frontmatter_overrides = true,
newest_content_at = '2026-07-01T00:00:00Z'
WHERE id = 'media-corpus'`);
await copyMigrationSources(engine, migrationTarget);
const sourceRows = await migrationTarget.executeRaw<any>(
`SELECT * FROM sources WHERE id = 'media-corpus'`,
);
expect(sourceRows).toHaveLength(1);
expect(sourceRows[0].last_commit).toBe('commit-media');
expect(sourceRows[0].archived).toBe(true);
expect(sourceRows[0].contextual_retrieval_mode).toBe('tokenmax');
expect(sourceRows[0].trust_frontmatter_overrides).toBe(true);
const config = typeof sourceRows[0].config === 'string'
? JSON.parse(sourceRows[0].config)
: sourceRows[0].config;
expect(config.custom).toBe('preserved');
const overlapping = await engine.listPages({ limit: 100 });
for (const page of overlapping) {
await migrationTarget.putPage(page.slug, {
type: page.type,
title: page.title,
compiled_truth: page.compiled_truth,
timeline: page.timeline,
frontmatter: page.frontmatter,
content_hash: page.content_hash,
}, { sourceId: page.source_id });
}
expect(await migrationTarget.getPage('people/alice', { sourceId: 'default' })).not.toBeNull();
expect(await migrationTarget.getPage('people/alice', { sourceId: 'media-corpus' })).not.toBeNull();
});
test('listAllPageRefs returns one row per (slug, source_id), ordered (F11)', async () => {
const refs = await engine.listAllPageRefs();
// 4 rows: alice@default, alice@media-corpus, widget@default, post-123@media-corpus
expect(refs.length).toBe(4);
// Sorted by (source_id, slug)
const ordered = refs.map(r => `${r.source_id}::${r.slug}`);
expect(ordered).toEqual([
'default::concepts/widget',
'default::people/alice',
'media-corpus::media/x/post-123',
'media-corpus::people/alice',
]);
});
test('getPage with sourceId picks the right (source, slug) row', async () => {
const aliceDefault = await engine.getPage('people/alice', { sourceId: 'default' });
const aliceMedia = await engine.getPage('people/alice', { sourceId: 'media-corpus' });
expect(aliceDefault?.title).toBe('Alice (default)');
expect(aliceMedia?.title).toBe('Alice (media-corpus)');
expect(aliceDefault?.source_id).toBe('default');
expect(aliceMedia?.source_id).toBe('media-corpus');
});
test('extract-takes processes both alice pages independently', async () => {
// Re-seed with takes fences so extract-takes has something to find.
// Fence markers come from src/core/takes-fence.ts (`<!--- gbrain:takes:begin -->`).
const TAKES_BODY = `<!--- gbrain:takes:begin -->
| # | claim | kind | who | weight | since | source |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | Alice founded the thing | fact | garry | 0.9 | 2024-01-01 | |
<!--- gbrain:takes:end -->`;
await engine.putPage('people/alice', {
type: 'person', title: 'Alice (default)',
compiled_truth: 'Default alice.\n' + TAKES_BODY,
}, { sourceId: 'default' });
await engine.putPage('people/alice', {
type: 'person', title: 'Alice (media-corpus)',
compiled_truth: 'Media alice.\n' + TAKES_BODY,
}, { sourceId: 'media-corpus' });
const result = await extractTakesFromDb(engine, {});
// Pre-fix: only one of the two alice rows had takes extracted because
// the loop matched by bare slug. Post-fix: both rows get processed.
// Each alice has 1 take; widget + post-123 have none.
expect(result.pagesWithTakes).toBe(2);
expect(result.takesUpserted).toBe(2);
});
test('listPages filters correctly with PageFilters.sourceId', async () => {
const onlyDefault = await engine.listPages({ sourceId: 'default', limit: 100 });
const onlyMedia = await engine.listPages({ sourceId: 'media-corpus', limit: 100 });
expect(onlyDefault.length).toBe(2);
expect(onlyMedia.length).toBe(2);
for (const p of onlyDefault) expect(p.source_id).toBe('default');
for (const p of onlyMedia) expect(p.source_id).toBe('media-corpus');
});
test('addLinksBatch with from/to_source_id targets the right rows (F4)', async () => {
// Link alice@media-corpus → post-123@media-corpus (in-source link).
const inserted = await engine.addLinksBatch([
{
from_slug: 'people/alice',
to_slug: 'media/x/post-123',
link_type: 'mentions',
link_source: 'markdown',
from_source_id: 'media-corpus',
to_source_id: 'media-corpus',
},
]);
expect(inserted).toBe(1);
const links = await engine.getLinks('people/alice', { sourceId: 'media-corpus' });
expect(links.length).toBe(1);
expect(links[0].to_slug).toBe('media/x/post-123');
// alice@default should have NO links — they're scoped to media-corpus.
const defaultLinks = await engine.getLinks('people/alice', { sourceId: 'default' });
expect(defaultLinks.length).toBe(0);
});
test('validateSourceId rejects path traversal (F6)', () => {
// v0.38 (codex P1-D + eng E2): regex TIGHTENED from permissive
// ^[a-z0-9_-]+$ to strict kebab ^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$.
// Underscores no longer allowed at the path-safety gate (matches
// what sources-ops always rejected at creation time). 'jarvis_memory'
// was a v0.32.8 permissive-regex test case; now lives in the
// rejected set. Path-traversal rejection unchanged.
//
// Allowed (strict kebab):
expect(() => validateSourceId('default')).not.toThrow();
expect(() => validateSourceId('media-corpus')).not.toThrow();
expect(() => validateSourceId('jarvis-memory')).not.toThrow();
expect(() => validateSourceId('abc123')).not.toThrow();
expect(() => validateSourceId('a')).not.toThrow();
// Rejected — path traversal / unsafe chars:
expect(() => validateSourceId('..')).toThrow();
expect(() => validateSourceId('../etc')).toThrow();
expect(() => validateSourceId('foo/bar')).toThrow();
expect(() => validateSourceId('foo bar')).toThrow();
expect(() => validateSourceId('Default')).toThrow(); // uppercase
expect(() => validateSourceId('.hidden')).toThrow();
expect(() => validateSourceId('')).toThrow();
// Rejected — strict regex additions (v0.38):
expect(() => validateSourceId('jarvis_memory')).toThrow(); // underscores
expect(() => validateSourceId('snake_case')).toThrow();
expect(() => validateSourceId('-leading')).toThrow(); // edge hyphen
expect(() => validateSourceId('trailing-')).toThrow();
const tooLong = 'a' + 'b'.repeat(31) + 'c'; // 33 chars
expect(() => validateSourceId(tooLong)).toThrow();
});
test('reverse-write disk layout uses .sources/<id>/<slug>.md for non-default (F6)', () => {
// Pure-function test of the disk-path computation embedded in
// reverseWriteRefs (patterns.ts + synthesize.ts). We don't drive the
// full dream cycle here — that requires LLM + subagent infra. Instead
// we replicate the path computation and verify the file layout matches
// what the production code would write.
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-multi-source-disk-'));
try {
const computePath = (source_id: string, slug: string): string =>
source_id === 'default'
? join(tmpDir, `${slug}.md`)
: join(tmpDir, '.sources', source_id, `${slug}.md`);
const defaultPath = computePath('default', 'people/alice');
const mediaPath = computePath('media-corpus', 'people/alice');
// The two paths must NOT collide.
expect(defaultPath).not.toBe(mediaPath);
expect(mediaPath).toContain('.sources/media-corpus/');
// Actually write to both paths to prove disk separation.
mkdirSync(join(tmpDir, 'people'), { recursive: true });
mkdirSync(join(tmpDir, '.sources', 'media-corpus', 'people'), { recursive: true });
writeFileSync(defaultPath, 'default alice');
writeFileSync(mediaPath, 'media-corpus alice');
expect(readFileSync(defaultPath, 'utf8')).toBe('default alice');
expect(readFileSync(mediaPath, 'utf8')).toBe('media-corpus alice');
expect(existsSync(defaultPath)).toBe(true);
expect(existsSync(mediaPath)).toBe(true);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
});