Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,8 @@ npm run push -- <org> --strict # Abort push if any validator
npm run apply -- <org> # Pull then push (full sync)
npm run validate -- <org> # Lint resources locally (fails fast on schema drift)
npm run sim -- <org> --suite <name> --target <name> # Run a simulation suite against an assistant/squad
npm run rollback -- <org> --to <ISO-timestamp> # Re-apply a snapshot taken before a push
npm run rollback -- <org> --list # List available snapshots

# Testing
npm run call -- <org> -a <assistant-name> # Call an assistant via WebSocket
Expand Down
2 changes: 1 addition & 1 deletion improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ you which stack PR closes the row.**
| --- | -------------------------------------------------------- | -------------------------------------------------- | ---------- | --------------------------------- |
| 1 | `push` drift detection | Prevent silent overwrites of dashboard edits | #4 | RESOLVED 2026-04-30 (Stack G) |
| 2 | `apply` same-file conflict | `apply` drops concurrent same-file dashboard edits | #4 | Partial — Stack G GET on push |
| 3 | Rollback | Current undo can clobber newer live changes | #4, #5 | Open (Stack H planned) |
| 3 | Rollback | Current undo can clobber newer live changes | #4, #5 | RESOLVED 2026-04-30 (Stack H) |
| 4 | State schema content hashes | Architectural unlock for #1, #2, #3, #6, #7 | None | RESOLVED 2026-04-30 (Stack F) |
| 5 | `push --dry-run` | Cheapest operator-safety win | None | RESOLVED 2026-04-30 (Stack C) |
| 6 | API-level optimistic concurrency | Server-side conflict rejection | Platform | Deferred (Stack I, gated) |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"eval": "tsx src/eval.ts",
"validate": "tsx src/validate-cmd.ts",
"sim": "tsx src/sim-cmd.ts",
"rollback": "tsx src/rollback-cmd.ts",
"build": "tsc --noEmit",
"test": "node --import tsx --test tests/*.test.ts"
},
Expand Down
57 changes: 57 additions & 0 deletions src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ import {
} from "./config.ts";
import { summarizeFindings, validateResources } from "./validate.ts";
import { checkDriftForUpdate } from "./drift.ts";
import { writeSnapshot } from "./snapshot.ts";

