Files
TORCH/src/cmd-backup.mjs
T
Claude be5ec21d5c feat(governance): add prompt versioning, rollback --list, and state backup
- governance/index.js: archive filenames now include ISO timestamp for
  deterministic sorting (<base>_<ts>_<hash>.md); write .meta.json sidecar
  on every archive with proposalId, author, reason, and archivedAt
- governance/index.js: add listPromptVersions(target) — returns archived
  versions for a prompt sorted newest-first with sidecar metadata surfaced
- cmd-rollback.mjs: add --list flag to list available versions without
  restoring (delegates to listPromptVersions)
- cli-parser.mjs: add --list and --output flags
- lib.mjs: pass list flag to cmdRollback; add backup command dispatch
- cmd-backup.mjs: new torch-lock backup command — snapshots
  .scheduler-memory/memory-store.json and scheduler run state to
  .torch/backups/<timestamp>/; supports --output override and --list
- docs/prompt-versioning.md: comprehensive guide to the full
  proposal → apply → list-versions → rollback workflow, state backup
  and restore procedure, and storage location reference
- test/governance.test.mjs: 7 scenario-based tests (SCN-proposal-create,
  SCN-proposal-list, SCN-proposal-reject, SCN-archive-naming,
  SCN-version-list, SCN-rollback-latest, SCN-rollback-hash)

https://claude.ai/code/session_01Ndq2D72Kp77BYo4owD63uY
2026-02-20 02:27:57 +00:00

110 lines
3.2 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { execSync } from 'node:child_process';
const BACKUP_ROOT = path.resolve(process.cwd(), '.torch/backups');
// Paths to snapshot on each backup run
const STATE_SOURCES = [
'.scheduler-memory/memory-store.json',
'task-logs/daily/.scheduler-run-state.json',
];
/**
* Creates a timestamped snapshot of TORCH runtime state under .torch/backups/<timestamp>/.
*
* Files captured (if they exist):
* - .scheduler-memory/memory-store.json (agent long-term memory)
* - task-logs/daily/.scheduler-run-state.json (scheduler deferral state)
*
* A backup-manifest.json is always written recording what was captured,
* the git commit at backup time, and an ISO timestamp.
*
* @param {Object} [opts]
* @param {string|null} [opts.output] - Override destination directory (default: .torch/backups/<ts>)
* @returns {Promise<{backupDir: string, manifest: Object}>}
*/
export async function cmdBackup({ output = null } = {}) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = output
? path.resolve(process.cwd(), output)
: path.join(BACKUP_ROOT, ts);
await fs.mkdir(backupDir, { recursive: true });
let gitCommit = null;
try {
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
} catch {
// Not in a git repo or git unavailable
}
const captured = [];
const skipped = [];
for (const relPath of STATE_SOURCES) {
const src = path.resolve(process.cwd(), relPath);
const dest = path.join(backupDir, relPath.replace(/\//g, '__'));
try {
await fs.copyFile(src, dest);
captured.push(relPath);
} catch (e) {
if (e.code === 'ENOENT') {
skipped.push({ path: relPath, reason: 'not found' });
} else {
skipped.push({ path: relPath, reason: e.message });
}
}
}
const manifest = {
createdAt: new Date().toISOString(),
gitCommit,
backupDir,
captured,
skipped,
};
await fs.writeFile(
path.join(backupDir, 'backup-manifest.json'),
JSON.stringify(manifest, null, 2)
);
console.log(JSON.stringify({ success: true, ...manifest }, null, 2));
return { backupDir, manifest };
}
/**
* Lists all available backups under .torch/backups/, newest first.
*
* @returns {Promise<Array<{id: string, createdAt: string|null, backupDir: string}>>}
*/
export async function listBackups() {
try {
const entries = await fs.readdir(BACKUP_ROOT, { withFileTypes: true });
const backups = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const backupDir = path.join(BACKUP_ROOT, entry.name);
let manifest = null;
try {
manifest = JSON.parse(await fs.readFile(path.join(backupDir, 'backup-manifest.json'), 'utf8'));
} catch {
// No manifest
}
backups.push({
id: entry.name,
createdAt: manifest?.createdAt ?? null,
gitCommit: manifest?.gitCommit ?? null,
captured: manifest?.captured ?? [],
backupDir,
});
}
// Newest first (ISO timestamp sort)
backups.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
return backups;
} catch {
return [];
}
}