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
+168 -5
View File
@@ -331,6 +331,26 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
db()
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
.run(nowIso(), betaId);
// Mark the version test-failed unless another beta for the same version
// still gates it as tested/promoted. Skip if the version is the currently-
// promoted one — that's authoritative, not a failure signal.
const stillOk = db()
.prepare(
`SELECT 1 FROM betas
WHERE script_version_id = ?
AND id <> ?
AND state IN ('ready-promote','promoted')
LIMIT 1`
)
.get(beta.script_version_id, betaId);
const isCurrent = script.current_version_id === beta.script_version_id;
if (!stillOk && !isCurrent) {
db()
.prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`)
.run(beta.script_version_id);
}
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
return { betaId, restored, dropped, warnings };
@@ -342,6 +362,12 @@ export interface PromoteResult {
betaId: number;
landed: string[]; // servers we wrote the promoted target to
versionId: number;
deploymentId: string;
}
function promoteDeploymentId(betaId: number): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
return `dep-${stamp}-promote-${betaId}`;
}
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
@@ -355,15 +381,86 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
const bfile = betaFile(script.target_rel_path);
const ofile = oldFile(script.target_rel_path);
const betaServers = listBetaServers(betaId).map((r) => r.server);
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
// For scoped scripts, promotion applies to every matching server (whether
// or not they were in the beta). For unscoped, just the beta servers.
const scopeServers = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value)
: [];
const allTargets = Array.from(new Set([...betaServers, ...scopeServers]));
const allTargets = Array.from(new Set([...betaServerSet, ...scopeServers]));
// Snapshot what's on-disk at every target BEFORE we write, so the promote
// deployment can be rolled back later. For beta servers the real "before"
// state is the .OLD file (or nothing), not the current -beta.gsc.
interface Snapshot {
server: string;
priorVersionId: number | null;
priorSha: string | null;
priorSize: number | null;
priorContent: Buffer | null;
hadFile: boolean;
}
const snapshots: Snapshot[] = [];
for (const name of allTargets) {
const dest = destOf(name);
if (betaServerSet.has(name)) {
// Prior state is captured in the beta_servers row + .OLD file.
const bs = db()
.prepare(`SELECT had_original, prior_version_id FROM beta_servers WHERE beta_id = ? AND server = ?`)
.get(betaId, name) as { had_original: number; prior_version_id: number | null } | undefined;
if (bs?.had_original) {
const backupPath = join(dest, ofile);
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
let priorSize: number | null = null;
if (existsSync(backupPath)) {
priorContent = await readFile(backupPath);
priorSha = sha256Buffer(priorContent);
priorSize = priorContent.byteLength;
} else if (bs.prior_version_id) {
const pv = getVersion(bs.prior_version_id);
if (pv) {
priorContent = pv.content;
priorSha = pv.sha256;
priorSize = pv.size;
}
}
snapshots.push({
server: name,
priorVersionId: bs.prior_version_id,
priorSha,
priorSize,
priorContent,
hadFile: true
});
} else {
snapshots.push({ server: name, priorVersionId: null, priorSha: null, priorSize: null, priorContent: null, hadFile: false });
}
} else {
// Non-beta matching-scope server — the current on-disk target is
// its "before".
const targetPath = join(dest, script.target_rel_path);
if (existsSync(targetPath)) {
const priorContent = await readFile(targetPath);
const inv = getServerScript(name, script.target_rel_path);
snapshots.push({
server: name,
priorVersionId: inv?.script_version_id ?? null,
priorSha: sha256Buffer(priorContent),
priorSize: priorContent.byteLength,
priorContent,
hadFile: true
});
} else {
snapshots.push({ server: name, priorVersionId: null, priorSha: null, priorSize: null, priorContent: null, hadFile: false });
}
}
}
// Now write files. Failures are per-server and don't abort the promote —
// the deployment record captures who succeeded and who didn't.
const landed: string[] = [];
const failures = new Map<string, string>();
for (const name of allTargets) {
const dest = destOf(name);
const targetPath = join(dest, script.target_rel_path);
@@ -381,14 +478,74 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
removeScript(name, ofile);
}
await writeFile(targetPath, version.content);
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `promote:${betaId}`);
landed.push(name);
} catch (err) {
// Best-effort continue; error will surface via the beta detail UI.
const msg = (err as Error).message;
failures.set(name, msg);
console.error(`[promote] ${name} failed:`, err);
}
}
// Build the deployment record. Only landed servers get deployment_files
// rows — a failed write leaves the prior file on disk, so recording it as
// if it changed would corrupt any future rollback.
const depId = promoteDeploymentId(betaId);
const startedAt = nowIso();
const priorScriptVersionId = script.current_version_id;
const insertDep = db().prepare(
`INSERT INTO deployments
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
VALUES (?, 'promote', ?, 'ui', ?, ?, ?, ?)`
);
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, ?, 0, ?)`
);
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(depId, startedAt, script.id, version.id, priorScriptVersionId, betaId);
for (const snap of snapshots) {
if (failures.has(snap.server)) {
// Record the failure but no file row — nothing changed on disk.
insertDepServer.run(depId, snap.server, 0, 'failed', null, failures.get(snap.server)!);
continue;
}
const verb = snap.hadFile ? 'updated' : 'added';
insertDepFile.run(
depId,
snap.server,
verb,
script.target_rel_path,
snap.priorSha,
version.sha256,
snap.priorContent,
version.content
);
// Beta servers are already at the new bytes AND already restarted
// during the beta's own state machine. Non-beta matching servers
// got the file just now and still need to drain-and-restart, which
// the deployment sweep handles.
if (betaServerSet.has(snap.server)) {
const bsRestartedAt = db()
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
.get(betaId, snap.server) as { restarted_at: string | null } | undefined;
insertDepServer.run(depId, snap.server, 1, 'done', bsRestartedAt?.restarted_at ?? startedAt, null);
} else {
insertDepServer.run(depId, snap.server, 1, 'pending', null, null);
}
recordScript(snap.server, script.target_rel_path, version.id, version.sha256, version.size, `promote:${depId}`);
}
})();
// Update the script's canonical version and close out the beta + wipe archive.
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
@@ -397,8 +554,14 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
emit({ type: 'beta-state', betaId, state: 'promoted' });
emit({
type: 'deployment-opened',
deploymentId: depId,
action: 'promote',
servers: landed
});
return { betaId, landed, versionId: version.id };
return { betaId, landed, versionId: version.id, deploymentId: depId };
}
// ---------- helpers used by API layer ----------