// Map a resource label to its state-file key. Used for snapshotting (Stack H)
// — snapshot directories are keyed by the same names the state file uses.
const RESOURCE_LABEL_TO_TYPE: Record<string, ResourceType> = {
tool: "tools",
"structured output": "structuredOutputs",
assistant: "assistants",
squad: "squads",
personality: "personalities",
scenario: "scenarios",
simulation: "simulations",
"simulation suite": "simulationSuites",
};
import {
hashPayload,
loadState,
Expand Down Expand Up @@ -88,6 +102,8 @@ async function upsertResourceWithStateRecovery(options: {
// payload, hash it, and compare to lastPulledHash. Refuse to overwrite
// without --overwrite. Skipped in dry-run because the operator just
// wants to see what would happen, and skipped if no baseline hash.
// Stack H — when we successfully fetch the platform payload, snapshot
// it (and our outgoing payload) so `npm run rollback` has a target.
if (!DRY_RUN) {
const stateEntry = stateSection[resourceId];
if (stateEntry) {
Expand All @@ -113,6 +129,47 @@ async function upsertResourceWithStateRecovery(options: {
". Continuing.",
);
}

// Snapshot the current platform payload + our outgoing payload to a
// per-push directory so rollback can revert. Costs one extra GET per
// resource — acceptable for the safety guarantee. (Follow-up: plumb
// drift's GET result through to avoid the duplicate fetch.)
try {
const resourceType = RESOURCE_LABEL_TO_TYPE[resourceLabel];
if (resourceType) {
const platformResponse = await fetch(
`${VAPI_BASE_URL}${updateEndpoint}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.VAPI_TOKEN}`,
},
},
);
if (platformResponse.ok) {
const platformPayloadForSnapshot = await platformResponse.json();
await writeSnapshot({
baseDir: BASE_DIR,
env: VAPI_ENV,
resourceType,
resourceId,
payload: {
outgoing: updatePayload,
platform: platformPayloadForSnapshot,
},
});
}
}
} catch (snapshotErr) {
// Snapshot failures should NOT block the push — the snapshot is a
// safety net, not a precondition. Log and move on.
console.warn(
` ⚠️ snapshot failed for ${resourceLabel} ${resourceId}: ` +
(snapshotErr instanceof Error
? snapshotErr.message
: String(snapshotErr)),
);
}
}
}

Expand Down
212 changes: 212 additions & 0 deletions src/rollback-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// CLI entry: `npm run rollback -- <org> --to <ISO-timestamp>` |
// `npm run rollback -- <org> --list`
//
// Reads .vapi-state.<env>.snapshots/<timestamp>/<resource-type>/<id>.json
// and re-applies the captured *platform* payload via PATCH, restoring the
// dashboard to its state at that snapshot moment.
//
// Self-contained (does not import config.ts) so it can run in isolation
// without triggering the global CLI parser.

import { existsSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import {
listSnapshotTimestamps,
loadSnapshot,
} from "./snapshot.ts";

const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE_DIR = join(__dirname, "..");

interface RollbackEnv {
env: string;
token: string;
baseUrl: string;
}

function loadEnvFile(env: string): RollbackEnv {
const envFiles = [
join(BASE_DIR, `.env.${env}`),
join(BASE_DIR, `.env.${env}.local`),
join(BASE_DIR, ".env.local"),
];
const envVars: Record<string, string> = {};
for (const envFile of envFiles) {
if (!existsSync(envFile)) continue;
for (const line of readFileSync(envFile, "utf-8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (envVars[key] === undefined) envVars[key] = value;
}
}
const token = process.env.VAPI_TOKEN || envVars.VAPI_TOKEN;
const baseUrl =
process.env.VAPI_BASE_URL ||
envVars.VAPI_BASE_URL ||
"https://api.vapi.ai";
if (!token) {
console.error(`❌ VAPI_TOKEN not found. Create .env.${env} with VAPI_TOKEN=your-token`);
process.exit(1);
}
return { env, token, baseUrl };
}

function printUsage(): void {
console.error(
[
"Usage:",
" npm run rollback -- <org> --list",
" npm run rollback -- <org> --to <ISO-timestamp>",
"",
"Snapshots are written automatically before each `npm run push` operation",
"to .vapi-state.<env>.snapshots/<timestamp>/. Use --list to inspect available",
"timestamps; use --to <ts> to re-apply the platform payloads from that snapshot.",
].join("\n"),
);
}

const ENDPOINT_MAP: Record<string, string> = {
tools: "/tool",
structuredOutputs: "/structured-output",
assistants: "/assistant",
squads: "/squad",
personalities: "/eval/simulation/personality",
scenarios: "/eval/simulation/scenario",
simulations: "/eval/simulation",
simulationSuites: "/eval/simulation/suite",
evals: "/eval",
};

interface ParsedArgs {
env: string;
list: boolean;
to?: string;
}

function parseArgs(): ParsedArgs {
const args = process.argv.slice(2);
const env = args[0];
if (!env) {
printUsage();
process.exit(1);
}
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
if (!SLUG_RE.test(env)) {
console.error(`❌ Invalid org name: ${env}`);
process.exit(1);
}
const parsed: ParsedArgs = { env, list: false };
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === "--list") parsed.list = true;
else if (a === "--to") parsed.to = args[++i];
else if (a === "--help" || a === "-h") {
printUsage();
process.exit(0);
}
}
if (!parsed.list && !parsed.to) {
console.error("❌ Specify --list or --to <timestamp>");
printUsage();
process.exit(1);
}
return parsed;
}

async function main(): Promise<void> {
const args = parseArgs();

if (args.list) {
const timestamps = await listSnapshotTimestamps(BASE_DIR, args.env);
if (timestamps.length === 0) {
console.log(`No snapshots found for ${args.env}.`);
return;
}
console.log(`Snapshots for ${args.env}:`);
for (const t of timestamps) console.log(` ${t}`);
return;
}

const cfg = loadEnvFile(args.env);
const entries = await loadSnapshot(BASE_DIR, args.env, args.to!);
if (entries.length === 0) {
console.log("Snapshot directory exists but contains no resources.");
return;
}

// We need state so we can resolve resourceId → UUID for the PATCH path.
// Snapshot files don't store the UUID directly because the snapshot is
// keyed by resourceId; the same resourceId points at the same UUID across
// pushes (unless renamed, in which case the snapshot is stale anyway).
const stateFile = join(BASE_DIR, `.vapi-state.${args.env}.json`);
if (!existsSync(stateFile)) {
console.error(`❌ State file not found: ${stateFile}`);
process.exit(1);
}
const state = JSON.parse(readFileSync(stateFile, "utf-8")) as Record<
string,
Record<string, { uuid: string }>
>;

console.log(`🔁 Rollback ${args.env} → snapshot ${args.to}`);
console.log(` ${entries.length} resource(s) to restore\n`);

let restored = 0;
let skipped = 0;
for (const entry of entries) {
const endpoint = ENDPOINT_MAP[entry.resourceType];
if (!endpoint) {
console.warn(` ⚠️ Unknown resource type: ${entry.resourceType}, skipping`);
skipped++;
continue;
}
const section = state[entry.resourceType];
const uuid = section?.[entry.resourceId]?.uuid;
if (!uuid) {
console.warn(
` ⚠️ No UUID in state for ${entry.resourceType}/${entry.resourceId} — skipping`,
);
skipped++;
continue;
}
process.stdout.write(` 🔁 ${entry.resourceType}/${entry.resourceId} ... `);
const response = await fetch(`${cfg.baseUrl}${endpoint}/${uuid}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${cfg.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(entry.payload.platform),
});
if (!response.ok) {
const text = await response.text();
console.log(`❌ ${response.status}`);
console.error(` ${text}`);
skipped++;
continue;
}
console.log("✅");
restored++;
}

console.log(
`\n📊 Rollback summary: ${restored} restored, ${skipped} skipped`,
);
if (skipped > 0) process.exit(1);
}

main().catch((error) => {
console.error("\n❌ Rollback failed:", error instanceof Error ? error.message : error);
process.exit(1);
});
Loading