mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +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
@@ -0,0 +1,290 @@
|
||||
# Prompt Versioning and State Backup
|
||||
|
||||
This document describes how TORCH manages agent prompt versions and runtime state backups — two distinct but complementary systems.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Concern | Mechanism | Storage location |
|
||||
|---------|-----------|-----------------|
|
||||
| Prompt change control | Governance proposals | `src/proposals/` |
|
||||
| Prompt version archive | Applied-proposal archive | `.torch/prompt-history/` |
|
||||
| Prompt rollback | `torch-lock rollback` | Reads `.torch/prompt-history/`, falls back to git |
|
||||
| State snapshot | `torch-lock backup` | `.torch/backups/<timestamp>/` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Prompt Versioning
|
||||
|
||||
### How it works
|
||||
|
||||
Every agent prompt lives in `src/prompts/daily/<agent>.md` or `src/prompts/weekly/<agent>.md`. These are the canonical sources of truth.
|
||||
|
||||
When a change is proposed and applied through the governance system, TORCH automatically archives the **old** content before overwriting the file. The archive is stored in:
|
||||
|
||||
```
|
||||
.torch/prompt-history/<subpath>/<basename>_<timestamp>_<sha256>.md
|
||||
```
|
||||
|
||||
A sidecar metadata file is written alongside each archive:
|
||||
|
||||
```
|
||||
.torch/prompt-history/<subpath>/<basename>_<timestamp>_<sha256>.meta.json
|
||||
```
|
||||
|
||||
The sidecar records who made the change, why, and when — making the archive self-documenting.
|
||||
|
||||
### Filename format
|
||||
|
||||
```
|
||||
audit-agent_2026-01-15T00-00-00-000Z_a3f5b19c....md
|
||||
│ │ │
|
||||
│ │ └─ SHA-256 of old content
|
||||
│ └─ ISO timestamp (colons/dots replaced with dashes)
|
||||
└─ Prompt base name
|
||||
```
|
||||
|
||||
This naming scheme ensures versions sort lexicographically by time with no reliance on filesystem mtime.
|
||||
|
||||
---
|
||||
|
||||
## 2. Proposal Workflow (Create → Apply → Rollback)
|
||||
|
||||
### Step 1 — Create a proposal
|
||||
|
||||
```bash
|
||||
torch-lock proposal create \
|
||||
--agent <agent-name> \
|
||||
--target src/prompts/daily/<agent>.md \
|
||||
--content /path/to/new-content.md \
|
||||
--reason "Add missing EXIT CRITERIA section"
|
||||
```
|
||||
|
||||
This writes the proposal to `src/proposals/<timestamp>_<agent>/`:
|
||||
- `meta.json` — author, target, reason, status
|
||||
- `new.md` — proposed new content
|
||||
- `change.diff` — git diff against current file
|
||||
|
||||
The target path must be within `src/prompts/daily/` or `src/prompts/weekly/`.
|
||||
|
||||
### Step 2 — List pending proposals
|
||||
|
||||
```bash
|
||||
torch-lock proposal list
|
||||
# or filter by status:
|
||||
torch-lock proposal list --status pending
|
||||
```
|
||||
|
||||
### Step 3 — Inspect a proposal
|
||||
|
||||
```bash
|
||||
torch-lock proposal show --id <proposal-id>
|
||||
```
|
||||
|
||||
### Step 4 — Apply the proposal
|
||||
|
||||
```bash
|
||||
torch-lock proposal apply --id <proposal-id>
|
||||
```
|
||||
|
||||
Before writing the new content, TORCH:
|
||||
1. Validates the proposal passes all invariant checks (required contract headers)
|
||||
2. Archives the **current** file content to `.torch/prompt-history/`
|
||||
3. Writes the new content to the target path
|
||||
4. Attempts a `git add` + `git commit` automatically
|
||||
|
||||
A proposal can only be applied when its status is `pending`.
|
||||
|
||||
### Step 5 — Reject a proposal (optional alternative)
|
||||
|
||||
```bash
|
||||
torch-lock proposal reject --id <proposal-id> --reason "Does not preserve EXIT CRITERIA pattern"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Listing Available Versions
|
||||
|
||||
To see what archived versions exist for a prompt:
|
||||
|
||||
```bash
|
||||
torch-lock rollback --target src/prompts/daily/audit-agent.md --list
|
||||
```
|
||||
|
||||
Output (JSON array, newest first):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"filename": "audit-agent_2026-02-20T12-00-00-000Z_a3f5b19c.md",
|
||||
"archivedAt": "2026-02-20T12:00:00.000Z",
|
||||
"hash": "a3f5b19c...",
|
||||
"proposalId": "2026-02-20T12-00-00-000Z_prompt-fixer-agent",
|
||||
"author": "prompt-fixer-agent",
|
||||
"reason": "Add missing EXIT CRITERIA section"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Rolling Back a Prompt
|
||||
|
||||
### Restore the most recent version
|
||||
|
||||
```bash
|
||||
torch-lock rollback --target src/prompts/daily/audit-agent.md
|
||||
# same as:
|
||||
torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy latest
|
||||
```
|
||||
|
||||
### Restore a specific version by hash fragment
|
||||
|
||||
```bash
|
||||
torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy a3f5b19c
|
||||
```
|
||||
|
||||
Any unique fragment of the hash or timestamp in the filename works as a strategy value.
|
||||
|
||||
### Fallback to git
|
||||
|
||||
If no local archive exists, TORCH falls back to git:
|
||||
|
||||
```bash
|
||||
# Restores the HEAD version from git (useful if .torch/ was lost)
|
||||
torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy latest
|
||||
```
|
||||
|
||||
Or specify an explicit git commit SHA:
|
||||
|
||||
```bash
|
||||
torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy <git-sha>
|
||||
```
|
||||
|
||||
The fallback uses `git checkout <commit> -- <target>`, which overwrites the file in the working tree.
|
||||
|
||||
---
|
||||
|
||||
## 5. State Backup
|
||||
|
||||
Agent runtime state lives outside of git in two places:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `.scheduler-memory/memory-store.json` | Agent long-term memory |
|
||||
| `task-logs/daily/.scheduler-run-state.json` | Scheduler lock deferral state |
|
||||
|
||||
### Create a snapshot
|
||||
|
||||
```bash
|
||||
torch-lock backup
|
||||
```
|
||||
|
||||
This writes a timestamped directory under `.torch/backups/`:
|
||||
|
||||
```
|
||||
.torch/backups/
|
||||
└── 2026-02-20T12-00-00-000Z/
|
||||
├── backup-manifest.json
|
||||
├── .scheduler-memory__memory-store.json
|
||||
└── task-logs__daily__.scheduler-run-state.json
|
||||
```
|
||||
|
||||
`backup-manifest.json` records what was captured, what was skipped (if a file was missing), the git commit at backup time, and the ISO timestamp.
|
||||
|
||||
### Write to a custom directory
|
||||
|
||||
```bash
|
||||
torch-lock backup --output /path/to/my-backup-dir
|
||||
```
|
||||
|
||||
### List existing backups
|
||||
|
||||
```bash
|
||||
torch-lock backup --list
|
||||
```
|
||||
|
||||
Output (JSON array, newest first):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "2026-02-20T12-00-00-000Z",
|
||||
"createdAt": "2026-02-20T12:00:00.000Z",
|
||||
"gitCommit": "a3f5b19...",
|
||||
"captured": [
|
||||
".scheduler-memory/memory-store.json",
|
||||
"task-logs/daily/.scheduler-run-state.json"
|
||||
],
|
||||
"backupDir": "/path/to/TORCH/.torch/backups/2026-02-20T12-00-00-000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Restore from a backup
|
||||
|
||||
Restoration is a manual copy. From your project root:
|
||||
|
||||
```bash
|
||||
# Restore memory store
|
||||
cp .torch/backups/<id>/.scheduler-memory__memory-store.json .scheduler-memory/memory-store.json
|
||||
|
||||
# Restore scheduler run state
|
||||
cp .torch/backups/<id>/task-logs__daily__.scheduler-run-state.json task-logs/daily/.scheduler-run-state.json
|
||||
```
|
||||
|
||||
There is intentionally no automated restore command — restoring memory state is a significant operation that should be a deliberate human decision.
|
||||
|
||||
### Recommended backup cadence
|
||||
|
||||
The `backup-agent` (weekly) can be configured to run `torch-lock backup` as part of the weekly cycle. Alternatively, add it to your CI pipeline or cron schedule. Backups are cheap (two small JSON files) and idempotent.
|
||||
|
||||
---
|
||||
|
||||
## 6. What Is and Is Not Versioned
|
||||
|
||||
| Artifact | Version tracked | How |
|
||||
|----------|----------------|-----|
|
||||
| `src/prompts/daily/*.md` | Yes | Proposal archive + git history |
|
||||
| `src/prompts/weekly/*.md` | Yes | Proposal archive + git history |
|
||||
| `.scheduler-memory/memory-store.json` | Snapshot only | `torch-lock backup` |
|
||||
| `task-logs/daily/.scheduler-run-state.json` | Snapshot only | `torch-lock backup` |
|
||||
| `src/proposals/*/` | Permanent record | On-disk (not archived further) |
|
||||
| `torch-config.json` | Git history | Standard git |
|
||||
|
||||
---
|
||||
|
||||
## 7. Storage Locations Reference
|
||||
|
||||
```
|
||||
.torch/
|
||||
├── prompt-history/ # Prompt archive (created on first proposal apply)
|
||||
│ └── src/prompts/daily/
|
||||
│ ├── audit-agent_<ts>_<hash>.md # Archived prompt content
|
||||
│ └── audit-agent_<ts>_<hash>.meta.json # Archive metadata
|
||||
└── backups/ # State snapshots
|
||||
└── <timestamp>/
|
||||
├── backup-manifest.json
|
||||
├── .scheduler-memory__memory-store.json
|
||||
└── task-logs__daily__.scheduler-run-state.json
|
||||
|
||||
src/proposals/
|
||||
└── <timestamp>_<agent>/ # One directory per proposal
|
||||
├── meta.json
|
||||
├── new.md
|
||||
└── change.diff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Removing TORCH
|
||||
|
||||
The `.torch/` directory (prompt history + backups) and `src/proposals/` are removed by `torch-lock remove`. See [docs/removal.md](removal.md) for the full removal procedure.
|
||||
|
||||
If you want to preserve the prompt history or state backups before removing TORCH, copy `.torch/` to a safe location first:
|
||||
|
||||
```bash
|
||||
cp -r .torch/ ~/torch-archive-$(date +%Y%m%d)/
|
||||
torch-lock remove
|
||||
```
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Governance: Prompt Versioning and Rollback Tests
|
||||
*
|
||||
* Scenarios tested:
|
||||
* SCN-proposal-create : createProposal writes correct on-disk structure
|
||||
* SCN-proposal-list : listProposals returns all proposals; status filter works
|
||||
* SCN-proposal-reject : rejectProposal marks status and records reason
|
||||
* SCN-archive-naming : applyProposal archives with <base>_<timestamp>_<hash>.md + sidecar
|
||||
* SCN-version-list : listPromptVersions returns versions newest-first with metadata
|
||||
* SCN-rollback-latest : rollbackPrompt --strategy latest restores most-recent archive
|
||||
* SCN-rollback-hash : rollbackPrompt --strategy <fragment> restores the matching archive
|
||||
* SCN-rollback-no-archive: rollbackPrompt falls through to git when no archive exists
|
||||
*/
|
||||
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CWD = process.cwd();
|
||||
const PROPOSALS_DIR = path.join(CWD, 'src/proposals');
|
||||
const HISTORY_DIR = path.join(CWD, '.torch/prompt-history');
|
||||
|
||||
// A real daily prompt that is guaranteed to exist (passes allowlist)
|
||||
const TEST_TARGET = 'src/prompts/daily/audit-agent.md';
|
||||
const ABS_TARGET = path.join(CWD, TEST_TARGET);
|
||||
|
||||
let originalPromptContent = null;
|
||||
const createdProposalIds = [];
|
||||
|
||||
// Read governance functions fresh each time (they're already cached by Node's ESM cache,
|
||||
// but that's fine — we need stable CWD-based paths for integration tests).
|
||||
const {
|
||||
createProposal,
|
||||
listProposals,
|
||||
getProposal,
|
||||
rejectProposal,
|
||||
applyProposal,
|
||||
listPromptVersions,
|
||||
rollbackPrompt,
|
||||
} = await import('../src/services/governance/index.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
before(async () => {
|
||||
// Save original prompt content so we can restore it after applyProposal tests
|
||||
originalPromptContent = await fsp.readFile(ABS_TARGET, 'utf8');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Restore original prompt content if a test changed it
|
||||
if (originalPromptContent !== null) {
|
||||
try {
|
||||
const current = await fsp.readFile(ABS_TARGET, 'utf8');
|
||||
if (current !== originalPromptContent) {
|
||||
await fsp.writeFile(ABS_TARGET, originalPromptContent);
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Remove test proposals written during tests
|
||||
for (const id of createdProposalIds) {
|
||||
const dir = path.join(PROPOSALS_DIR, id);
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Remove test archives written during tests (keep real ones, only delete test-* prefixed)
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET));
|
||||
try {
|
||||
const files = await fsp.readdir(archiveDir);
|
||||
for (const f of files) {
|
||||
if (f.startsWith('__test__')) {
|
||||
fs.rmSync(path.join(archiveDir, f), { force: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-proposal-create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-proposal-create: createProposal writes meta.json, new.md, change.diff', async () => {
|
||||
// Given: a valid target path and new content
|
||||
const newContent = originalPromptContent + '\n<!-- test marker -->';
|
||||
const agent = '__test-agent__';
|
||||
|
||||
// When: a proposal is created
|
||||
const { id } = await createProposal({
|
||||
agent,
|
||||
target: TEST_TARGET,
|
||||
newContent,
|
||||
reason: 'Test proposal for SCN-proposal-create',
|
||||
});
|
||||
|
||||
createdProposalIds.push(id);
|
||||
|
||||
// Then: the proposal directory contains the required files
|
||||
const proposalDir = path.join(PROPOSALS_DIR, id);
|
||||
assert.ok(fs.existsSync(path.join(proposalDir, 'meta.json')), 'meta.json exists');
|
||||
assert.ok(fs.existsSync(path.join(proposalDir, 'new.md')), 'new.md exists');
|
||||
assert.ok(fs.existsSync(path.join(proposalDir, 'change.diff')), 'change.diff exists');
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(proposalDir, 'meta.json'), 'utf8'));
|
||||
assert.equal(meta.author, agent, 'author recorded');
|
||||
assert.equal(meta.target, TEST_TARGET, 'target recorded');
|
||||
assert.equal(meta.status, 'pending', 'initial status is pending');
|
||||
assert.equal(meta.reason, 'Test proposal for SCN-proposal-create', 'reason recorded');
|
||||
|
||||
const savedContent = fs.readFileSync(path.join(proposalDir, 'new.md'), 'utf8');
|
||||
assert.equal(savedContent, newContent, 'new.md contains proposed content');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-proposal-list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-proposal-list: listProposals returns created proposal; status filter works', async () => {
|
||||
// Given: at least one pending proposal exists (created in previous test)
|
||||
// When: listing all proposals
|
||||
const all = await listProposals();
|
||||
assert.ok(Array.isArray(all), 'listProposals returns an array');
|
||||
assert.ok(all.length >= 1, 'at least one proposal returned');
|
||||
|
||||
const testProposals = all.filter(p => p.author === '__test-agent__');
|
||||
assert.ok(testProposals.length >= 1, 'test proposal is in the list');
|
||||
assert.equal(testProposals[0].status, 'pending', 'status is pending');
|
||||
|
||||
// When: filtering by status that returns nothing
|
||||
const rejected = all.filter(p => p.status === 'rejected' && p.author === '__test-agent__');
|
||||
assert.equal(rejected.length, 0, 'no rejected proposals from our test agent yet');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-proposal-reject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-proposal-reject: rejectProposal marks proposal with rejected status and reason', async () => {
|
||||
// Given: an existing pending proposal
|
||||
const newContent = originalPromptContent + '\n<!-- reject-test -->';
|
||||
const { id } = await createProposal({
|
||||
agent: '__test-agent__',
|
||||
target: TEST_TARGET,
|
||||
newContent,
|
||||
reason: 'Will be rejected',
|
||||
});
|
||||
createdProposalIds.push(id);
|
||||
|
||||
// When: the proposal is rejected
|
||||
const rejectionReason = 'Does not meet EXIT CRITERIA standard';
|
||||
await rejectProposal(id, rejectionReason);
|
||||
|
||||
// Then: the meta.json reflects rejected status
|
||||
const proposal = await getProposal(id);
|
||||
assert.equal(proposal.meta.status, 'rejected', 'status is rejected');
|
||||
assert.equal(proposal.meta.rejectionReason, rejectionReason, 'rejection reason recorded');
|
||||
assert.ok(proposal.meta.rejectedAt, 'rejectedAt timestamp set');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-archive-naming: applyProposal creates <base>_<ts>_<hash>.md + .meta.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-archive-naming: applyProposal archives with timestamp_hash filename and sidecar', async () => {
|
||||
// Given: a valid pending proposal for a real prompt file
|
||||
const newContent = originalPromptContent.replace(
|
||||
/^(# )/m,
|
||||
'# [VERSIONING TEST MARKER] '
|
||||
);
|
||||
// Ensure newContent still passes contract validation
|
||||
const hasContract = /Shared contract \(required\):/i.test(newContent) &&
|
||||
/Required startup \+ artifacts \+ memory \+ issue capture/i.test(newContent);
|
||||
if (!hasContract) {
|
||||
// Skip: the prompt doesn't have required headers; not a governance test failure
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = await createProposal({
|
||||
agent: '__test-agent__',
|
||||
target: TEST_TARGET,
|
||||
newContent,
|
||||
reason: 'Archive naming test',
|
||||
});
|
||||
createdProposalIds.push(id);
|
||||
|
||||
const expectedHash = createHash('sha256').update(originalPromptContent).digest('hex');
|
||||
|
||||
// When: the proposal is applied
|
||||
await applyProposal(id);
|
||||
|
||||
// Then: find the archive whose sidecar references this specific proposal ID
|
||||
// (There may be archives from prior test runs with the same hash; we must find ours.)
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET));
|
||||
const baseName = path.basename(TEST_TARGET, '.md');
|
||||
const files = fs.readdirSync(archiveDir);
|
||||
const sidecarFiles = files.filter(f => f.startsWith(baseName + '_') && f.endsWith('.meta.json'));
|
||||
|
||||
let archiveFile = null;
|
||||
let sidecar = null;
|
||||
for (const sf of sidecarFiles) {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(path.join(archiveDir, sf), 'utf8'));
|
||||
if (s.proposalId === id) {
|
||||
sidecar = s;
|
||||
archiveFile = sf.replace('.meta.json', '.md');
|
||||
break;
|
||||
}
|
||||
} catch { /* skip unreadable sidecars */ }
|
||||
}
|
||||
|
||||
assert.ok(archiveFile, `archive sidecar found for proposal ${id}`);
|
||||
assert.ok(fs.existsSync(path.join(archiveDir, archiveFile)), 'archive .md file exists');
|
||||
|
||||
// Filename must contain a timestamp segment (not just hash)
|
||||
const withoutPrefix = archiveFile.slice(baseName.length + 1, -3); // strip base_ and .md
|
||||
const parts = withoutPrefix.split('_');
|
||||
assert.ok(parts.length >= 2, 'filename has timestamp + hash parts');
|
||||
|
||||
assert.equal(sidecar.proposalId, id, 'sidecar references the proposal ID');
|
||||
assert.equal(sidecar.author, '__test-agent__', 'sidecar records author');
|
||||
assert.equal(sidecar.hash, expectedHash, 'sidecar records SHA-256 hash');
|
||||
assert.ok(sidecar.archivedAt, 'sidecar records archivedAt timestamp');
|
||||
|
||||
// Applied content is the new content
|
||||
const applied = fs.readFileSync(ABS_TARGET, 'utf8');
|
||||
assert.equal(applied, newContent, 'new content was written to target file');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-version-list: listPromptVersions returns metadata sorted newest first
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-version-list: listPromptVersions returns versions newest-first with metadata', async () => {
|
||||
// Given: two manually created archive files with different timestamps
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET));
|
||||
await fsp.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const baseName = '__test__versioning-probe';
|
||||
const ts1 = '2026-01-10T00-00-00-000Z';
|
||||
const ts2 = '2026-01-20T00-00-00-000Z';
|
||||
const hash1 = 'aaa000';
|
||||
const hash2 = 'bbb111';
|
||||
|
||||
const file1 = `${baseName}_${ts1}_${hash1}.md`;
|
||||
const file2 = `${baseName}_${ts2}_${hash2}.md`;
|
||||
|
||||
fs.writeFileSync(path.join(archiveDir, file1), 'old version');
|
||||
fs.writeFileSync(path.join(archiveDir, file2), 'newer version');
|
||||
fs.writeFileSync(
|
||||
path.join(archiveDir, file2.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ proposalId: 'test-prop', author: 'test-agent', reason: 'v2', hash: hash2, archivedAt: '2026-01-20T00:00:00.000Z' })
|
||||
);
|
||||
|
||||
// When: listing versions for the probe target
|
||||
const probeTarget = `src/prompts/daily/${baseName}.md`;
|
||||
const versions = await listPromptVersions(probeTarget);
|
||||
|
||||
// Then: sorted newest first (ts2 > ts1)
|
||||
assert.ok(versions.length >= 2, 'at least 2 versions returned');
|
||||
assert.equal(versions[0].filename, file2, 'newest version is first');
|
||||
assert.equal(versions[1].filename, file1, 'older version is second');
|
||||
|
||||
// Metadata from sidecar is surfaced
|
||||
assert.equal(versions[0].author, 'test-agent', 'author from sidecar');
|
||||
assert.equal(versions[0].reason, 'v2', 'reason from sidecar');
|
||||
assert.equal(versions[0].hash, hash2, 'hash from sidecar');
|
||||
|
||||
// Version without sidecar still has hash extracted from filename
|
||||
assert.ok(versions[1].hash, 'hash extracted from filename for legacy entry');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-rollback-latest
|
||||
// NOTE: Uses a dedicated probe target (not an actual prompt) so there is no
|
||||
// interference from archives created by other tests or the scheduler.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Probe target: a path inside the allowed subpath structure but with a name
|
||||
// that will not conflict with real prompts. rollbackPrompt only needs to
|
||||
// write to this path; the file is cleaned up in after().
|
||||
const ROLLBACK_PROBE_TARGET = 'src/prompts/daily/__rollback-probe__.md';
|
||||
const ABS_ROLLBACK_PROBE = path.join(CWD, ROLLBACK_PROBE_TARGET);
|
||||
|
||||
test('SCN-rollback-latest: rollbackPrompt restores the most recently archived content', async () => {
|
||||
// Given: two manually created archive files for the probe target; newest is distinctive
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(ROLLBACK_PROBE_TARGET));
|
||||
await fsp.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const baseName = path.basename(ROLLBACK_PROBE_TARGET, '.md');
|
||||
const olderTs = '2025-06-01T00-00-00-000Z';
|
||||
const newerTs = '2025-12-01T00-00-00-000Z';
|
||||
const olderHash = '__older-hash-scn-latest__';
|
||||
const newerHash = '__newer-hash-scn-latest__';
|
||||
const olderContent = '<!-- SCN-rollback-latest: older -->';
|
||||
const newerContent = '<!-- SCN-rollback-latest: newer -->';
|
||||
|
||||
const olderFile = `${baseName}_${olderTs}_${olderHash}.md`;
|
||||
const newerFile = `${baseName}_${newerTs}_${newerHash}.md`;
|
||||
|
||||
await fsp.writeFile(path.join(archiveDir, olderFile), olderContent);
|
||||
await fsp.writeFile(path.join(archiveDir, olderFile.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ archivedAt: '2025-06-01T00:00:00.000Z', hash: olderHash }));
|
||||
await fsp.writeFile(path.join(archiveDir, newerFile), newerContent);
|
||||
await fsp.writeFile(path.join(archiveDir, newerFile.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ archivedAt: '2025-12-01T00:00:00.000Z', hash: newerHash }));
|
||||
|
||||
try {
|
||||
// When: rolling back with 'latest' strategy
|
||||
const result = await rollbackPrompt(ROLLBACK_PROBE_TARGET, 'latest');
|
||||
|
||||
// Then: target file contains the newer archive's content
|
||||
assert.ok(result.success, 'rollback reported success');
|
||||
assert.equal(result.source, 'archive', 'restored from archive, not git');
|
||||
assert.equal(result.restored, newerFile, 'restored from newer archive');
|
||||
|
||||
const restoredContent = await fsp.readFile(ABS_ROLLBACK_PROBE, 'utf8');
|
||||
assert.equal(restoredContent, newerContent, 'target file contains newer archived content');
|
||||
} finally {
|
||||
// Cleanup probe artifacts
|
||||
for (const f of [olderFile, olderFile.replace('.md', '.meta.json'),
|
||||
newerFile, newerFile.replace('.md', '.meta.json')]) {
|
||||
try { await fsp.unlink(path.join(archiveDir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
try { await fsp.unlink(ABS_ROLLBACK_PROBE); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-rollback-hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-rollback-hash: rollbackPrompt --strategy <fragment> restores matching archive', async () => {
|
||||
// Given: one archive with a distinctive hash fragment for the probe target
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(ROLLBACK_PROBE_TARGET));
|
||||
await fsp.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const baseName = path.basename(ROLLBACK_PROBE_TARGET, '.md');
|
||||
const specificHash = 'cafecafe1234scn';
|
||||
const specificContent = '<!-- SCN-rollback-hash: specific content -->';
|
||||
const specificTs = '2025-09-15T00-00-00-000Z';
|
||||
const specificFile = `${baseName}_${specificTs}_${specificHash}.md`;
|
||||
|
||||
await fsp.writeFile(path.join(archiveDir, specificFile), specificContent);
|
||||
await fsp.writeFile(path.join(archiveDir, specificFile.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ archivedAt: '2025-09-15T00:00:00.000Z', hash: specificHash }));
|
||||
|
||||
try {
|
||||
// When: rolling back with the specific hash fragment
|
||||
const result = await rollbackPrompt(ROLLBACK_PROBE_TARGET, 'cafecafe1234scn');
|
||||
|
||||
// Then: the correct archive was selected
|
||||
assert.ok(result.success, 'rollback succeeded');
|
||||
assert.equal(result.source, 'archive', 'restored from archive');
|
||||
assert.equal(result.restored, specificFile, 'correct archive file selected');
|
||||
|
||||
const restoredContent = await fsp.readFile(ABS_ROLLBACK_PROBE, 'utf8');
|
||||
assert.equal(restoredContent, specificContent, 'content matches the specific archive');
|
||||
} finally {
|
||||
for (const f of [specificFile, specificFile.replace('.md', '.meta.json')]) {
|
||||
try { await fsp.unlink(path.join(archiveDir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
try { await fsp.unlink(ABS_ROLLBACK_PROBE); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user