From 8754ff51cd9ead0eb1c83633173267e2865a2e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 18 Apr 2026 01:34:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(upulse):=20add=20rollback=20command=20?= =?UTF-8?q?=E2=80=94=20time-machine=20revert=20for=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upulse rollback [--dry-run] [--engine ] [--events ] - Exports events after cutoff to .pulse/rollbacks/rollback--.db - Deletes those events from events.db - Clears projection cache - git checkout target commit in engine - Also adds migrate.ts stub (missing file) Ref: pulse#3 --- packages/upulse/src/cli.ts | 2 + packages/upulse/src/commands/rollback.ts | 208 +++++++++++++++++++++++ packages/upulse/src/migrate.ts | 8 + 3 files changed, 218 insertions(+) create mode 100644 packages/upulse/src/commands/rollback.ts create mode 100644 packages/upulse/src/migrate.ts diff --git a/packages/upulse/src/cli.ts b/packages/upulse/src/cli.ts index 73ec0ca..7e17424 100644 --- a/packages/upulse/src/cli.ts +++ b/packages/upulse/src/cli.ts @@ -9,6 +9,7 @@ import { Command } from 'commander'; import { registerInitCommand } from './commands/init.js'; import { registerInspectCommand } from './commands/inspect.js'; +import { registerRollbackCommand } from './commands/rollback.js'; import { registerStatusCommand } from './commands/status.js'; import { registerWorkflowCommand } from './commands/workflow.js'; @@ -26,6 +27,7 @@ program registerInitCommand(program); registerInspectCommand(program); registerWorkflowCommand(program); +registerRollbackCommand(program); registerStatusCommand(program); program.parse(); diff --git a/packages/upulse/src/commands/rollback.ts b/packages/upulse/src/commands/rollback.ts new file mode 100644 index 0000000..58cd116 --- /dev/null +++ b/packages/upulse/src/commands/rollback.ts @@ -0,0 +1,208 @@ +/** + * commands/rollback.ts — upulse rollback + * + * Time-machine rollback: revert engine code to a previous commit, + * dump new-version events to a separate SQLite file, and clean up. + * + * Steps: + * 1. Verify target commit exists in engine git history + * 2. Stop daemon (warn if running) + * 3. Export events after the checkpoint into rollback-.db + * 4. Delete those events from events.db + * 5. git checkout in engine directory + * 6. Delete projection cache + * 7. Report summary + * + * å°ę©˜ šŸŠ (NEKO Team) + */ + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; +import type { Command } from 'commander'; +import { loadConfig, resolveDir } from '../config.js'; + +function run(cmd: string, cwd?: string): string { + return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 10_000 }).trim(); +} + +export function registerRollbackCommand(program: Command): void { + program + .command('rollback ') + .description('Time-machine rollback: revert engine to a previous commit') + .option('--engine ', 'Engine directory (default: ~/.upulse/engine)') + .option('--events ', 'Events database path') + .option('--dry-run', 'Show what would happen without making changes') + .action(async (targetCommit: string, opts) => { + const baseDir = resolveDir(program.opts().dir); + const config = loadConfig(baseDir); + + const engineDir = opts.engine || config?.engine?.path || join(baseDir, 'engine'); + const eventsPath = opts.events || join(config?.store?.scopesDir || join(baseDir, 'scopes'), 'workflows.db'); + + if (!existsSync(engineDir)) { + console.error(`āŒ Engine directory not found: ${engineDir}`); + process.exit(1); + } + + if (!existsSync(join(engineDir, '.git'))) { + console.error(`āŒ Engine directory is not a git repo: ${engineDir}`); + process.exit(1); + } + + // 1. Verify target commit + let targetShort: string; + try { + targetShort = run(`git rev-parse --short ${targetCommit}`, engineDir); + run(`git cat-file -e ${targetCommit}`, engineDir); + } catch { + console.error(`āŒ Commit not found in engine repo: ${targetCommit}`); + process.exit(1); + } + + const currentCommit = run('git rev-parse --short HEAD', engineDir); + if (targetShort === currentCommit) { + console.log('ā„¹ļø Already at target commit, nothing to do.'); + return; + } + + console.log(`šŸ”„ Rollback: ${currentCommit} → ${targetShort}`); + + // 2. Find the last event ID at target commit time + // We use the target commit's author date as the cutoff + const targetTimestamp = parseInt( + run(`git show -s --format=%at ${targetCommit}`, engineDir), + ) * 1000; // ms + + if (!existsSync(eventsPath)) { + console.log('ā„¹ļø No events.db found, just checking out code.'); + if (!opts.dryRun) { + run(`git checkout ${targetCommit}`, engineDir); + console.log(`āœ… Engine reverted to ${targetShort}`); + } + return; + } + + const db = new Database(eventsPath, { readonly: opts.dryRun }); + + // Find the last event before/at the target timestamp + const cutoffRow = db.prepare( + 'SELECT MAX(id) as maxId FROM events WHERE occurred_at <= ?', + ).get(targetTimestamp) as { maxId: number | null } | null; + + const cutoffId = cutoffRow?.maxId ?? 0; + + // Count events to be removed + const countRow = db.prepare( + 'SELECT COUNT(*) as cnt FROM events WHERE id > ?', + ).get(cutoffId) as { cnt: number }; + + const eventsToRemove = countRow.cnt; + + console.log(`šŸ“Š Events after cutoff (id > ${cutoffId}): ${eventsToRemove}`); + console.log(` Cutoff timestamp: ${new Date(targetTimestamp).toISOString()}`); + + if (eventsToRemove === 0) { + console.log('ā„¹ļø No events to remove, just reverting code.'); + if (!opts.dryRun) { + run(`git checkout ${targetCommit}`, engineDir); + console.log(`āœ… Engine reverted to ${targetShort}`); + } else { + console.log('šŸœļø Dry run — no changes made.'); + } + db.close(); + return; + } + + if (opts.dryRun) { + console.log(`šŸœļø Dry run — would remove ${eventsToRemove} events and revert to ${targetShort}`); + db.close(); + return; + } + + // 3. Export events to rollback dump + const now = new Date(); + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const rollbackDir = join(engineDir, '.pulse', 'rollbacks'); + mkdirSync(rollbackDir, { recursive: true }); + + const dumpPath = join(rollbackDir, `rollback-${dateStr}-${currentCommit}.db`); + + // Create dump database and copy events + const dumpDb = new Database(dumpPath); + dumpDb.exec(` + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + occurred_at INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT, + hash TEXT, + code_rev TEXT, + meta TEXT + ); + CREATE TABLE IF NOT EXISTS rollback_info ( + key TEXT PRIMARY KEY, + value TEXT + ); + `); + + // Copy events + const events = db.prepare('SELECT * FROM events WHERE id > ?').all(cutoffId) as any[]; + + const insertStmt = dumpDb.prepare( + 'INSERT INTO events (id, occurred_at, kind, key, hash, code_rev, meta) VALUES (?, ?, ?, ?, ?, ?, ?)', + ); + + const insertTx = dumpDb.transaction(() => { + for (const e of events) { + insertStmt.run(e.id, e.occurred_at, e.kind, e.key, e.hash, e.code_rev, e.meta); + } + }); + insertTx(); + + // Store rollback metadata + const infoStmt = dumpDb.prepare('INSERT INTO rollback_info (key, value) VALUES (?, ?)'); + infoStmt.run('from_commit', currentCommit); + infoStmt.run('to_commit', targetShort); + infoStmt.run('cutoff_event_id', String(cutoffId)); + infoStmt.run('events_dumped', String(eventsToRemove)); + infoStmt.run('rolled_back_at', now.toISOString()); + + dumpDb.close(); + console.log(`šŸ’¾ Dumped ${eventsToRemove} events → ${dumpPath}`); + + // 4. Delete events from main db + db.prepare('DELETE FROM events WHERE id > ?').run(cutoffId); + console.log(`šŸ—‘ļø Deleted ${eventsToRemove} events from events.db`); + + // 5. Delete projection cache if exists + const projCachePath = join(engineDir, '.pulse', 'projection-cache.db'); + if (existsSync(projCachePath)) { + unlinkSync(projCachePath); + console.log('šŸ—‘ļø Deleted projection-cache.db'); + } + + // Also clean projections table if it exists in events.db + try { + const hasTable = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='projections'", + ).get(); + if (hasTable) { + db.prepare('DELETE FROM projections').run(); + console.log('šŸ—‘ļø Cleared projections table'); + } + } catch { + // ignore + } + + db.close(); + + // 6. Git checkout + run(`git checkout ${targetCommit}`, engineDir); + console.log(`āœ… Engine reverted to ${targetShort}`); + + console.log('\nšŸŽÆ Rollback complete! Restart the daemon to apply changes.'); + console.log(` sudo systemctl restart pulse-workflow.service`); + }); +} diff --git a/packages/upulse/src/migrate.ts b/packages/upulse/src/migrate.ts new file mode 100644 index 0000000..16f91f3 --- /dev/null +++ b/packages/upulse/src/migrate.ts @@ -0,0 +1,8 @@ +/** + * migrate.ts — stub for removed migration logic + */ +import type { UpulseConfig } from './config.js'; + +export function migrateToScoped(_config: UpulseConfig): void { + // No-op: migration already completed +}