Promote as a real deployment; version state and scope dropdowns

Turns promotion into a first-class deployment (action='promote') so it lands
in the deployments UI with before/after content, per-server restart tracking,
and one-click rollback. Non-beta matching-scope servers get restart_state=
pending so the existing sweep drains and restarts them the same way drift does.

Also adds a per-version state (untested/tested/test-failed) so the upload
flow can visibly signal that a beta must land before promotion, and swaps
the scope-value text box for game/map dropdowns populated from the fleet.
Beta rollback at ready-promote is now labelled Reject and marks the version
test-failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 14:24:35 -04:00
co-authored by Claude Opus 4.7
parent 3f934fd579
commit 1afdfddc87
16 changed files with 1014 additions and 41 deletions
+197
View File
@@ -0,0 +1,197 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { destOf } from '../config.js';
import { db } from '../db/index.js';
import { emit } from '../pipeline/bus.js';
import { recordScript, removeScript } from './inventory.js';
import { getVersion } from './scripts.js';
// Roll a promoted script back to whatever version was current before the
// promote landed. Uses the deployment_files snapshots already captured at
// promote time — no on-disk archaeology needed.
//
// Semantics:
// - Reverses only the file writes recorded in deployment_files. Servers that
// failed at promote time (no file row) are left alone.
// - Restores scripts.current_version_id to the promote row's prior_version_id.
// - Marks the original promote as rolled_back_by = <new deployment id>.
// - Records a fresh action='rollback' deployment with pending server rows so
// the sweep drains-and-restarts every affected server. Beta servers that
// were still running the promoted bytes get restarted the same as any
// other; the beta itself is not touched (it stays in state='promoted').
export class RollbackError extends Error {
constructor(message: string, public readonly kind: 'no-such-deployment' | 'not-promote' | 'already-rolled-back') {
super(message);
}
}
interface DeploymentRow {
id: string;
action: string;
script_id: number | null;
script_version_id: number | null;
prior_version_id: number | null;
rolled_back_by: string | null;
}
interface FileRow {
id: number;
server: string;
verb: 'added' | 'updated' | 'removed';
rel_path: string;
sha256_before: string | null;
sha256_after: string | null;
content_before: Buffer | null;
content_after: Buffer | null;
}
function nowIso(): string {
return new Date().toISOString();
}
function rollbackDeploymentId(promoteId: string): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
return `dep-${stamp}-rollback-${promoteId}`;
}
export interface RollbackDeploymentResult {
deploymentId: string; // the new rollback deployment
reverses: string; // the promote deployment id
scriptId: number | null;
priorVersionId: number | null;
affectedServers: string[];
}
export async function rollbackPromoteDeployment(promoteId: string): Promise<RollbackDeploymentResult> {
const dep = db()
.prepare(`SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by
FROM deployments WHERE id = ?`)
.get(promoteId) as DeploymentRow | undefined;
if (!dep) throw new RollbackError(`no such deployment ${promoteId}`, 'no-such-deployment');
if (dep.action !== 'promote') throw new RollbackError(`deployment ${promoteId} is action='${dep.action}', not 'promote'`, 'not-promote');
if (dep.rolled_back_by) throw new RollbackError(`deployment ${promoteId} already rolled back by ${dep.rolled_back_by}`, 'already-rolled-back');
const fileRows = db()
.prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after
FROM deployment_files WHERE deployment_id = ? ORDER BY server, rel_path`
)
.all(promoteId) as FileRow[];
const newDepId = rollbackDeploymentId(promoteId);
const startedAt = nowIso();
// Group by server so each server gets exactly one deployment_servers row.
const perServer = new Map<string, FileRow[]>();
for (const f of fileRows) {
const arr = perServer.get(f.server);
if (arr) arr.push(f);
else perServer.set(f.server, [f]);
}
const affectedServers: string[] = [];
const perServerFailures = new Map<string, string>();
for (const [server, files] of perServer) {
const dest = destOf(server);
try {
for (const f of files) {
const abs = join(dest, f.rel_path);
await mkdir(join(dest, dirname(f.rel_path)), { recursive: true });
if (f.verb === 'added') {
// Promote created this file — undo by removing it.
if (existsSync(abs)) await rm(abs, { force: true });
removeScript(server, f.rel_path);
} else if (f.verb === 'updated' && f.content_before) {
await writeFile(abs, f.content_before);
// Restore inventory to the prior version if we can identify it.
const priorVer = dep.prior_version_id ? getVersion(dep.prior_version_id) : null;
recordScript(
server,
f.rel_path,
priorVer?.id ?? null,
f.sha256_before!,
f.content_before.byteLength,
`rollback:${newDepId}`
);
} else if (f.verb === 'removed' && f.content_before) {
// Should not occur on a promote row, but handle symmetrically.
await writeFile(abs, f.content_before);
recordScript(server, f.rel_path, null, f.sha256_before!, f.content_before.byteLength, `rollback:${newDepId}`);
}
}
affectedServers.push(server);
} catch (err) {
perServerFailures.set(server, (err as Error).message);
}
}
// Write the new rollback deployment + mark the original.
const insertDep = db().prepare(
`INSERT INTO deployments
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
VALUES (?, 'rollback', ?, 'ui', ?, ?, ?, NULL)`
);
const insertDepServer = db().prepare(
`INSERT INTO deployment_servers
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
VALUES (?, ?, ?, ?, NULL, NULL, NULL, 0, ?)`
);
// Mirror the promote's deployment_files into the rollback row so it's
// browsable as a diff (before/after swapped).
const insertDepFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
db().transaction(() => {
insertDep.run(
newDepId,
startedAt,
dep.script_id,
dep.prior_version_id, // this rollback lands the *prior* version, so that's now current
dep.script_version_id, // what was in place immediately before this rollback
);
for (const [server, files] of perServer) {
if (perServerFailures.has(server)) {
insertDepServer.run(newDepId, server, 0, 'failed', perServerFailures.get(server)!);
continue;
}
insertDepServer.run(newDepId, server, files.length, 'pending', null);
for (const f of files) {
const invertedVerb =
f.verb === 'added' ? 'removed'
: f.verb === 'removed' ? 'added'
: 'updated';
insertDepFile.run(
newDepId,
server,
invertedVerb,
f.rel_path,
f.sha256_after,
f.sha256_before,
f.content_after,
f.content_before
);
}
}
db().prepare(`UPDATE deployments SET rolled_back_by = ? WHERE id = ?`).run(newDepId, promoteId);
// Restore the script's current version pointer.
if (dep.script_id !== null) {
db().prepare(`UPDATE scripts SET current_version_id = ? WHERE id = ?`).run(dep.prior_version_id, dep.script_id);
}
})();
emit({ type: 'deployment-opened', deploymentId: newDepId, action: 'rollback', servers: affectedServers });
return {
deploymentId: newDepId,
reverses: promoteId,
scriptId: dep.script_id,
priorVersionId: dep.prior_version_id,
affectedServers
};
}