mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat: structured error code summary for sync --skip-failed (#500) When sync encounters per-file failures, the blocked/skip-failed messages now include a breakdown by error code (SLUG_MISMATCH, YAML_PARSE, etc.) instead of just a raw count. This makes it immediately obvious *why* files failed without requiring manual investigation. Changes: - Add classifyErrorCode() — maps error messages to ParseValidationCode - Add summarizeFailuresByCode() — groups failures into sorted code summary - SyncFailure now carries a 'code' field (backfilled on acknowledge) - acknowledgeSyncFailures() returns AcknowledgeResult {count, summary} - sync blocked + skip-failed messages show code breakdown - doctor sync_failures check shows code breakdown for both unacked and historical - 12 new tests for classifyErrorCode, summarizeFailuresByCode, and structured returns Before: Sync blocked: 2688 file(s) failed to parse. After: Sync blocked: 2688 file(s) failed to parse: SLUG_MISMATCH: 2685 YAML_DUPLICATE_KEY: 3 Closes #500 * fix: eng-review fixes for sync error-code classification - Reorder classifyErrorCode() so DB-layer errors (DB_DUPLICATE_KEY, STATEMENT_TIMEOUT) check BEFORE YAML patterns. Postgres "duplicate key value violates unique constraint" no longer mislabels as YAML_DUPLICATE_KEY. - Rewrite MISSING_OPEN/MISSING_CLOSE/EMPTY_FRONTMATTER/NULL_BYTES/NESTED_QUOTES regexes to match the canonical messages emitted by collectValidationErrors() in src/core/markdown.ts. Previous patterns (e.g. /missing.*open/i) never fired because the upstream throw site emits prose ("File is empty...", "No closing --- delimiter found"), not the code name. - Extract formatCodeBreakdown() helper that accepts either raw failures or pre-summarized {code, count}[] input. Replaces 3 duplicate inline builders in src/commands/sync.ts. - 15 new tests (37/37 pass on test/sync-failures.test.ts): - DB vs YAML duplicate-key disambiguation (3 cases) - Canonical-message coverage for the 5 frontmatter codes (7 cases) - acknowledgeSyncFailures() legacy-entry backfill branch (2 cases) - formatCodeBreakdown() dual-input shape (3 cases) - TODOS.md: file 3 follow-ups (P2 plumb structured ParseValidationCode; P0-at-ship CHANGELOG migration note for AcknowledgeResult; P3 concurrent- safe ack of sync-failures.jsonl). Eng-review plan: ~/.claude/plans/then-codex-synchronous-toucan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.22.9) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: 16-core runner + 4-way matrix shard for test job The unit test suite ran 22m17s on ubuntu-latest (2-core/7GB) because: - 187 test files run with bun test parallelism bounded by core count - 23 of those files spin up a fresh PGLiteEngine + initSchema in beforeEach, paying ~22s WASM cold-start per test on the small runner This commit fixes the runner side: - runs-on: ubuntu-latest-16-cores (16 vCPU / 64 GB RAM) - strategy.matrix.shard splits 4 parallel jobs, each running ~40 of 158 unit test files. Single-file wall-time floor is ~3 min after the test refactor, so 4 shards × 16 cores hits the floor quickly without wasting cores past it. - pre-test gates (typecheck, check-jsonb, check-progress, check-wasm) only run on shard 1 — they're not test files and don't benefit from sharding. scripts/test-shard.sh partitions test files by stable FNV-1a hash mod N. Same file always lands in the same shard, so retries are reproducible. Pure shell, portable to bash 3.2 (macOS) and bash 5.x (CI). Excludes test/e2e/ which runs via bun run test:e2e separately and needs DATABASE_URL. Also: ignore .claude/ harness state files (scheduled_tasks.lock etc) instead of just .claude/skills/. Cost: ~$0.19/run vs $0 (public repo, default runner is free). At 50 PRs/month that's ~$10/month for ~5x faster CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: refactor top-3 PGLite-heavy files to share one engine per file Three test files were spinning up a fresh PGLiteEngine + connect + initSchema in beforeEach. PGLite WASM cold-start is ~22s on the small CI runner; doing this per test multiplied wall-time across the suite. The 3 files alone accounted for ~6.5 min of the 22m CI run (177s + 132s + 87s). Refactor: move PGLite setup to beforeAll (one engine per file), wipe data in beforeEach via the new test/helpers/reset-pglite.ts helper. The reset helper: - TRUNCATEs every public table CASCADE, including sources (so tests that register their own sources don't leak rows into the next test). - Re-seeds the default source row that pages.source_id's DEFAULT FKs against. Without this, the next page insert would fail FK validation. - Preserves schema_version so migration helpers don't think the brain is on v0. Files refactored: - test/extract-incremental.test.ts (8 tests, was 177s on CI) - test/brain-writer.test.ts (16 tests; only the scanBrainSources block uses PGLite, was 132s on CI) - test/sync.test.ts (37 tests; only the performSync dry-run block uses PGLite, was 87s on CI) All 61 tests still pass locally. The remaining 20 PGLite-heavy files use the same beforeEach anti-pattern; this commit only refactors the proven worst offenders. Sweep the rest in a follow-up if CI numbers indicate it's worth it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: fall back to ubuntu-latest for matrix shard The ubuntu-latest-16-cores label requires a provisioned larger-runner pool in repo/org settings. Without that setup, jobs queue indefinitely waiting for a runner that doesn't exist (verified: 4 shards stuck in 'queued' status with empty runner_name for 5+ min). Drop back to the default 2-core ubuntu-latest. The 4-way matrix shard still delivers ~5-6x speedup via parallelism alone — 4 jobs running in parallel, each handling ~40 of 158 unit test files. Cost stays $0 (default runner is free for public repos). If we ever provision a larger-runner pool, flip this label back to ubuntu-latest-16-cores. The matrix + sharder will use the bigger boxes unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Wintermute <wintermute@garrytan.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
7.5 KiB
TypeScript
194 lines
7.5 KiB
TypeScript
/**
|
||
* Regression guards for the incremental extract path (PR #417).
|
||
*
|
||
* Eng-review Step 5: 8 unit cases asserting `runExtractCore({ slugs })`
|
||
* processes only the requested slugs in the cycle path while
|
||
* `slugs: undefined` falls through to the existing full-walk behavior.
|
||
*
|
||
* All tests use PGLite/in-memory — no DB connection required.
|
||
*/
|
||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
import { runExtractCore } from '../src/commands/extract.ts';
|
||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||
import type { BrainEngine } from '../src/core/engine.ts';
|
||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||
|
||
// One PGLite per file (beforeAll), wipe data per test (beforeEach).
|
||
// PGLite cold-start dominates wall-time; sharing the engine across all tests
|
||
// in this file cuts ~22s × 8 tests = ~3 min on CI.
|
||
let engine: PGLiteEngine;
|
||
let tempDir: string;
|
||
|
||
beforeAll(async () => {
|
||
engine = new PGLiteEngine();
|
||
await engine.connect({ engine: 'pglite' });
|
||
await engine.initSchema();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await engine.disconnect();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await resetPgliteState(engine);
|
||
tempDir = mkdtempSync(join(tmpdir(), 'gbrain-extract-test-'));
|
||
mkdirSync(join(tempDir, 'people'), { recursive: true });
|
||
mkdirSync(join(tempDir, 'companies'), { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
async function seedPage(slug: string, body: string): Promise<void> {
|
||
const [type, name] = slug.split('/');
|
||
await engine.putPage(slug, {
|
||
type: type as 'person' | 'company',
|
||
title: name,
|
||
compiled_truth: body,
|
||
timeline: '',
|
||
frontmatter: {},
|
||
content_hash: 'h',
|
||
});
|
||
// Also write to disk so walkMarkdownFiles can find it
|
||
const filePath = join(tempDir, slug + '.md');
|
||
mkdirSync(join(tempDir, type), { recursive: true });
|
||
writeFileSync(filePath, body);
|
||
}
|
||
|
||
describe('runExtractCore — incremental cycle path (#417)', () => {
|
||
test('1. slugs: [] returns immediately with zero counts (early-return path)', async () => {
|
||
await seedPage('people/alice-example', '# alice');
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'all',
|
||
dir: tempDir,
|
||
slugs: [],
|
||
});
|
||
expect(result.links_created).toBe(0);
|
||
expect(result.timeline_entries_created).toBe(0);
|
||
expect(result.pages_processed).toBe(0);
|
||
});
|
||
|
||
test('2. slugs: undefined falls through to full-walk path', async () => {
|
||
await seedPage('people/alice-example', '# alice\n\n[bob](people/bob-example)');
|
||
await seedPage('people/bob-example', '# bob');
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'all',
|
||
dir: tempDir,
|
||
});
|
||
// Full walk processes everything found on disk
|
||
expect(result.pages_processed).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('3. slugs: [a, b] reads only those two files (incremental processing)', async () => {
|
||
await seedPage('people/alice-example', '# alice');
|
||
await seedPage('people/bob-example', '# bob');
|
||
await seedPage('people/charlie-example', '# charlie');
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'all',
|
||
dir: tempDir,
|
||
slugs: ['people/alice-example', 'people/bob-example'],
|
||
});
|
||
// Only 2 files processed even though 3 exist on disk
|
||
expect(result.pages_processed).toBe(2);
|
||
});
|
||
|
||
test('4. Slug whose file no longer exists is silently skipped', async () => {
|
||
await seedPage('people/alice-example', '# alice');
|
||
// people/ghost has no file on disk but is in the slugs list
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'all',
|
||
dir: tempDir,
|
||
slugs: ['people/alice-example', 'people/ghost'],
|
||
});
|
||
// alice processed; ghost skipped (no file)
|
||
expect(result.pages_processed).toBe(1);
|
||
});
|
||
|
||
test('5. mode: links skips timeline extraction in incremental', async () => {
|
||
const body = '# alice\n\n## Timeline\n- 2026-01-01: started';
|
||
await seedPage('people/alice-example', body);
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'links',
|
||
dir: tempDir,
|
||
slugs: ['people/alice-example'],
|
||
});
|
||
// Timeline extraction skipped even though body contains a timeline
|
||
expect(result.timeline_entries_created).toBe(0);
|
||
});
|
||
|
||
test('6. dryRun: true does not invoke addLinksBatch / addTimelineEntriesBatch', async () => {
|
||
await seedPage('people/alice-example', '# alice\n\n[bob](people/bob-example)');
|
||
await seedPage('people/bob-example', '# bob');
|
||
|
||
let linksBatchCalled = false;
|
||
let timelineBatchCalled = false;
|
||
const originalAddLinks = engine.addLinksBatch.bind(engine);
|
||
const originalAddTimeline = engine.addTimelineEntriesBatch.bind(engine);
|
||
(engine as unknown as { addLinksBatch: typeof originalAddLinks }).addLinksBatch = async (...args) => {
|
||
linksBatchCalled = true;
|
||
return originalAddLinks(...args);
|
||
};
|
||
(engine as unknown as { addTimelineEntriesBatch: typeof originalAddTimeline }).addTimelineEntriesBatch = async (...args) => {
|
||
timelineBatchCalled = true;
|
||
return originalAddTimeline(...args);
|
||
};
|
||
|
||
await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'all',
|
||
dir: tempDir,
|
||
slugs: ['people/alice-example'],
|
||
dryRun: true,
|
||
});
|
||
|
||
expect(linksBatchCalled).toBe(false);
|
||
expect(timelineBatchCalled).toBe(false);
|
||
});
|
||
|
||
test('7. BATCH_SIZE flush — slugs producing >100 candidate links exercise the mid-iteration flush', async () => {
|
||
// BATCH_SIZE in extract.ts is 100. Create one slug with 150 outbound links.
|
||
const targets: string[] = [];
|
||
for (let i = 0; i < 150; i++) {
|
||
const target = `companies/co-${i}`;
|
||
targets.push(target);
|
||
await seedPage(target, `# co-${i}`);
|
||
}
|
||
const linkBlock = targets.map(t => `- [${t}](${t})`).join('\n');
|
||
await seedPage('people/alice-example', `# alice\n\n${linkBlock}`);
|
||
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'links',
|
||
dir: tempDir,
|
||
slugs: ['people/alice-example'],
|
||
});
|
||
// The flush happens mid-iteration when batch hits 100; the remaining 50 flush at end.
|
||
// No exception means the flush path executed cleanly.
|
||
expect(result.pages_processed).toBe(1);
|
||
expect(result.links_created).toBeGreaterThanOrEqual(0); // Just confirms the flush path didn't blow up
|
||
});
|
||
|
||
test('8. Full-slug-set resolution — slug references file outside changed set', async () => {
|
||
// alice references bob, but only alice is in the incremental slugs list.
|
||
// The allSlugs set must still include bob (from walkMarkdownFiles) so
|
||
// resolveSlug succeeds; otherwise the link would silently drop.
|
||
// Markdown link pattern requires .md target.
|
||
await seedPage('people/alice-example', '# alice\n\n[bob](bob-example.md)');
|
||
await seedPage('people/bob-example', '# bob');
|
||
|
||
const result = await runExtractCore(engine as unknown as BrainEngine, {
|
||
mode: 'links',
|
||
dir: tempDir,
|
||
slugs: ['people/alice-example'],
|
||
});
|
||
|
||
// Only alice's file was read, but the resulting link must reference bob
|
||
// (resolved via the full allSlugs set built from walkMarkdownFiles).
|
||
expect(result.pages_processed).toBe(1);
|
||
// Link from alice to bob was extracted successfully via the full allSlugs set
|
||
expect(result.links_created).toBeGreaterThan(0);
|
||
});
|
||
});
|