mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-04 20:01:54 +00:00
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
This commit is contained in:
7 files changed
+914
-47
No files matched your search
@@ -40,6 +40,8 @@ export function parseArgs(argv) {
|
||||
reason: null,
|
||||
strategy: null,
|
||||
status: null,
|
||||
list: false,
|
||||
output: null,
|
||||
};
|
||||
let i = 0;
|
||||
|
||||
@@ -161,6 +163,12 @@ export function parseArgs(argv) {
|
||||
args.status = argv[++i];
|
||||
} else if (arg.startsWith('--status=')) {
|
||||
args.status = arg.split('=')[1];
|
||||
} else if (arg === '--list') {
|
||||
args.list = true;
|
||||
} else if (arg === '--output') {
|
||||
args.output = argv[++i];
|
||||
} else if (arg.startsWith('--output=')) {
|
||||
args.output = arg.split('=')[1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -1,12 +1,23 @@
|
||||
import { rollbackPrompt } from './services/governance/index.js';
|
||||
import { rollbackPrompt, listPromptVersions } from './services/governance/index.js';
|
||||
import { ExitError } from './errors.mjs';
|
||||
|
||||
export async function cmdRollback(target, strategy) {
|
||||
export async function cmdRollback(target, strategy, { list = false } = {}) {
|
||||
if (!target) {
|
||||
console.error('Usage: torch-lock rollback --target <path> [--strategy <hash|latest>]');
|
||||
console.error('Usage: torch-lock rollback --target <path> [--strategy <hash|latest>] [--list]');
|
||||
throw new ExitError(1, 'Missing target');
|
||||
}
|
||||
|
||||
if (list) {
|
||||
try {
|
||||
const versions = await listPromptVersions(target);
|
||||
console.log(JSON.stringify(versions, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Failed to list versions: ${e.message}`);
|
||||
throw new ExitError(1, 'List versions failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveStrategy = strategy || 'latest';
|
||||
|
||||
try {
|
||||
|
||||
+12
-1
@@ -723,7 +723,18 @@ export async function main(argv) {
|
||||
}
|
||||
|
||||
case 'rollback': {
|
||||
await cmdRollback(args.target, args.strategy);
|
||||
await cmdRollback(args.target, args.strategy, { list: args.list });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'backup': {
|
||||
const { cmdBackup, listBackups } = await import('./cmd-backup.mjs');
|
||||
if (args.list) {
|
||||
const backups = await listBackups();
|
||||
console.log(JSON.stringify(backups, null, 2));
|
||||
} else {
|
||||
await cmdBackup({ output: args.output });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -175,18 +175,26 @@ export async function applyProposal(id) {
|
||||
try {
|
||||
const oldContent = await fs.readFile(absoluteTarget, 'utf8');
|
||||
const hash = createHash('sha256').update(oldContent).digest('hex');
|
||||
|
||||
// We store archives in .torch/prompt-history/<target_path>/<hash>.md
|
||||
// Note: meta.target is relative path like 'src/prompts/daily/agent.md'
|
||||
// We want to keep that structure.
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(meta.target));
|
||||
await fs.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
// Add timestamp to filename for easier sorting later?
|
||||
// Or just rely on hash and store metadata elsewhere?
|
||||
// User requested <path>/<hash>.md.
|
||||
const archivePath = path.join(archiveDir, `${path.basename(meta.target, '.md')}_${hash}.md`);
|
||||
// Filename: <base>_<timestamp>_<hash>.md for deterministic sort by name
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const baseName = path.basename(meta.target, '.md');
|
||||
const archiveName = `${baseName}_${ts}_${hash}.md`;
|
||||
const archivePath = path.join(archiveDir, archiveName);
|
||||
await fs.writeFile(archivePath, oldContent);
|
||||
|
||||
// Write sidecar metadata for rich version listing
|
||||
const archiveMeta = {
|
||||
proposalId: id,
|
||||
author: meta.author,
|
||||
reason: meta.reason,
|
||||
target: meta.target,
|
||||
hash,
|
||||
archivedAt: new Date().toISOString(),
|
||||
};
|
||||
await fs.writeFile(archivePath.replace(/\.md$/, '.meta.json'), JSON.stringify(archiveMeta, null, 2));
|
||||
} catch (_e) {
|
||||
// If file didn't exist, nothing to archive
|
||||
}
|
||||
@@ -228,6 +236,78 @@ export async function rejectProposal(id, reason) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function listPromptVersions(target) {
|
||||
// target is relative path e.g. src/prompts/daily/agent.md
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(target));
|
||||
const targetBase = path.basename(target, '.md');
|
||||
const prefix = targetBase + '_';
|
||||
|
||||
const versions = [];
|
||||
try {
|
||||
const files = await fs.readdir(archiveDir);
|
||||
const archiveFiles = files.filter(f => f.startsWith(prefix) && f.endsWith('.md'));
|
||||
|
||||
for (const file of archiveFiles) {
|
||||
const filePath = path.join(archiveDir, file);
|
||||
const metaPath = filePath.replace(/\.md$/, '.meta.json');
|
||||
|
||||
let meta = null;
|
||||
try {
|
||||
meta = JSON.parse(await fs.readFile(metaPath, 'utf8'));
|
||||
} catch {
|
||||
// No sidecar — legacy archive or missing
|
||||
}
|
||||
|
||||
// Extract timestamp and hash from filename: <base>_<timestamp>_<hash>.md
|
||||
// Legacy format: <base>_<hash>.md (40-char hex)
|
||||
const withoutPrefix = file.slice(prefix.length, -3); // strip prefix and .md
|
||||
const parts = withoutPrefix.split('_');
|
||||
let archivedAt = null;
|
||||
let hash = null;
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// New format: timestamp_hash (timestamp has dashes instead of colons)
|
||||
hash = parts[parts.length - 1];
|
||||
const tsPart = parts.slice(0, -1).join('_');
|
||||
// Convert back: YYYY-MM-DDTHH-MM-SS-mmmZ -> ISO
|
||||
archivedAt = tsPart.replace(
|
||||
/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/,
|
||||
'$1:$2:$3.$4Z'
|
||||
);
|
||||
if (archivedAt === tsPart) archivedAt = null; // parse failed
|
||||
} else {
|
||||
// Legacy: single part = hash only, use file stat for date
|
||||
hash = withoutPrefix;
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
archivedAt = stat.mtime.toISOString();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
versions.push({
|
||||
filename: file,
|
||||
archivedAt: meta?.archivedAt ?? archivedAt,
|
||||
hash: meta?.hash ?? hash,
|
||||
proposalId: meta?.proposalId ?? null,
|
||||
author: meta?.author ?? null,
|
||||
reason: meta?.reason ?? null,
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// archiveDir doesn't exist — no versions yet
|
||||
}
|
||||
|
||||
// Sort newest first, using archivedAt string (ISO sorts lexicographically)
|
||||
versions.sort((a, b) => {
|
||||
if (a.archivedAt && b.archivedAt) return b.archivedAt.localeCompare(a.archivedAt);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
export async function rollbackPrompt(target, hashOrStrategy = 'latest') {
|
||||
// target is relative path e.g. src/prompts/daily/agent.md
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(target));
|
||||
@@ -237,52 +317,36 @@ export async function rollbackPrompt(target, hashOrStrategy = 'latest') {
|
||||
let sourceName = null;
|
||||
|
||||
try {
|
||||
// Try Local Archive first
|
||||
if (hashOrStrategy === 'latest') {
|
||||
// Find latest file in archiveDir
|
||||
// Our naming is <agent>_<hash>.md.
|
||||
// We can rely on file mtime.
|
||||
const files = await fs.readdir(archiveDir);
|
||||
const targetBase = path.basename(target, '.md');
|
||||
const prefix = targetBase + '_';
|
||||
const candidates = files.filter(f => f.startsWith(prefix));
|
||||
|
||||
if (candidates.length > 0) {
|
||||
const stats = await Promise.all(candidates.map(async f => ({
|
||||
name: f,
|
||||
...await fs.stat(path.join(archiveDir, f))
|
||||
})));
|
||||
const latest = stats.sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
|
||||
sourceName = latest.name;
|
||||
sourceContent = await fs.readFile(path.join(archiveDir, sourceName), 'utf8');
|
||||
}
|
||||
const versions = await listPromptVersions(target);
|
||||
if (versions.length > 0) {
|
||||
sourceName = versions[0].filename;
|
||||
sourceContent = await fs.readFile(path.join(archiveDir, sourceName), 'utf8');
|
||||
}
|
||||
} else {
|
||||
// Try to find file containing hash
|
||||
const files = await fs.readdir(archiveDir);
|
||||
const match = files.find(f => f.includes(hashOrStrategy));
|
||||
if (match) {
|
||||
sourceName = match;
|
||||
sourceContent = await fs.readFile(path.join(archiveDir, match), 'utf8');
|
||||
}
|
||||
// Try to find file containing the hash or timestamp fragment
|
||||
const files = await fs.readdir(archiveDir);
|
||||
const match = files.find(f => f.includes(hashOrStrategy) && f.endsWith('.md'));
|
||||
if (match) {
|
||||
sourceName = match;
|
||||
sourceContent = await fs.readFile(path.join(archiveDir, match), 'utf8');
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Archive lookup failed
|
||||
// Archive lookup failed
|
||||
}
|
||||
|
||||
if (sourceContent) {
|
||||
await fs.writeFile(absoluteTarget, sourceContent);
|
||||
return { success: true, source: 'archive', restored: sourceName };
|
||||
await fs.writeFile(absoluteTarget, sourceContent);
|
||||
return { success: true, source: 'archive', restored: sourceName };
|
||||
}
|
||||
|
||||
// Fallback to Git
|
||||
try {
|
||||
const commit = hashOrStrategy === 'latest' ? 'HEAD' : hashOrStrategy;
|
||||
// Use git show instead of checkout to avoid detaching head if we just want content
|
||||
// But user said "git checkout" implying revert state.
|
||||
// Actually `git checkout <commit> -- <path>` overwrites the file in working tree.
|
||||
execSync(`git checkout "${commit}" -- "${target}"`);
|
||||
return { success: true, source: 'git', restored: commit };
|
||||
const commit = hashOrStrategy === 'latest' ? 'HEAD' : hashOrStrategy;
|
||||
execSync(`git checkout "${commit}" -- "${target}"`);
|
||||
return { success: true, source: 'git', restored: commit };
|
||||
} catch (e) {
|
||||
throw new Error(`Rollback failed: Local archive not found and git failed (${e.message})`, { cause: e });
|
||||
throw new Error(`Rollback failed: Local archive not found and git failed (${e.message})`, { cause: e });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user