mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* docs: add SKILLPACK Section 18 — Live Sync (MUST ADD) Contract-first guide for keeping the vector DB in sync with the brain repo. Documents the pooler prerequisite (Session mode required for transactions), sync + embed primitives, four example approaches (cron, --watch, webhook, git hook), isSyncable exclusions, silent skip warning, and OpenClaw/Hermes cron registration examples. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add GBRAIN_VERIFY.md installation verification runbook Six-check runbook: schema (doctor), skillpack loaded, auto-update, live sync (coverage check + embed check + end-to-end push-and-search test), embedding coverage, brain-first lookup protocol. Emphasizes "sync ran" != "sync worked" — the real test is searching for corrected text after a push. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add setup Phases H (Live Sync) and I (Verification) Phase H: MUST ADD live sync setup — pooler prerequisite check, automatic sync configuration (agent picks approach), sync+embed chaining, coverage verification. Phase I: run GBRAIN_VERIFY.md end-to-end before declaring setup complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add install steps 8-9 (live sync + verification) Step 8: set up automatic sync with SKILLPACK Section 18 reference. Step 9: run GBRAIN_VERIFY.md runbook. Add GBRAIN_VERIFY.md to docs section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add API key loading instructions to CLAUDE.md Source ~/.zshrc before running Tier 2 tests so OPENAI_API_KEY and ANTHROPIC_API_KEY are available. Without this, embedding and skills tests skip silently. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version to v0.5.0 Live sync, verification runbook, API key loading instructions. Version markers updated in SKILLPACK and RECOMMENDED_SCHEMA. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add anti-hand-roll rule to skill routing in CLAUDE.md Explicitly prohibit manually running git commit + push + gh pr create when /ship is available. /ship handles VERSION, CHANGELOG, document-release, reviews, and coverage audit. Hand-rolling skips all of these. Added "commit and ship" / "push and ship" variants to the ship routing rule. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: changelog voice rule + rewrite 0.5.0 changelog to sell the upgrade CLAUDE.md: add changelog voice guidance — lead with benefits, not implementation details. Make users want to upgrade. CHANGELOG: rewrite 0.5.0 entries from dry feature descriptions to capability-focused bullets ("your brain never falls behind" not "SKILLPACK Section 18 added"). SKILLPACK Section 17: update the auto-update message template to instruct agents to sell the upgrade, not just summarize the diff. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add v0.5.0 migration directive for live sync + verification Agents upgrading from v0.4.x will automatically: check their pooler connection string, set up automatic sync, and run the verification runbook. Without this migration file, upgrading agents would learn about live sync (by re-reading Section 18) but wouldn't set it up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: sharpen migration file guidance in CLAUDE.md Replace vague "requires agent action" with concrete trigger list: new setup steps existing users don't have, MUST ADD skillpack sections, schema changes, deprecated commands, new verification steps, new crons. Add the key test: "if an existing user upgrades and does nothing else, will their brain work worse?" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: make Section 17 upgrade flow work for direct user requests Section 17 was structured as a cron-initiated flow only. An agent handling "upgrade gbrain" might just run the command and stop, missing the post-upgrade steps where the value is (re-read skills, run migrations, schema sync). Added explicit entry point for direct upgrade requests. Made Steps 2-4 more concrete about where to find files and why migrations can't be skipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add E2E sync tests — git-to-DB pipeline (11 tests) Tests the full sync lifecycle against real Postgres+pgvector: - First sync imports all pages from a git repo - Second sync with no changes returns up_to_date - Incremental sync picks up new files (add → commit → sync → verify) - Incremental sync picks up modifications — THE CRITICAL TEST: corrected text appears in DB and keyword search after sync - Incremental sync handles deletes - Non-syncable files are excluded (README, .raw/, ops/) - Sync state (last_commit, last_run) persisted to config - Sync logged to ingest_log - --full reimports everything - --dry-run shows changes without applying Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: strengthen CLAUDE.md to always run ALL test tiers Replace passive "source zshrc" suggestion with ALWAYS directive. Explicitly state that "run all tests" means ALL tiers including Tier 2 with API keys. Do not skip Tier 2 just because keys need loading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Tier 2 E2E tests — correct openclaw CLI invocation The tests used `openclaw -p` which doesn't exist. The correct command is `openclaw agent --local --agent <id> --message <prompt>`. Also fixed JSON output parsing (structured JSON goes to stderr, not stdout — use non-JSON mode instead). Fixed ingest test to assert on agent response text rather than test DB state (the agent writes to its own configured DB, not the ephemeral test DB). 82 tests pass, 0 fail, 0 skip across all 5 E2E files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
/**
|
|
* E2E Sync Tests — Tier 1 (no API keys required)
|
|
*
|
|
* Tests the full git-to-DB sync pipeline: create a git repo, commit
|
|
* markdown files, run gbrain sync, verify pages appear in the database.
|
|
* Covers first sync, incremental add/modify/delete, and the critical
|
|
* "edit → sync → search returns corrected text" flow.
|
|
*
|
|
* Run: DATABASE_URL=... bun test test/e2e/sync.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, unlinkSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
hasDatabase, setupDB, teardownDB, getEngine,
|
|
} from './helpers.ts';
|
|
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
if (skip) {
|
|
console.log('Skipping E2E sync tests (DATABASE_URL not set)');
|
|
}
|
|
|
|
/** Create a temp git repo with initial markdown files */
|
|
function createTestRepo(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-sync-e2e-'));
|
|
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
|
|
// Create initial structure
|
|
mkdirSync(join(dir, 'people'), { recursive: true });
|
|
mkdirSync(join(dir, 'concepts'), { recursive: true });
|
|
|
|
writeFileSync(join(dir, 'people/alice.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Alice Smith',
|
|
'tags: [engineer, frontend]',
|
|
'---',
|
|
'',
|
|
'Alice is a frontend engineer at Acme Corp.',
|
|
'',
|
|
'---',
|
|
'',
|
|
'- 2026-01-15: Joined Acme Corp',
|
|
].join('\n'));
|
|
|
|
writeFileSync(join(dir, 'concepts/testing.md'), [
|
|
'---',
|
|
'type: concept',
|
|
'title: Testing Philosophy',
|
|
'tags: [engineering]',
|
|
'---',
|
|
'',
|
|
'Every untested path is a path where bugs hide.',
|
|
].join('\n'));
|
|
|
|
// Initial commit
|
|
execSync('git add -A && git commit -m "initial commit"', { cwd: dir, stdio: 'pipe' });
|
|
|
|
return dir;
|
|
}
|
|
|
|
function gitCommit(repoPath: string, message: string) {
|
|
execSync(`git add -A && git commit -m "${message}"`, { cwd: repoPath, stdio: 'pipe' });
|
|
}
|
|
|
|
describeE2E('E2E: Git-to-DB Sync Pipeline', () => {
|
|
let repoPath: string;
|
|
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
repoPath = createTestRepo();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await teardownDB();
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('first sync imports all pages from git repo', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('first_sync');
|
|
// performFullSync delegates to runImport which doesn't populate pagesAffected
|
|
// Verify pages exist in DB directly instead
|
|
const alice = await engine.getPage('people/alice');
|
|
expect(alice).not.toBeNull();
|
|
expect(alice!.title).toBe('Alice Smith');
|
|
|
|
const testing = await engine.getPage('concepts/testing');
|
|
expect(testing).not.toBeNull();
|
|
expect(testing!.title).toBe('Testing Philosophy');
|
|
});
|
|
|
|
test('second sync with no changes returns up_to_date', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('up_to_date');
|
|
expect(result.added).toBe(0);
|
|
expect(result.modified).toBe(0);
|
|
expect(result.deleted).toBe(0);
|
|
});
|
|
|
|
test('incremental sync picks up new files', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
// Add a new file
|
|
writeFileSync(join(repoPath, 'people/bob.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Bob Jones',
|
|
'tags: [designer]',
|
|
'---',
|
|
'',
|
|
'Bob is a product designer who loves typography.',
|
|
].join('\n'));
|
|
gitCommit(repoPath, 'add bob');
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.added).toBe(1);
|
|
expect(result.pagesAffected).toContain('people/bob');
|
|
|
|
const bob = await engine.getPage('people/bob');
|
|
expect(bob).not.toBeNull();
|
|
expect(bob!.title).toBe('Bob Jones');
|
|
expect(bob!.compiled_truth).toContain('typography');
|
|
});
|
|
|
|
test('incremental sync picks up modifications — corrected text appears', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
// Modify alice's page — this is the critical "correction" test
|
|
writeFileSync(join(repoPath, 'people/alice.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Alice Smith',
|
|
'tags: [engineer, frontend]',
|
|
'---',
|
|
'',
|
|
'Alice is a staff frontend engineer at Acme Corp, leading the design system team.',
|
|
'',
|
|
'---',
|
|
'',
|
|
'- 2026-04-01: Promoted to staff engineer',
|
|
'- 2026-01-15: Joined Acme Corp',
|
|
].join('\n'));
|
|
gitCommit(repoPath, 'update alice - promotion');
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.modified).toBe(1);
|
|
expect(result.pagesAffected).toContain('people/alice');
|
|
|
|
// THE CRITICAL CHECK: corrected text appears in the DB
|
|
const alice = await engine.getPage('people/alice');
|
|
expect(alice!.compiled_truth).toContain('staff frontend engineer');
|
|
expect(alice!.compiled_truth).toContain('design system team');
|
|
// Old text should be replaced, not appended
|
|
expect(alice!.compiled_truth).not.toBe('Alice is a frontend engineer at Acme Corp.');
|
|
});
|
|
|
|
test('keyword search finds corrected text after sync', async () => {
|
|
const engine = getEngine();
|
|
|
|
// Search for the new text
|
|
const results = await engine.searchKeyword('design system team');
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const aliceResult = results.find((r: any) => r.slug === 'people/alice');
|
|
expect(aliceResult).toBeDefined();
|
|
});
|
|
|
|
test('incremental sync handles deletes', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
// Delete bob's page
|
|
unlinkSync(join(repoPath, 'people/bob.md'));
|
|
gitCommit(repoPath, 'remove bob');
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
expect(result.status).toBe('synced');
|
|
expect(result.deleted).toBe(1);
|
|
|
|
const bob = await engine.getPage('people/bob');
|
|
expect(bob).toBeNull();
|
|
});
|
|
|
|
test('sync skips non-syncable files (README, hidden, .raw)', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
// Add files that should be excluded
|
|
writeFileSync(join(repoPath, 'README.md'), '# Brain Repo\nThis is the readme.');
|
|
mkdirSync(join(repoPath, '.raw'), { recursive: true });
|
|
writeFileSync(join(repoPath, '.raw/data.md'), '---\ntitle: Raw\n---\nRaw data.');
|
|
mkdirSync(join(repoPath, 'ops'), { recursive: true });
|
|
writeFileSync(join(repoPath, 'ops/deploy.md'), '---\ntitle: Deploy\n---\nOps stuff.');
|
|
gitCommit(repoPath, 'add non-syncable files');
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
|
|
// These should not create pages
|
|
const readme = await engine.getPage('README');
|
|
expect(readme).toBeNull();
|
|
|
|
const raw = await engine.getPage('.raw/data');
|
|
expect(raw).toBeNull();
|
|
|
|
const ops = await engine.getPage('ops/deploy');
|
|
expect(ops).toBeNull();
|
|
});
|
|
|
|
test('sync stores last_commit and last_run in config', async () => {
|
|
const engine = getEngine();
|
|
|
|
const lastCommit = await engine.getConfig('sync.last_commit');
|
|
const lastRun = await engine.getConfig('sync.last_run');
|
|
const repoPathConfig = await engine.getConfig('sync.repo_path');
|
|
|
|
expect(lastCommit).toBeTruthy();
|
|
expect(lastCommit!.length).toBe(40); // full SHA
|
|
expect(lastRun).toBeTruthy();
|
|
expect(repoPathConfig).toBe(repoPath);
|
|
});
|
|
|
|
test('sync logs to ingest_log', async () => {
|
|
const engine = getEngine();
|
|
|
|
const logs = await engine.getIngestLog();
|
|
const syncLogs = logs.filter((l: any) => l.source_type === 'git_sync');
|
|
|
|
expect(syncLogs.length).toBeGreaterThanOrEqual(1);
|
|
expect(syncLogs[0].source_ref).toContain(repoPath);
|
|
});
|
|
|
|
test('--full reimports everything regardless of last_commit', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
full: true,
|
|
});
|
|
|
|
expect(result.status).toBe('first_sync');
|
|
// performFullSync delegates to runImport — verify pages exist instead
|
|
const alice = await engine.getPage('people/alice');
|
|
expect(alice).not.toBeNull();
|
|
const testing = await engine.getPage('concepts/testing');
|
|
expect(testing).not.toBeNull();
|
|
});
|
|
|
|
test('dry-run shows changes without applying them', async () => {
|
|
const { performSync } = await import('../../src/commands/sync.ts');
|
|
const engine = getEngine();
|
|
|
|
// Add a new file
|
|
writeFileSync(join(repoPath, 'concepts/dry-run-test.md'), [
|
|
'---',
|
|
'type: concept',
|
|
'title: Dry Run Test',
|
|
'---',
|
|
'',
|
|
'This should not be imported.',
|
|
].join('\n'));
|
|
gitCommit(repoPath, 'add dry run test');
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result.status).toBe('dry_run');
|
|
expect(result.added).toBe(1);
|
|
|
|
// Page should NOT exist in DB
|
|
const page = await engine.getPage('concepts/dry-run-test');
|
|
expect(page).toBeNull();
|
|
|
|
// Clean up: do a real sync so the commit is consumed
|
|
await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
});
|
|
});
|
|
});
|