mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(v0.26.5): destructive operation guard — impact preview, confirmation gate, soft-delete
Three-layer protection against accidental data loss:
1. **Impact preview**: Every destructive operation (sources remove, purge)
now shows a formatted preview of exactly what will be destroyed —
page count, chunk count, embedding count, file count — BEFORE acting.
2. **--confirm-destructive flag**: `--yes` alone is no longer sufficient
when a source has data. Must pass `--confirm-destructive` to proceed
with permanent deletion. Prevents scripted/reflexive destroys.
3. **Soft-delete with 72h TTL**: New `gbrain sources archive <id>`
hides a source from search and federation without destroying any data.
Data preserved for 72 hours. Restorable via `gbrain sources restore <id>`.
Expired archives purged via `gbrain sources purge`.
New subcommands:
- `gbrain sources archive <id>` — soft-delete (hide, preserve 72h)
- `gbrain sources restore <id>` — un-archive, re-federate
- `gbrain sources archived` — list soft-deleted sources + TTL
- `gbrain sources purge [<id>] [--confirm-destructive]` — permanent delete
Behavioral changes:
- `sources remove` with data now requires `--confirm-destructive` (not just `--yes`)
- `sources remove --dry-run` shows full impact preview without side effects
- Impact box format shows source name, id, and all cascade counts
New files:
- src/core/destructive-guard.ts — impact assessment, confirmation gate,
soft-delete/restore/purge logic, display formatters
* chore(release): v0.26.5 — destructive operation guard
Bump VERSION + package.json to 0.26.5 and add the v0.26.5 CHANGELOG entry
on top of the destructive-guard feature commit cherry-picked from PR #595.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.26.5): page-level soft-delete + autopilot purge + search visibility
Closes the destructive-guard posture across every gbrain destructive surface.
PR #595 cherry-pick covered the CLI source-remove path; this commit closes
the higher-velocity MCP `delete_page` agent footgun and the three internal
correctness gaps the CEO+Eng review surfaced:
- Gap 1: archived sources were not actually filtered from search. Now they
are, via `buildVisibilityClause` in `searchKeyword`/`searchKeywordChunks`/
`searchVector` for both engines.
- Gap 2: 72h TTL was honor-system. Now wired into a new autopilot `purge`
phase (9th in ALL_PHASES) that calls `purgeExpiredSources` + `engine.
purgeDeletedPages(72)`. Manual escape hatch: `gbrain pages purge-deleted`.
- Gap 3: zero tests for safety-critical code. ~30 cases now in
`test/destructive-guard.test.ts`, `test/pages-soft-delete.test.ts`, and
`test/sql-ranking.test.ts` covering the boundary truth table, JSONB→column
migration, soft-delete/restore/purge round-trip, multi-source isolation,
cascade verification, and the Q3 IRON-rule contract test.
Schema migration v33 (`destructive_guard_columns`): adds `pages.deleted_at`
+ partial purge index, promotes `archived` from `sources.config` JSONB to
real columns (`sources.archived BOOLEAN`, `archived_at`, `archive_expires_at`),
backfills any pre-v0.26.5 JSONB shape. Engine-aware: Postgres uses CREATE
INDEX CONCURRENTLY, PGLite uses plain CREATE INDEX. Forward-reference
bootstrap extended in both engines so pre-v0.26.5 brains don't crash on the
embedded-schema replay.
BrainEngine surface: new `softDeletePage` / `restorePage` /
`purgeDeletedPages` methods + `includeDeleted` flag on `getPage`/`listPages`.
MCP ops: `delete_page` rewired to soft-delete (description string updated);
new `restore_page` (scope: write) + `purge_deleted_pages` (scope: admin,
localOnly: true).
Q3 contract (eng-review lynchpin): `get_page(slug)` returns null for
soft-deleted by default; `get_page(slug, {include_deleted: true})` surfaces
the row with `deleted_at` populated. Same flag for `list_pages`. Mirrors
the search-filter contract end-to-end.
Issue 5 (eng-review): `archived` is now a real column on `sources`, not a
JSONB key. No reserved-key footgun. Faster filter. Visibility clause
compiles to a column lookup, not JSONB containment.
Verification:
- bun run typecheck: PASS
- bun run build:schema + bun run build:llms: regenerated
- targeted test runs: 90 pass / 0 fail across destructive-guard,
pages-soft-delete, sql-ranking, schema-bootstrap-coverage, build-llms
- full bun test: 16 pre-existing failures inherited from v0.26.2 (sync,
sync-parallel, queue-child-done, etc — already filed in TODOS.md as
"Fix 22 pre-existing test failures unrelated to OAuth")
CHANGELOG, CLAUDE.md (Key Files + Commands), TODOS.md updated. The plan
file at ~/.claude/plans/take-a-look-and-gentle-pine.md captures the full
review trail (CEO=C, Eng-Q3=A, Eng-Issue5=a, 8 defaults applied).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v0.26.5): CI fallout — getStats excludes soft-deleted; tests use --confirm-destructive
Two CI failures from the v0.26.5 ship:
1. **Tier 1 (Postgres E2E):** `E2E: Page CRUD > delete_page removes page and
others survive` failed because `delete_page` now soft-deletes (sets
deleted_at) but `getStats.page_count` was still counting all rows. The
test seeds 16 pages, deletes one, and asserts page_count is 15. Fix:
`getStats` now filters `WHERE deleted_at IS NULL` for page_count in both
engines. This matches the visibility-filter contract — soft-deleted pages
are hidden everywhere the user looks (search, get_page, list_pages, stats).
Chunks and links stay raw because they still occupy storage until the
autopilot purge phase runs.
2. **Test 2 (PGLite unit):** `multi-source-integration.test.ts:184` and
`e2e/multi-source.test.ts:274` called `runSources(engine, ['remove', X,
'--yes'])` against populated sources. v0.26.5's destructive guard rejects
`--yes` alone on populated sources and calls `process.exit(5)`, which
killed the bun test runner mid-suite (CI exit 5). Both test sites now
pass `--confirm-destructive` per the v0.26.5 contract.
Verification: 115/0 pass across destructive-guard, pages-soft-delete,
sql-ranking, schema-bootstrap-coverage, sources, repos-alias, and
multi-source-integration test files. typecheck PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): cycle phase count is 9 (v0.26.5 added `purge` phase)
CI failure: `runCycle — yieldBetweenPhases hook` tests asserted exactly 8
phases. v0.26.5 added the autopilot `purge` phase as the 9th, so:
- `test/core/cycle.test.ts:381` — `hookCalls` is now 9 (one yield per phase)
- `test/core/cycle.test.ts:392` — `report.phases.length` is now 9
- `test/e2e/cycle.test.ts:101` — same update for the dry-run E2E
The `purge` phase invocation was already visible in the failing log output:
the cycle ran 9 phases end-to-end; the test assertions hadn't been updated.
Verification: bun run typecheck PASS. cycle.test.ts: 28/0 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
7.8 KiB
TypeScript
223 lines
7.8 KiB
TypeScript
/**
|
|
* E2E cycle tests — Tier 1 (no API keys required).
|
|
*
|
|
* Exercises runCycle against REAL Postgres (via the E2E helpers' setupDB /
|
|
* teardownDB lifecycle) with a real git repo and a mocked embedBatch.
|
|
* Covers what the unit tests can't: the gbrain_cycle_locks table's
|
|
* INSERT...ON CONFLICT...WHERE semantics under a real postgres-js client,
|
|
* the v0.17 schema migration applying cleanly to a fresh Postgres, and the
|
|
* dry-run regression guard asserting zero writes when flag is set.
|
|
*
|
|
* Run: DATABASE_URL=... bun test test/e2e/cycle.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, mock, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';
|
|
|
|
// Mock embedBatch BEFORE importing runCycle so no real OpenAI calls happen
|
|
// even when the full cycle's embed phase runs.
|
|
mock.module('../../src/core/embedding.ts', () => ({
|
|
embedBatch: async (texts: string[]) => {
|
|
// Deterministic fake vector for each chunk.
|
|
return texts.map(() => new Float32Array(1536));
|
|
},
|
|
}));
|
|
|
|
const { runCycle } = await import('../../src/core/cycle.ts');
|
|
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
if (skip) {
|
|
console.log('Skipping E2E cycle tests (DATABASE_URL not set)');
|
|
}
|
|
|
|
function makeGitRepo(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-cycle-'));
|
|
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.email test@test.co', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.name test', { cwd: dir, stdio: 'pipe' });
|
|
|
|
mkdirSync(join(dir, 'people'), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, 'people/alice.md'),
|
|
'---\ntype: person\ntitle: Alice\n---\n\nAlice collaborates with Bob.\n',
|
|
);
|
|
writeFileSync(
|
|
join(dir, 'people/bob.md'),
|
|
'---\ntype: person\ntitle: Bob\n---\n\nBob is a person.\n',
|
|
);
|
|
execSync('git add -A && git commit -m init', { cwd: dir, stdio: 'pipe' });
|
|
return dir;
|
|
}
|
|
|
|
describeE2E('E2E: runCycle against real Postgres', () => {
|
|
let repo: string;
|
|
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
repo = makeGitRepo();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await teardownDB();
|
|
if (repo) rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('v0.17 migration v16 created gbrain_cycle_locks table', async () => {
|
|
const conn = getConn();
|
|
const rows = await conn.unsafe(
|
|
`SELECT tablename FROM pg_tables WHERE tablename = 'gbrain_cycle_locks'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
|
|
// idx_cycle_locks_ttl index also exists.
|
|
const idx = await conn.unsafe(
|
|
`SELECT indexname FROM pg_indexes WHERE indexname = 'idx_cycle_locks_ttl'`,
|
|
);
|
|
expect(idx.length).toBe(1);
|
|
});
|
|
|
|
test('dry-run full cycle: zero DB writes + zero filesystem changes', async () => {
|
|
const conn = getConn();
|
|
// Baseline: track initial state.
|
|
const beforePages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
|
|
const beforeSync = await conn.unsafe(
|
|
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
|
|
);
|
|
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: true,
|
|
pull: false,
|
|
});
|
|
|
|
expect(report.schema_version).toBe('1');
|
|
// Cycle ran all 9 phases (or skipped the ones that don't support dry-run).
|
|
// v0.26.5 added the `purge` phase (9th, after `orphans`).
|
|
expect(report.phases.length).toBe(9);
|
|
|
|
// Nothing got written.
|
|
const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
|
|
expect(afterPages[0].n).toBe(beforePages[0].n);
|
|
|
|
// sync.last_commit unchanged (wasn't set before, isn't set now).
|
|
const afterSync = await conn.unsafe(
|
|
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
|
|
);
|
|
expect(afterSync.length).toBe(beforeSync.length);
|
|
|
|
// Cycle lock was acquired + released; table should be empty after.
|
|
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
|
|
expect(locks[0].n).toBe(0);
|
|
});
|
|
|
|
test('live cycle: pages get synced + chunks created + cycle lock cleaned up', async () => {
|
|
const conn = getConn();
|
|
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: false,
|
|
pull: false,
|
|
});
|
|
|
|
expect(report.schema_version).toBe('1');
|
|
// The sync phase should have run and imported real pages.
|
|
const syncPhase = report.phases.find(p => p.phase === 'sync');
|
|
expect(syncPhase).toBeDefined();
|
|
expect(syncPhase?.status).not.toBe('fail');
|
|
|
|
// Pages exist in the DB.
|
|
const pages = await conn.unsafe(`SELECT slug FROM pages ORDER BY slug`);
|
|
const slugs = (pages as unknown as Array<{ slug: string }>).map(p => p.slug);
|
|
expect(slugs).toContain('people/alice');
|
|
expect(slugs).toContain('people/bob');
|
|
|
|
// sync.last_commit bookmark is now set.
|
|
const sync = await conn.unsafe(
|
|
`SELECT value FROM config WHERE key = 'sync.last_commit'`,
|
|
);
|
|
expect(sync.length).toBe(1);
|
|
expect((sync[0] as any).value.length).toBeGreaterThanOrEqual(7);
|
|
|
|
// Cycle lock is released.
|
|
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
|
|
expect(locks[0].n).toBe(0);
|
|
}, 60_000);
|
|
|
|
test('concurrent cycle is blocked by the lock (status:skipped)', async () => {
|
|
const conn = getConn();
|
|
|
|
// Seed a fresh-TTL lock held by a different (fake) PID.
|
|
await conn.unsafe(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
|
|
);
|
|
|
|
try {
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: true,
|
|
pull: false,
|
|
});
|
|
expect(report.status).toBe('skipped');
|
|
expect(report.reason).toBe('cycle_already_running');
|
|
expect(report.phases.length).toBe(0);
|
|
} finally {
|
|
// Clean up the seeded lock.
|
|
await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
|
|
}
|
|
});
|
|
|
|
test('TTL-expired lock is auto-claimed (crashed holder recovery)', async () => {
|
|
const conn = getConn();
|
|
|
|
// Seed a stale lock (TTL in the past).
|
|
await conn.unsafe(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`,
|
|
);
|
|
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
dryRun: true,
|
|
pull: false,
|
|
});
|
|
// Crashed holder's stale TTL lets the new run acquire the lock.
|
|
expect(report.status).not.toBe('skipped');
|
|
|
|
// Lock released after the run.
|
|
const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
|
|
expect(locks[0].n).toBe(0);
|
|
});
|
|
|
|
test('--phase orphans skips the lock entirely (read-only optimization)', async () => {
|
|
const conn = getConn();
|
|
|
|
// Seed a fresh-TTL lock held by someone else. A read-only phase
|
|
// selection should succeed anyway (orphans never acquires the lock).
|
|
await conn.unsafe(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
|
|
);
|
|
|
|
try {
|
|
const report = await runCycle(getEngine(), {
|
|
brainDir: repo,
|
|
phases: ['orphans'],
|
|
pull: false,
|
|
});
|
|
// Status is NOT skipped — orphans ran despite the held lock.
|
|
expect(report.status).not.toBe('skipped');
|
|
const orphansPhase = report.phases.find(p => p.phase === 'orphans');
|
|
expect(orphansPhase).toBeDefined();
|
|
} finally {
|
|
await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
|
|
}
|
|
});
|
|
});
|