feat(upulse): add rollback command — time-machine revert for engine
CI / test (push) Has been cancelled

- upulse rollback <commit> [--dry-run] [--engine <path>] [--events <path>]
- Exports events after cutoff to .pulse/rollbacks/rollback-<date>-<commit>.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
This commit is contained in:
2026-04-18 01:34:26 +00:00
parent 37e3331ccb
commit 8754ff51cd
3 changed files with 218 additions and 0 deletions
+2
View File
@@ -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();
+208
View File
@@ -0,0 +1,208 @@
/**
* commands/rollback.ts — upulse rollback <commit>
*
* 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-<date>.db
* 4. Delete those events from events.db
* 5. git checkout <commit> 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 <commit>')
.description('Time-machine rollback: revert engine to a previous commit')
.option('--engine <path>', 'Engine directory (default: ~/.upulse/engine)')
.option('--events <path>', '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`);
});
}
+8
View File
@@ -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
}