feat(upulse): add rollback command — time-machine revert for engine
CI / test (push) Has been cancelled
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:
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user