diff --git a/.changeset/t13891-one-project-snapshot.md b/.changeset/t13891-one-project-snapshot.md new file mode 100644 index 0000000000..cc728c8675 --- /dev/null +++ b/.changeset/t13891-one-project-snapshot.md @@ -0,0 +1,6 @@ +--- +id: t13891-one-project-snapshot +tasks: [T13891, T13897] +kind: fix +summary: "session-end and pre-op snapshots write one tasks-*.db per run for the unified project cleo.db; brain and conduit are covered by it instead of getting hard links that diverged under per-prefix retention (or full copies where links are unsupported); listBrainBackups and backup verify read the project snapshot, and older brain-*/conduit-* files stay listed; the project cleo.db snapshot is always named tasks-*, whichever prefix the gate admitted" +--- diff --git a/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts b/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts index f45e1883e8..e2104043e3 100644 --- a/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts @@ -214,7 +214,7 @@ describe('runBackupVerify (T10319 core SDK)', () => { expect(result.summary.corrupt).toBeGreaterThanOrEqual(1); }); - it('detects 3-DB mixed fixture: fresh+healthy / stale+healthy / corrupt', () => { + it('detects 3-DB mixed fixture: fresh+healthy / stale brain covered by the project snapshot / corrupt', () => { // 1. Fresh + healthy: tasks const now = new Date(); const stampNow = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; @@ -241,14 +241,15 @@ describe('runBackupVerify (T10319 core SDK)', () => { }); expect(result.dbs['tasks']?.verdict).toBe('healthy'); - expect(result.dbs['brain']?.verdict).toBe('stale'); + // T13891: the fresh tasks-* snapshot is the whole project store, so it is + // brain's freshest snapshot too; the 48h brain-* copy no longer decides. + expect(result.dbs['brain']?.verdict).toBe('healthy'); expect(result.dbs['conduit']?.verdict).toBe('corrupt'); // Summary must add up across all roles. const { healthy, stale, corrupt, missing } = result.summary; expect(healthy + stale + corrupt + missing).toBe(DB_INVENTORY.length); - expect(healthy).toBeGreaterThanOrEqual(1); - expect(stale).toBeGreaterThanOrEqual(1); + expect(healthy).toBeGreaterThanOrEqual(2); expect(corrupt).toBeGreaterThanOrEqual(1); }); diff --git a/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts b/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts index 017e5eb988..dd1a579b90 100644 --- a/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts +++ b/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts @@ -144,6 +144,30 @@ describe('backups restore onto the live cleo.db (T13245)', () => { expect(readFileSync(decoy, 'utf8')).toBe('decoy'); }); + it.each([ + 'brain', + 'conduit', + ])('backup recover %s picks a newer tasks-* project snapshot over an older %s-* file (T13897)', async (role) => { + // An older role-prefixed file (pre-T13891), then the store moves on and a + // run writes the one unified project snapshot as tasks-*. + const older = sessionSnapshot(role, '20261005-090000'); + await seedTasks(env.accessor, [ + { id: 'T003', title: 'after the older snapshot', type: 'task' }, + ]); + const newer = sessionSnapshot('tasks', '20261005-100000'); + const backedUp = fingerprint(newer); + expect(backedUp).not.toEqual(fingerprint(older)); + await seedTasks(env.accessor, [ + { id: 'T004', title: 'after the newer snapshot', type: 'task' }, + ]); + resetDbState(); + + const result = await recoverProjectStore({ role, projectRoot: env.tempDir, cwd: env.tempDir }); + expect(result.restored).toBe(true); + expect(result.source.path).toBe(newer); + expect(fingerprint(live)).toEqual(backedUp); + }); + it('a corrupt newest snapshot is skipped for the next valid one', async () => { const older = sessionSnapshot('tasks', '20261005-090000'); const backedUp = fingerprint(older); diff --git a/packages/core/src/store/__tests__/recover-brain-db.test.ts b/packages/core/src/store/__tests__/recover-brain-db.test.ts index 053d77ac2c..a897c6dfdd 100644 --- a/packages/core/src/store/__tests__/recover-brain-db.test.ts +++ b/packages/core/src/store/__tests__/recover-brain-db.test.ts @@ -204,6 +204,32 @@ describe('recoverMalformedBrainDb (T10303)', () => { expect(result.observationsRecovered).toBe(9); }); + it('picks a newer tasks-* project snapshot over an older brain-* one (T13897)', () => { + const root = mkdtempSync(join(tmpdir(), 'cleo-test-recover-tasks-prefix-')); + const cleoDir = join(root, '.cleo'); + const vacuumDir = join(cleoDir, 'backups', 'sqlite'); + const snapshotDir = join(cleoDir, 'backups', 'snapshot'); + const corruptPath = join(cleoDir, 'brain.db'); + mkdirSync(vacuumDir, { recursive: true }); + mkdirSync(snapshotDir, { recursive: true }); + + seedHealthyBrainDb(join(vacuumDir, 'brain-20260523-120000.db'), 2); + const newer = join(vacuumDir, 'tasks-20260523-131622.db'); + seedHealthyBrainDb(newer, 5); + writeCorruptFile(corruptPath); + + const result = recoverMalformedBrainDb({ + corruptPath, + snapshotDir, + vacuumSnapshotDir: vacuumDir, + logger: makeLogger(), + }); + + expect(result.integrityOK).toBe(true); + expect(result.restoredFrom).toBe(newer); + expect(result.observationsRecovered).toBe(5); + }); + it('uses legacy PRE-DUP-FIX artifacts as a last-resort fallback', () => { const root = mkdtempSync(join(tmpdir(), 'cleo-test-recover-predup-')); const cleoDir = join(root, '.cleo'); diff --git a/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts b/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts index 2fba57c3b7..f4cbe49b89 100644 --- a/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts @@ -13,10 +13,11 @@ * 3. Invokes `vacuumIntoBackupAll({ cwd: tempDir })`. * 4. Exits cleanly. * - * The parent process then asserts that a `brain-YYYYMMDD-HHmmss.db` file - * landed in `/.cleo/backups/sqlite/`. Before the T10316 fix the - * fast-path returned `null` and snapshotOne silently skipped brain — so - * the only artifacts were `tasks-*.db` snapshots. + * The parent process then asserts the snapshot landed in + * `/.cleo/backups/sqlite/`. Before the T10316 fix the fast-path + * returned `null` and snapshotOne silently skipped brain. Since T13891 brain + * lives in the unified project cleo.db, so the run writes ONE `tasks-*.db` + * snapshot that holds it, and no separate `brain-*.db` copy. * * This test deliberately bypasses vitest mocks because the bug surfaces only * in a real process where module-singleton caches are empty. The mock-based @@ -35,6 +36,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { collectSnapshotCandidatesForRole } from '../recover-malformed-db.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -64,7 +66,7 @@ describe('sqlite-backup real-process (T10316)', () => { } }); - it('produces brain-YYYYMMDD-HHmmss.db even when no caller pre-opened brain.db', () => { + it('covers brain in the one project snapshot even when no caller pre-opened brain.db (T10316, T13891)', () => { // The compiled dist MUST exist — the test depends on the real module // graph (no vitest module mocks). Builds run before tests via the // monorepo workflow; a missing dist is a clear signal to rebuild. @@ -121,12 +123,17 @@ describe('sqlite-backup real-process (T10316)', () => { const brainSnapshots = files.filter((f) => /^brain-\d{8}-\d{6}\.db$/.test(f)); const tasksSnapshots = files.filter((f) => /^tasks-\d{8}-\d{6}\.db$/.test(f)); - // Headline regression check: at LEAST one brain-*.db snapshot file. - // Pre-fix, this count was 0 in production despite tasks-*.db being healthy. - expect(brainSnapshots.length).toBeGreaterThanOrEqual(1); + // T13891: brain lives in the unified project cleo.db, so the run writes + // ONE project snapshot (tasks-*) that holds the brain tables, and no + // separate brain-*.db or conduit-*.db copy. + expect(tasksSnapshots).toHaveLength(1); + expect(brainSnapshots).toHaveLength(0); + expect(files.filter((f) => /^conduit-\d{8}-\d{6}\.db$/.test(f))).toHaveLength(0); - // Sanity: tasks snapshot should also exist — confirms the eager-open - // contract holds for the previously-working path too. - expect(tasksSnapshots.length).toBeGreaterThanOrEqual(1); + // Recovery finds that snapshot for brain and conduit (T13897). + for (const role of ['brain', 'conduit'] as const) { + const candidates = collectSnapshotCandidatesForRole({ role, vacuumSnapshotDir: backupDir }); + expect(candidates.map((c) => c.path)).toEqual([join(backupDir, tasksSnapshots[0] ?? '')]); + } }); }); diff --git a/packages/core/src/store/__tests__/sqlite-backup.test.ts b/packages/core/src/store/__tests__/sqlite-backup.test.ts index a27fcbb245..be4c5653d0 100644 --- a/packages/core/src/store/__tests__/sqlite-backup.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup.test.ts @@ -19,7 +19,7 @@ * @epic T4867 */ -import { chmodSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -415,7 +415,13 @@ describe('sqlite-backup', () => { 'tasks-20260102-120000.db', 'tasks-20260101-120000.db', ]); - expect(brainList.map((e) => e.name)).toEqual(['brain-20260103-120000.db']); + // T13891: brain lives in the unified store, so its listing holds the + // project snapshots (tasks-*) plus older brain-* files, newest first. + expect(brainList.map((e) => e.name)).toEqual([ + 'brain-20260103-120000.db', + 'tasks-20260102-120000.db', + 'tasks-20260101-120000.db', + ]); // T10317: every project-tier + derived inventory row now contributes a // bucket to listSqliteBackupsAll(). Empty buckets surface as `[]`. expect(Object.keys(all).sort()).toEqual( @@ -599,11 +605,12 @@ describe('sqlite-backup', () => { }); /** - * T12508 #2: tasks, brain and conduit share ONE cleo.db handle. A run must - * VACUUM it once and hard-link the same file under the other prefixes, so - * every `-*.db` reader still finds a file at no extra disk cost. + * T12508 #2 / T13891: tasks, brain and conduit share ONE cleo.db handle. A + * run VACUUMs it once and writes ONE project snapshot; brain and conduit are + * covered by that file (no hard link, no copy), so a session end costs one + * copy of the store, not three under per-prefix retention. */ - it('one VACUUM per physical database file; other prefixes are hard links (T12508)', async () => { + it('one project snapshot per run for the unified store; brain and conduit are covered by it (T12508, T13891)', async () => { const tempDir = join(tmpdir(), `cleo-t12508-dedupe-${Date.now()}`); const backupDir = join(tempDir, 'backups', 'sqlite'); mkdirSync(tempDir, { recursive: true }); @@ -639,17 +646,68 @@ describe('sqlite-backup', () => { expect(r?.snapshotted).toEqual(['tasks', 'brain', 'conduit']); expect(r?.linked).toEqual(['brain', 'conduit']); const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-/.test(f)); - expect(files).toHaveLength(3); - const inodes = new Set(files.map((f) => statSync(join(backupDir, f)).ino)); - expect(inodes.size).toBe(1); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^tasks-\d{8}-\d{6}\.db$/); + }); + + it('a debounced tasks prefix still files the unified store as tasks-*, never brain-* (T13891)', async () => { + const tempDir = join(tmpdir(), `cleo-t13910-mixed-${Date.now()}`); + const backupDir = join(tempDir, 'backups', 'sqlite'); + mkdirSync(tempDir, { recursive: true }); + const shared = new DatabaseSync(join(tempDir, 'cleo.db')); + shared.exec('CREATE TABLE tasks_schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); + vi.doMock('../sqlite.js', () => ({ getNativeDb: () => shared, getDb: async () => null })); + vi.doMock('../memory-sqlite.js', () => ({ + getBrainNativeDb: () => shared, + getBrainDb: async () => null, + })); + vi.doMock('../conduit-sqlite.js', () => ({ + getConduitNativeDb: () => shared, + ensureConduitDb: () => ({ action: 'exists', path: '' }), + })); + stubOtherChokepointOpeners(); + vi.doMock('../../paths.js', () => ({ + getCleoDir: () => tempDir, + getCleoHome: () => tempDir, + resolveOrCwd: (cwd?: string) => cwd ?? tempDir, + })); + + const { vacuumIntoBackup, vacuumIntoBackupAll } = await import('../sqlite-backup.js'); + // A tasks-only snapshot refreshes the tasks debounce; the next all-snapshot + // run admits only brain and conduit. + await vacuumIntoBackup(); + const [first] = readdirSync(backupDir).filter((f) => /^tasks-.*\.db$/.test(f)); + expect(first).toBeDefined(); + // Data written after the first snapshot must be in the brain-admitted one. + shared.exec("INSERT INTO tasks_schema_meta (key, value) VALUES ('t13891-marker', 'latest')"); + const r = await vacuumIntoBackupAll(); + shared.close(); + + expect(r?.snapshotted).not.toContain('tasks'); + expect(r?.snapshotted).toContain('brain'); + // Same-bucket retention may drop the first snapshot; never a brain/conduit file. + const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-.*\.db$/.test(f)); + for (const f of files) expect(f).toMatch(/^tasks-\d{8}-\d{6}\.db$/); + const latest = files + .filter((f) => f !== first) + .sort() + .at(-1); + expect(latest).toBeDefined(); + const snap = new DatabaseSync(join(backupDir, latest ?? ''), { readOnly: true }); + const row = snap + .prepare("SELECT value FROM tasks_schema_meta WHERE key = 't13891-marker'") + .get() as { value: string } | undefined; + snap.close(); + expect(row?.value).toBe('latest'); }); /** - * T12508: on a filesystem without hard links (exFAT → ENOTSUP) the shared - * file is COPIED under the other prefixes instead — still one VACUUM, and - * `brain-*` / `conduit-*` still exist for restore and recovery. + * T13891: on a filesystem without hard links (exFAT → ENOTSUP) the old + * design COPIED the shared file under brain and conduit (three full copies + * per session end). Now nothing is linked or copied: one file, and + * `listBrainBackups` finds the brain tables in that project snapshot. */ - it('falls back to a copy when hard links are unsupported (T12508)', async () => { + it('writes one project snapshot, never a copy, where hard links are unsupported (T13891)', async () => { const tempDir = join(tmpdir(), `cleo-t12508-nolink-${Date.now()}`); const backupDir = join(tempDir, 'backups', 'sqlite'); mkdirSync(tempDir, { recursive: true }); @@ -696,13 +754,12 @@ describe('sqlite-backup', () => { expect(vacuums).toBe(1); expect(r?.failed).toEqual([]); - expect(r?.linked).toEqual([]); + expect(r?.linked).toEqual(['brain', 'conduit']); expect(r?.snapshotted).toEqual(['tasks', 'brain', 'conduit']); const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-.*\.db$/.test(f)); - expect(files).toHaveLength(3); - // Three separate files (copies), and brain recovery finds its prefix. - expect(new Set(files.map((f) => statSync(join(backupDir, f)).ino)).size).toBe(3); - expect(brainList).toHaveLength(1); + expect(files).toHaveLength(1); + // The one project snapshot is what the brain listing returns. + expect(brainList.map((e) => e.name)).toEqual(files); }); /** diff --git a/packages/core/src/store/backup-recover.ts b/packages/core/src/store/backup-recover.ts index a8d97b922a..edf9da83a3 100644 --- a/packages/core/src/store/backup-recover.ts +++ b/packages/core/src/store/backup-recover.ts @@ -38,6 +38,7 @@ import { recoverMalformedDb, resolveRoleBackupDirs, resolveRoleDbPath, + vacuumSnapshotPrefixes, } from './recover-malformed-db.js'; import { ensureTriggerSuspendTableAtPath } from './sync/trigger-suspend-at-path.js'; import { @@ -215,11 +216,13 @@ function snapshotMatchesPin(role: DbRole, candidate: SnapshotCandidate, pin: str ); if (iso.startsWith(pin)) return true; } - // Vacuum snapshot: `-20260523-130026.db`. - const vacuumPrefix = `${role}-`; - if (name.startsWith(vacuumPrefix) && name.endsWith('.db')) { - const tail = name.slice(vacuumPrefix.length, -3); // strip ".db" - if (tail.startsWith(pin)) return true; + // Vacuum snapshot: `-20260523-130026.db` (`tasks-*` also holds brain and conduit, T13897). + for (const prefix of vacuumSnapshotPrefixes(role)) { + const vacuumPrefix = `${prefix}-`; + if (name.startsWith(vacuumPrefix) && name.endsWith('.db')) { + const tail = name.slice(vacuumPrefix.length, -3); // strip ".db" + if (tail.startsWith(pin)) return true; + } } return false; } diff --git a/packages/core/src/store/backup-verify.ts b/packages/core/src/store/backup-verify.ts index 4ccde5fe33..b08db370f4 100644 --- a/packages/core/src/store/backup-verify.ts +++ b/packages/core/src/store/backup-verify.ts @@ -141,8 +141,11 @@ function buildSnapshotPatterns(role: string): readonly RegExp[] { new RegExp(`^${safe}\\.db\\.[A-Za-z0-9_-]+-\\d{8}-\\d{6}$`), new RegExp(`^${safe}\\.db\\.snapshot-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z$`), // T13245: `createBackup` writes ONE `cleo.db.` copy of the - // project store, which holds the tasks, brain and conduit tables. - ...(PROJECT_STORE_LABEL_ROLES.has(role) ? [/^cleo\.db\.[A-Za-z0-9_-]+-\d{8}-\d{6}$/] : []), + // project store, which holds the tasks, brain and conduit tables. T13891: + // `vacuumIntoBackupAll` likewise writes one `tasks-*.db` for all three. + ...(PROJECT_STORE_LABEL_ROLES.has(role) + ? [/^cleo\.db\.[A-Za-z0-9_-]+-\d{8}-\d{6}$/, /^tasks-\d{8}-\d{6}\.db$/] + : []), ]; } diff --git a/packages/core/src/store/recover-brain-db.ts b/packages/core/src/store/recover-brain-db.ts index 48bc510710..1547f775df 100644 --- a/packages/core/src/store/recover-brain-db.ts +++ b/packages/core/src/store/recover-brain-db.ts @@ -138,9 +138,11 @@ const SNAPSHOT_FILENAME_RE = /^brain\.db\.snapshot-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2 /** * Snapshot filename pattern for VACUUM INTO debounced session-end snapshots: - * `brain-20260523-130026.db`. Format mirrors `sqlite-backup.ts:formatTimestamp`. + * `brain-20260523-130026.db`, and `tasks-*.db`: brain lives in the unified + * project store, whose one snapshot per run carries the tasks prefix + * (T13891, T13897). Format mirrors `sqlite-backup.ts:formatTimestamp`. */ -const VACUUM_FILENAME_RE = /^brain-(\d{8})-(\d{6})\.db$/; +const VACUUM_FILENAME_RE = /^(?:brain|tasks)-(\d{8})-(\d{6})\.db$/; /** Legacy pre-T9685 dup-fix backup pattern: `brain.db.PRE-DUP-FIX-191315`. */ const PRE_DUP_FIX_RE = /^brain\.db\.PRE-DUP-FIX-/; diff --git a/packages/core/src/store/recover-malformed-db.ts b/packages/core/src/store/recover-malformed-db.ts index 84714cff9a..1afa63d11d 100644 --- a/packages/core/src/store/recover-malformed-db.ts +++ b/packages/core/src/store/recover-malformed-db.ts @@ -255,14 +255,29 @@ function buildSystemSnapshotRegex(role: DbRole): RegExp { ); } +/** + * VACUUM INTO filename prefixes that hold a role's data. Brain and conduit + * live in the unified project `cleo.db`, whose one snapshot per run is + * `tasks-*.db` (T13891); older runs also wrote `brain-*` / `conduit-*`. + * + * @param role - Inventory role. + * @returns The prefixes whose `-YYYYMMDD-HHmmss.db` files hold it. + * @task T13897 + */ +export function vacuumSnapshotPrefixes(role: DbRole): readonly string[] { + return role === 'brain' || role === 'conduit' ? [role, 'tasks'] : [role]; +} + /** * Build the per-role VACUUM-INTO snapshot filename regex (matches - * `-20260523-130026.db`). + * `-20260523-130026.db` for every {@link vacuumSnapshotPrefixes} + * prefix, so the freshest is picked across them). * * @internal */ function buildVacuumSnapshotRegex(role: DbRole): RegExp { - return new RegExp(`^${escapeRegExpLiteral(role)}-(\\d{8})-(\\d{6})\\.db$`); + const prefixes = vacuumSnapshotPrefixes(role).map(escapeRegExpLiteral).join('|'); + return new RegExp(`^(?:${prefixes})-(\\d{8})-(\\d{6})\\.db$`); } /** diff --git a/packages/core/src/store/sqlite-backup.ts b/packages/core/src/store/sqlite-backup.ts index 159001925b..9619e8f99d 100644 --- a/packages/core/src/store/sqlite-backup.ts +++ b/packages/core/src/store/sqlite-backup.ts @@ -47,7 +47,6 @@ import { copyFileSync, existsSync, fsyncSync, - linkSync, mkdirSync, openSync, readdirSync, @@ -57,7 +56,7 @@ import { statSync, unlinkSync, } from 'node:fs'; -import { dirname, join, sep } from 'node:path'; +import { basename, dirname, join, sep } from 'node:path'; import type { DatabaseSync } from 'node:sqlite'; import type { DbInventoryEntry, DbRole } from '@cleocode/contracts'; import { DB_INVENTORY } from '@cleocode/contracts/db-inventory.js'; @@ -700,9 +699,9 @@ async function snapshotProjectTargetsGated( ); if (byPrefix.size === 0) return null; - // One VACUUM per physical database file per run (T12508): tasks, brain and - // conduit share the project cleo.db handle. Maps source realpath → the - // snapshot file written for it in this run. + // One snapshot per physical database file per run (T12508, T13891): tasks, + // brain and conduit share the project cleo.db handle, so only the first of + // them writes a file. Maps source realpath → the snapshot written for it. const writtenBySource = new Map(); return runGatedSnapshot( { @@ -768,32 +767,6 @@ function assertOwnedByProject(source: string, prefix: string, cwd?: string): voi } } -/** - * `link()` error codes that mean "this filesystem cannot hard-link these - * files" rather than a real failure: permission-less link (EPERM), no link - * support (ENOTSUP, e.g. exFAT), different devices (EXDEV), link-count cap - * (EMLINK). The caller copies instead. - */ -const LINK_FALLBACK_CODES = new Set(['EPERM', 'ENOTSUP', 'EXDEV', 'EMLINK']); - -/** - * Hard-link `existing` to `dest`. Returns `false` when the filesystem cannot - * hard-link (see {@link LINK_FALLBACK_CODES}); any other error throws. - * - * @task T12508 - */ -function tryHardLink(existing: string, dest: string): boolean { - try { - linkSync(existing, dest); - return true; - } catch (err) { - if (err instanceof Error && 'code' in err && typeof err.code === 'string') { - if (LINK_FALLBACK_CODES.has(err.code)) return false; - } - throw err; - } -} - /** * An in-progress snapshot file `.tmp-`, or a SQLite sidecar * (`-journal`, `-wal`, `-shm`) that `VACUUM INTO` may leave beside it. @@ -926,12 +899,12 @@ async function freeSnapshotName(backupDir: string, prefix: string): Promise-*.db` (restore, recover-brain-db, listing) - // still finds a file. A filesystem without hard links (exFAT, FAT, some - // network mounts) gets a copy instead. - const existing = source !== null ? writtenBySource.get(source) : undefined; - if (existing !== undefined) { - try { - if (tryHardLink(existing, dest)) { - rotateSnapshots(backupDir, target.prefix, destName); - return 'linked'; - } - const tmp = tempSnapshotPath(dest); - try { - copyFileSync(existing, tmp); - commitSnapshotFile(tmp, dest); - } catch (err) { - rmSync(tmp, { force: true }); - throw err; - } - rotateSnapshots(backupDir, target.prefix, destName); - return 'written'; - } finally { - if (opened && target.closeDb) target.closeDb(opened); - } - } - try { // TRUNCATE checkpoint: flushes all WAL frames to the main DB and truncates // the WAL file to zero bytes, ensuring a consistent DB state before the @@ -1030,7 +993,7 @@ async function snapshotOne( } if (source !== null) writtenBySource.set(source, dest); - rotateSnapshots(backupDir, target.prefix, destName); + rotateSnapshots(backupDir, filePrefix, destName); return 'written'; } finally { // Release the ephemeral handle for raw-file-vacuum-readonly targets. @@ -1150,12 +1113,18 @@ export function listSqliteBackups( } /** - * List existing brain.db snapshots (newest first). + * List existing snapshots that hold the brain tables (newest first): the + * project store's `tasks-*.db` snapshots, since brain lives in the unified + * `cleo.db` and a run writes one file for it (T13891), plus older + * `brain-*.db` files written before that. */ export function listBrainBackups( cwd?: string, ): Array<{ name: string; path: string; mtimeMs: number }> { - return listSqliteBackupsForPrefix('brain', cwd); + return [ + ...listSqliteBackupsForPrefix('tasks', cwd), + ...listSqliteBackupsForPrefix('brain', cwd), + ].sort((a, b) => b.mtimeMs - a.mtimeMs); } /** diff --git a/packages/core/src/system/backup.ts b/packages/core/src/system/backup.ts index 13192b7a1b..b04b99c769 100644 --- a/packages/core/src/system/backup.ts +++ b/packages/core/src/system/backup.ts @@ -29,8 +29,9 @@ * snapshotter) write to the SAME directory: `.cleo/backups/sqlite/`. The two * producers use distinguishable filename schemes that coexist: * - * - `vacuumIntoBackupAll` writes `tasks-YYYYMMDD-HHmmss.db` / - * `brain-YYYYMMDD-HHmmss.db` (no sidecar). + * - `vacuumIntoBackupAll` writes ONE `tasks-YYYYMMDD-HHmmss.db` per run for + * the unified project store (no sidecar); older `brain-*` / `conduit-*` + * files predate T13891. * - `createBackup` writes `.` + `.meta.json`, * where `backupId = -YYYYMMDD-HHmmss` (matching the same local-time * timestamp format).