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
+31
View File
@@ -41,6 +41,35 @@ function migrateServers(handle: Database.Database): void {
}
}
// Additive columns on tables the script model owns. Same pattern as
// migrateServers — CREATE TABLE IF NOT EXISTS won't backfill columns onto
// pre-existing tables, so we introspect and ALTER as needed.
const SCRIPT_VERSIONS_COLUMNS: Array<{ name: string; ddl: string }> = [
// state was added post-launch to make untested/tested/test-failed visible
// in the UI. Existing rows default to 'untested' — this is safe because
// any real promoted version's script has current_version_id set (which is
// the authoritative "promoted" signal), and any version already gated
// through a ready-promote beta will be re-marked next time that state
// machine fires.
{ name: 'state', ddl: "ALTER TABLE script_versions ADD COLUMN state TEXT NOT NULL DEFAULT 'untested'" }
];
const DEPLOYMENTS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'script_id', ddl: 'ALTER TABLE deployments ADD COLUMN script_id INTEGER REFERENCES scripts(id)' },
{ name: 'script_version_id', ddl: 'ALTER TABLE deployments ADD COLUMN script_version_id INTEGER REFERENCES script_versions(id)' },
{ name: 'prior_version_id', ddl: 'ALTER TABLE deployments ADD COLUMN prior_version_id INTEGER REFERENCES script_versions(id)' },
{ name: 'beta_id', ddl: 'ALTER TABLE deployments ADD COLUMN beta_id INTEGER REFERENCES betas(id)' },
{ name: 'rolled_back_by', ddl: 'ALTER TABLE deployments ADD COLUMN rolled_back_by TEXT REFERENCES deployments(id)' }
];
function migrateTable(handle: Database.Database, table: string, cols: Array<{ name: string; ddl: string }>): void {
const rows = handle.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
const existing = new Set(rows.map((r) => r.name));
for (const col of cols) {
if (!existing.has(col.name)) handle.exec(col.ddl);
}
}
// The pre-script-model beta tables had entirely different shapes (slug PK,
// beta_scripts, beta_deploys). schema.sql defines the new shape; CREATE TABLE
// IF NOT EXISTS is a no-op if the old tables already exist, so we have to
@@ -84,6 +113,8 @@ export function db(): Database.Database {
dropLegacyBetaTables(_db);
_db.exec(schemaSql);
migrateServers(_db);
migrateTable(_db, 'script_versions', SCRIPT_VERSIONS_COLUMNS);
migrateTable(_db, 'deployments', DEPLOYMENTS_COLUMNS);
return _db;
}
+8 -2
View File
@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS script_versions (
size INTEGER NOT NULL,
content BLOB NOT NULL,
notes TEXT,
state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed'
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(script_id, version)
);
@@ -97,10 +98,15 @@ CREATE INDEX IF NOT EXISTS idx_beta_servers_stage ON beta_servers(stage);
CREATE TABLE IF NOT EXISTS deployments (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
action TEXT NOT NULL, -- 'drift' | 'promote' | 'rollback'
started_at TEXT NOT NULL,
finished_at TEXT,
triggered_by TEXT
triggered_by TEXT,
script_id INTEGER REFERENCES scripts(id), -- promote/rollback only
script_version_id INTEGER REFERENCES script_versions(id), -- the version this deployment lands / restores
prior_version_id INTEGER REFERENCES script_versions(id), -- current_version_id before this promote
beta_id INTEGER REFERENCES betas(id), -- promote only
rolled_back_by TEXT REFERENCES deployments(id) -- set on the promote row when a rollback reverses it
);
CREATE INDEX IF NOT EXISTS idx_deployments_started ON deployments(started_at DESC);
+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 ----------
+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
};
}
+30
View File
@@ -13,6 +13,8 @@ export interface ScriptRow {
created_at: string;
}
export type ScriptVersionState = 'untested' | 'tested' | 'test-failed';
export interface ScriptVersionRow {
id: number;
script_id: number;
@@ -21,6 +23,7 @@ export interface ScriptVersionRow {
size: number;
content: Buffer;
notes: string | null;
state: ScriptVersionState;
uploaded_at: string;
}
@@ -233,3 +236,30 @@ export function serversMatchingScope(scopeType: ScopeType | null, scopeValue: st
.all(scopeValue) as Array<{ name: string }>;
return rows.map((r) => r.name);
}
// Distinct game IDs present in the registered fleet. Powers the scope=game
// dropdown so operators can only pick values that will match at least one
// server.
export function listDistinctGames(): string[] {
const rows = db()
.prepare(`SELECT DISTINCT game FROM servers WHERE game IS NOT NULL AND game <> '' ORDER BY game`)
.all() as Array<{ game: string }>;
return rows.map((r) => r.game);
}
// Distinct maps grouped by their game. Scope=map is chained (pick game → pick
// map) so the UI can filter map options to those valid under the selected game.
export function listMapsByGame(): Record<string, string[]> {
const rows = db()
.prepare(
`SELECT DISTINCT game, map FROM servers
WHERE game IS NOT NULL AND game <> '' AND map IS NOT NULL AND map <> ''
ORDER BY game, map`
)
.all() as Array<{ game: string; map: string }>;
const out: Record<string, string[]> = {};
for (const r of rows) {
(out[r.game] ??= []).push(r.map);
}
return out;
}
+15 -1
View File
@@ -66,9 +66,23 @@ function reconcileBetaState(betaId: number): void {
.all(betaId) as Array<{ stage: BetaStage }>;
if (rows.length === 0) return;
if (rows.every((r) => r.stage === 'ready')) {
db()
const upd = db()
.prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ? AND state = 'running'`)
.run(betaId);
if (upd.changes > 0) {
// The version that just cleared its beta gate is now considered
// tested. This survives promote (tested → promoted is derived from
// scripts.current_version_id, not stored) and reject (rollbackBeta
// demotes back to test-failed).
const beta = db()
.prepare(`SELECT script_version_id FROM betas WHERE id = ?`)
.get(betaId) as { script_version_id: number } | undefined;
if (beta) {
db()
.prepare(`UPDATE script_versions SET state = 'tested' WHERE id = ?`)
.run(beta.script_version_id);
}
}
emit({ type: 'beta-state', betaId, state: 'ready-promote' });
}
}
+17 -3
View File
@@ -6,9 +6,15 @@ export async function GET({ url }: { url: URL }) {
const deployments = db()
.prepare(
`SELECT id, action, started_at, finished_at, triggered_by
FROM deployments
ORDER BY started_at DESC
`SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by,
d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by,
s.name AS script_name, sv.version AS script_version,
pv.version AS prior_version
FROM deployments d
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
LEFT JOIN script_versions pv ON pv.id = d.prior_version_id
ORDER BY d.started_at DESC
LIMIT 500`
)
.all() as Array<{
@@ -17,6 +23,14 @@ export async function GET({ url }: { url: URL }) {
started_at: string;
finished_at: string | null;
triggered_by: string | null;
script_id: number | null;
script_version_id: number | null;
prior_version_id: number | null;
beta_id: number | null;
rolled_back_by: string | null;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}>;
const serversByDep = db()
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { rollbackPromoteDeployment, RollbackError } from '$lib/server/fleet/rollback.js';
export async function POST({ params }: { params: { id: string } }) {
try {
const result = await rollbackPromoteDeployment(params.id);
return json({ ok: true, result });
} catch (err) {
if (err instanceof RollbackError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
}
throw err;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { listDistinctGames, listMapsByGame } from '$lib/server/fleet/scripts.js';
// Powers the script-upload scope dropdowns. Options are always live from the
// fleet — no caching. This is a cheap two-query call and the servers list
// changes infrequently.
export async function GET() {
return json({
games: listDistinctGames(),
mapsByGame: listMapsByGame()
});
}
+1
View File
@@ -32,6 +32,7 @@ export async function GET() {
version: versions[0].version,
sha256: versions[0].sha256,
size: versions[0].size,
state: versions[0].state,
uploaded_at: versions[0].uploaded_at
}
: null,
+1
View File
@@ -26,6 +26,7 @@ export async function GET({ params }: { params: { id: string } }) {
sha256: v.sha256,
size: v.size,
notes: v.notes,
state: v.state,
uploaded_at: v.uploaded_at
}));
const matched = script.scope_type && script.scope_value
+12 -2
View File
@@ -75,7 +75,11 @@
} finally { acting = null; }
}
async function rollback(): Promise<void> {
if (!confirm('Rollback this beta?')) return;
const isReject = beta?.state === 'ready-promote';
const prompt = isReject
? `Reject beta #${id}? This restores original files on every beta server and marks the version as test-failed.`
: `Rollback beta #${id}? This restores original files on every beta server.`;
if (!confirm(prompt)) return;
acting = 'rollback';
flash = null;
try {
@@ -171,7 +175,13 @@
onclick={rollback}
disabled={beta.state === 'promoted' || beta.state === 'rolled-back' || acting !== null}
>
{acting === 'rollback' ? 'rolling back…' : 'rollback'}
{#if acting === 'rollback'}
{beta.state === 'ready-promote' ? 'rejecting…' : 'rolling back…'}
{:else if beta.state === 'ready-promote'}
reject
{:else}
rollback
{/if}
</button>
</div>
</section>
+63 -1
View File
@@ -15,6 +15,14 @@
finished_at: string | null;
triggered_by: string | null;
servers: ServerRow[];
script_id: number | null;
script_version_id: number | null;
prior_version_id: number | null;
beta_id: number | null;
rolled_back_by: string | null;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}
interface DriftFile { relPath: string; size: number; sha256: string }
@@ -26,6 +34,7 @@
let drift = $state<DriftPreview | null>(null);
let scanning = $state(false);
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
async function loadDeployments(): Promise<void> {
@@ -91,6 +100,25 @@
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
async function rollbackPromote(d: Deployment, ev: Event): Promise<void> {
ev.preventDefault();
ev.stopPropagation();
const priorLabel = d.prior_version === null ? 'nothing (undo the first promotion)' : `v${d.prior_version}`;
const msg = `Rollback ${d.script_name ?? 'this script'} v${d.script_version ?? '?'} on ${d.servers.length} server(s)? Reverts to ${priorLabel}.`;
if (!confirm(msg)) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/deployments/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = `rollback deployment opened`;
await loadDeployments();
} finally {
rollingBack = null;
}
}
</script>
<h1>Deployments</h1>
@@ -153,9 +181,26 @@
{#snippet deployRow(d: Deployment)}
<a class="row" href="/deployments/{d.id}">
<div class="head">
<div class="head-l">
<span class="action">{d.action}</span>
{#if d.action === 'promote' && d.script_name}
<span class="target mono">{d.script_name} v{d.script_version}</span>
{:else if d.action === 'rollback' && d.script_name}
<span class="target mono">{d.script_name} → v{d.script_version ?? '—'}</span>
{/if}
{#if d.rolled_back_by}
<span class="tag rb">rolled back</span>
{/if}
</div>
<div class="head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<button class="rb-btn" onclick={(e) => rollbackPromote(d, e)} disabled={rollingBack === d.id}>
{rollingBack === d.id ? 'rolling back…' : 'rollback'}
</button>
{/if}
<span class="when">{ago(d.started_at)}</span>
</div>
</div>
<div class="chips">
{#each d.servers as s (s.server)}
<span class={chipClass(s.restart_state)} title={s.error ?? ''}>
@@ -197,9 +242,26 @@
transition: border-color 0.15s, box-shadow 0.15s;
}
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.head { display: flex; justify-content: space-between; margin-bottom: 10px; align-items: baseline; }
.head { display: flex; justify-content: space-between; margin-bottom: 10px; align-items: center; gap: 12px; }
.head-l { display: flex; gap: 10px; align-items: baseline; }
.head-r { display: flex; gap: 10px; align-items: center; }
.action { font-family: var(--font-mono); font-size: var(--text-md); color: var(--cream); }
.target { color: var(--spark-500); font-size: var(--text-mono); }
.when { color: var(--ink-500); font-size: var(--text-mono); font-family: var(--font-mono); }
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); border: 1px solid transparent; }
.tag.rb { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
.rb-btn {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
color: var(--danger-500);
border-radius: var(--r-sm);
padding: 6px 12px;
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
}
.rb-btn:hover:not(:disabled) { border-color: var(--danger-500); }
.rb-btn:disabled { opacity: 0.5; cursor: default; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
padding: 3px 9px;
+153 -11
View File
@@ -13,6 +13,7 @@
version: number;
sha256: string;
size: number;
state: 'untested' | 'tested' | 'test-failed';
uploaded_at: string;
} | null;
versionCount: number;
@@ -20,22 +21,69 @@
runningBetaId: number | null;
}
interface ScopeOptions {
games: string[];
mapsByGame: Record<string, string[]>;
}
let scripts = $state<ScriptRow[]>([]);
let loading = $state(true);
let flash = $state<string | null>(null);
let uploading = $state(false);
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
let successBanner = $state<{ name: string; scriptId: number; version: number } | null>(null);
// Form state for creating a new script.
let newName = $state('');
let newScopeType = $state<'none' | 'game' | 'map'>('none');
let newScopeValue = $state('');
let newScopeGame = $state('');
let newScopeMap = $state('');
let newFile = $state<File | null>(null);
let newNotes = $state('');
// The scope value we send to the API — computed from the two selects so
// scope=map ships the map (game is just a filter narrowing the map list).
const scopeValue = $derived.by(() => {
if (newScopeType === 'game') return newScopeGame;
if (newScopeType === 'map') return newScopeMap;
return '';
});
// Live preview: which servers would this script land on? Empty when the
// scope matches nothing OR when scope=none (unscoped scripts only push to
// explicitly-chosen beta targets, so there's no promotion audience yet).
const matchingServers = $derived.by(() => {
if (newScopeType === 'none') return null;
if (!scopeValue) return null;
// scope-options carries fleet distincts, but not the (server → game/map)
// mapping. We derive the count by checking every server in `scripts`
// list responses — instead, use the /api/servers list. For simplicity
// we call a scoped preview against the API on the fly. Cheaper: reuse
// scope options — but we don't have server names. Fetch servers once.
return matchingServersFromCache();
});
let serverCache = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
function matchingServersFromCache(): string[] {
return serverCache
.filter((s) => {
const field = newScopeType === 'game' ? s.game : s.map;
return field === scopeValue;
})
.map((s) => s.name);
}
async function load(): Promise<void> {
const res = await fetch('/api/scripts');
const body = (await res.json()) as { scripts: ScriptRow[] };
const [scriptsRes, scopeRes, serversRes] = await Promise.all([
fetch('/api/scripts'),
fetch('/api/scope-options'),
fetch('/api/servers')
]);
const body = (await scriptsRes.json()) as { scripts: ScriptRow[] };
scripts = body.scripts;
scopeOptions = (await scopeRes.json()) as ScopeOptions;
const sBody = (await serversRes.json()) as { servers: Array<{ name: string; game: string | null; map: string | null }> };
serverCache = sBody.servers;
loading = false;
}
@@ -52,24 +100,38 @@
async function createScript(e: Event): Promise<void> {
e.preventDefault();
if (uploading || !newFile || !newName.trim()) return;
// Guard: scope was set but no value picked yet.
if (newScopeType !== 'none' && !scopeValue) {
flash = `pick a ${newScopeType}`;
return;
}
uploading = true;
flash = null;
const form = new FormData();
form.append('name', newName.trim());
form.append('scopeType', newScopeType);
if (newScopeValue.trim()) form.append('scopeValue', newScopeValue.trim());
if (scopeValue) form.append('scopeValue', scopeValue);
if (newNotes.trim()) form.append('notes', newNotes.trim());
form.append('content', newFile);
try {
const res = await fetch('/api/scripts', { method: 'POST', body: form });
const body = (await res.json()) as { ok: boolean; error?: string };
const body = (await res.json()) as {
ok: boolean;
error?: string;
script?: { id: number; name: string };
versionId?: number;
};
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
successBanner = body.script
? { name: body.script.name, scriptId: body.script.id, version: 1 }
: null;
newName = '';
newScopeType = 'none';
newScopeValue = '';
newScopeGame = '';
newScopeMap = '';
newNotes = '';
newFile = null;
await load();
@@ -82,12 +144,32 @@
if (!s.scope_type) return 'unscoped';
return `${s.scope_type}=${s.scope_value ?? '?'}`;
}
// What state tag to show on a script row. Distinguishes "never promoted"
// (whole script is untested) from "promoted but has a newer untested
// version" (existing production is fine, but there's unreleased work).
function scriptStateTag(s: ScriptRow): { label: string; cls: string } | null {
if (!s.latestVersion) return null;
if (s.current_version_id === null) return { label: 'untested', cls: 'state-untested' };
if (s.latestVersion.id !== s.current_version_id) {
if (s.latestVersion.state === 'untested') return { label: `v${s.latestVersion.version} untested`, cls: 'state-untested' };
if (s.latestVersion.state === 'tested') return { label: `v${s.latestVersion.version} tested`, cls: 'state-tested' };
if (s.latestVersion.state === 'test-failed') return { label: `v${s.latestVersion.version} test-failed`, cls: 'state-test-failed' };
}
return null;
}
</script>
<h1>Scripts</h1>
<section class="panel">
<h2>Upload a script</h2>
{#if successBanner}
<div class="banner banner-info">
<strong>v{successBanner.version} uploaded — untested.</strong>
<span>Open a beta on <a href="/scripts/{successBanner.scriptId}">{successBanner.name}</a> to test it before it can be promoted.</span>
</div>
{/if}
<form onsubmit={createScript} class="upload-form">
<label>
<span class="k">name</span>
@@ -101,10 +183,36 @@
<option value="game">game</option>
<option value="map">map</option>
</select>
{#if newScopeType !== 'none'}
<input type="text" bind:value={newScopeValue} placeholder={newScopeType === 'game' ? 't5' : 'zombie_theater'} disabled={uploading} />
{#if newScopeType === 'game'}
<select bind:value={newScopeGame} disabled={uploading}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
{:else if newScopeType === 'map'}
<select bind:value={newScopeGame} disabled={uploading}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
<select bind:value={newScopeMap} disabled={uploading || !newScopeGame}>
<option value="">— pick a map —</option>
{#each (scopeOptions.mapsByGame[newScopeGame] ?? []) as m (m)}
<option value={m}>{m}</option>
{/each}
</select>
{/if}
</div>
{#if newScopeType !== 'none' && scopeValue}
{@const matches = matchingServers ?? []}
{#if matches.length === 0}
<p class="preview warn">⚠ no matching servers in the fleet — the promoted script will land nowhere</p>
{:else}
<p class="preview ok">{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}</p>
{/if}
{/if}
</label>
<label>
<span class="k">notes (optional)</span>
@@ -134,6 +242,10 @@
<div class="row-top">
<span class="script-name">{s.name}</span>
<span class="scope tag scope-{s.scope_type ?? 'none'}">{scopeLabel(s)}</span>
{#if scriptStateTag(s)}
{@const t = scriptStateTag(s)!}
<span class="tag {t.cls}">{t.label}</span>
{/if}
{#if s.runningBetaId}
<span class="tag beta">beta {s.runningBetaId} running</span>
{/if}
@@ -193,9 +305,8 @@
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
.scope-row { display: flex; gap: 8px; }
.scope-row select { flex: 0 0 120px; }
.scope-row input { flex: 1; }
.scope-row { display: flex; gap: 8px; flex-wrap: wrap; }
.scope-row select:nth-of-type(1) { flex: 0 0 120px; }
.primary {
grid-column: 1 / -1;
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
@@ -238,4 +349,35 @@
.scope-game { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
.scope-map { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.35); color: var(--amber-500); }
.beta { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.3); color: var(--amber-500); }
.state-untested { background: var(--bg-nav); border-color: var(--border); color: var(--ink-300); }
.state-tested { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
.state-test-failed { background: rgba(255, 92, 92, 0.10); border-color: rgba(255, 92, 92, 0.4); color: var(--danger-500); }
.banner {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 4px;
border-radius: var(--r-sm);
padding: 12px 14px;
margin-bottom: 12px;
font-size: var(--text-mono);
}
.banner-info {
background: rgba(124, 212, 255, 0.08);
border: 1px solid rgba(124, 212, 255, 0.28);
color: var(--ink-100);
}
.banner-info a { color: var(--spark-500); text-decoration: underline; }
.preview {
grid-column: 1 / -1;
margin: 4px 0 0;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.preview.ok { color: var(--ink-300); }
.preview.warn { color: var(--amber-500); }
.scope-row select:nth-of-type(2), .scope-row select:nth-of-type(3) { flex: 1; }
</style>
+86 -11
View File
@@ -18,9 +18,11 @@
sha256: string;
size: number;
notes: string | null;
state: 'untested' | 'tested' | 'test-failed';
uploaded_at: string;
}
interface ServerRow { name: string; game: string | null; map: string | null }
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]> }
let script = $state<Script | null>(null);
let versions = $state<Version[]>([]);
@@ -37,8 +39,16 @@
// Scope form.
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeValue = $state('');
let scopeGame = $state('');
let scopeMap = $state('');
let updatingScope = $state(false);
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
const editedScopeValue = $derived.by(() => {
if (scopeType === 'game') return scopeGame;
if (scopeType === 'map') return scopeMap;
return '';
});
// Open-beta form.
let betaVersionId = $state<number | null>(null);
@@ -50,9 +60,10 @@
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
async function load(): Promise<void> {
const [detailRes, serversRes] = await Promise.all([
const [detailRes, serversRes, scopeRes] = await Promise.all([
fetch(`/api/scripts/${id}`),
fetch('/api/servers')
fetch('/api/servers'),
fetch('/api/scope-options')
]);
if (!detailRes.ok) { loading = false; return; }
const body = (await detailRes.json()) as {
@@ -66,12 +77,30 @@
matchedServers = body.matchedServers;
runningBetaId = body.runningBetaId;
scopeType = script.scope_type ?? 'none';
scopeValue = script.scope_value ?? '';
// Seed the two selects from the current scope. For scope=map we need
// to figure out which game the map belongs to so the two selects show
// consistent state; use the first matched server as ground truth.
if (scopeType === 'game') {
scopeGame = script.scope_value ?? '';
scopeMap = '';
} else if (scopeType === 'map') {
scopeMap = script.scope_value ?? '';
scopeGame = '';
}
if (versions[0]) betaVersionId = versions[0].id;
if (serversRes.ok) {
const s = (await serversRes.json()) as { servers: ServerRow[] };
servers = s.servers;
// Now that we know the fleet, backfill scopeGame for scope=map so
// the game select shows the right option.
if (scopeType === 'map' && scopeMap) {
const owner = servers.find((sv) => sv.map === scopeMap);
if (owner?.game) scopeGame = owner.game;
}
}
if (scopeRes.ok) {
scopeOptions = (await scopeRes.json()) as ScopeOptions;
}
loading = false;
}
@@ -110,6 +139,10 @@
async function saveScope(): Promise<void> {
if (updatingScope) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `pick a ${scopeType}`;
return;
}
updatingScope = true;
flash = null;
try {
@@ -118,7 +151,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scopeType: scopeType === 'none' ? null : scopeType,
scopeValue: scopeType === 'none' ? null : scopeValue.trim()
scopeValue: scopeType === 'none' ? null : editedScopeValue
})
});
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
@@ -218,8 +251,26 @@
<option value="game">game</option>
<option value="map">map</option>
</select>
{#if scopeType !== 'none'}
<input type="text" bind:value={scopeValue} placeholder={scopeType === 'game' ? 't5' : 'zombie_theater'} disabled={updatingScope} />
{#if scopeType === 'game'}
<select bind:value={scopeGame} disabled={updatingScope}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
{:else if scopeType === 'map'}
<select bind:value={scopeGame} disabled={updatingScope}>
<option value="">— pick a game —</option>
{#each scopeOptions.games as g (g)}
<option value={g}>{g}</option>
{/each}
</select>
<select bind:value={scopeMap} disabled={updatingScope || !scopeGame}>
<option value="">— pick a map —</option>
{#each (scopeOptions.mapsByGame[scopeGame] ?? []) as m (m)}
<option value={m}>{m}</option>
{/each}
</select>
{/if}
<button onclick={saveScope} disabled={updatingScope}>save scope</button>
</div>
@@ -243,6 +294,15 @@
</form>
</section>
{#if script.current_version_id === null && versions.length > 0}
<section class="panel banner-info">
<p>
<strong>This script has never been promoted.</strong>
Open a beta below to test v{versions[0].version} before it can be promoted to matching servers.
</p>
</section>
{/if}
<section class="panel">
<h2>Open beta with a version</h2>
{#if runningBetaId}
@@ -297,7 +357,15 @@
<span class="v-size mono">{v.size}B</span>
<span class="v-notes">{v.notes ?? ''}</span>
<span class="v-when mono">{v.uploaded_at}</span>
{#if script.current_version_id === v.id}<span class="tag cur">current</span>{/if}
{#if script.current_version_id === v.id}
<span class="tag cur">promoted</span>
{:else if v.state === 'tested'}
<span class="tag state-tested">tested</span>
{:else if v.state === 'test-failed'}
<span class="tag state-test-failed">test-failed</span>
{:else}
<span class="tag state-untested">untested</span>
{/if}
</div>
{/each}
</div>
@@ -326,7 +394,7 @@
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
.scope-form { display: flex; gap: 10px; align-items: center; }
.scope-form select, .scope-form input, .version-form input, .beta-form select {
.scope-form select, .version-form input, .beta-form select {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
@@ -374,8 +442,15 @@
.v-num { color: var(--cream); font-weight: 600; }
.v-sha, .v-size, .v-when { color: var(--ink-500); }
.v-notes { color: var(--ink-300); font-family: var(--font-body); font-size: var(--text-sm); }
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); }
.tag.cur { background: rgba(124, 212, 255, 0.15); border: 1px solid rgba(124, 212, 255, 0.4); color: var(--spark-500); }
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); border: 1px solid transparent; }
.tag.cur { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
.tag.state-untested { background: var(--bg-nav); border-color: var(--border); color: var(--ink-300); }
.tag.state-tested { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
.tag.state-test-failed { background: rgba(255, 92, 92, 0.10); border-color: rgba(255, 92, 92, 0.4); color: var(--danger-500); }
.banner-info { background: rgba(124, 212, 255, 0.06); border-color: rgba(124, 212, 255, 0.28); }
.banner-info p { margin: 0; color: var(--ink-100); font-family: var(--font-body); font-size: var(--text-sm); }
.banner-info strong { color: var(--cream); }
.danger {
background: var(--bg-nav);
+201
View File
@@ -0,0 +1,201 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
// Fresh module registry per test — otherwise the db() singleton leaks across
// fixtures and hits a released sqlite handle.
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(['t5_kino_1', 't5_ascension_1']);
delete process.env.KOMODO_ADDRESS;
delete process.env.RCON_PASSWORD;
});
afterEach(async () => {
await fixture.cleanup();
});
async function seedFleet(): Promise<void> {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
}
describe('scope options', () => {
it('lists distinct games and maps by game from the fleet', async () => {
await seedFleet();
const { listDistinctGames, listMapsByGame } = await import('../../src/lib/server/fleet/scripts.js');
// Both fixture servers are t5, so games is a single-entry list.
expect(listDistinctGames()).toEqual(['t5']);
const byGame = listMapsByGame();
expect(byGame.t5).toEqual(expect.arrayContaining(['zombie_theater', 'zombie_cosmodrome']));
expect(byGame.t5.length).toBe(2);
});
});
describe('promote creates a deployment', () => {
it('records deployment_files for every landed server and marks beta servers done, others pending', async () => {
await seedFleet();
const { createScript, getScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
// The reconcileBetaState only fires from the beta-runner; short-circuit
// that in the test by flipping beta state manually.
// A game-scoped script matches both fixture servers.
const { script } = createScript({
name: 'hud tweaks',
targetRelPath: 'hud.gsc',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('main(){}\n')
});
// Open the beta on ONE server; promotion should still write to the
// other server (via scope) and mark it pending for restart.
const { betaId } = await openBeta({
scriptId: script.id,
scriptVersionId: script.current_version_id ?? (await import('../../src/lib/server/fleet/scripts.js')).getLatestVersion(script.id)!.id,
servers: ['t5_kino_1'],
waitForHealth: false,
waitForMatch: false,
triggerKind: 'mark-required'
});
// Force all beta_servers to 'ready' and beta to 'ready-promote'.
db().prepare(`UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?`)
.run(new Date().toISOString(), betaId);
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
const result = await promoteBeta(betaId);
expect(result.deploymentId).toMatch(/^dep-.*-promote-\d+$/);
expect(new Set(result.landed)).toEqual(new Set(['t5_kino_1', 't5_ascension_1']));
// Deployment row is action='promote' with the new columns populated.
const dep = db().prepare(`SELECT * FROM deployments WHERE id = ?`).get(result.deploymentId) as any;
expect(dep.action).toBe('promote');
expect(dep.script_id).toBe(script.id);
expect(dep.beta_id).toBe(betaId);
expect(dep.prior_version_id).toBeNull(); // never promoted before
// deployment_files has one row per landed server.
const files = db().prepare(`SELECT * FROM deployment_files WHERE deployment_id = ?`).all(result.deploymentId) as any[];
expect(files.length).toBe(2);
for (const f of files) {
expect(f.verb).toBe('added');
expect(f.rel_path).toBe('hud.gsc');
expect(f.sha256_after).toBeTruthy();
}
// Beta server row is 'done' (beta already restarted it); non-beta scope
// server row is 'pending' (sweep will drain-and-restart).
const servers = db()
.prepare(`SELECT * FROM deployment_servers WHERE deployment_id = ? ORDER BY server`)
.all(result.deploymentId) as any[];
const byName = Object.fromEntries(servers.map((s) => [s.server, s.restart_state]));
expect(byName['t5_kino_1']).toBe('done');
expect(byName['t5_ascension_1']).toBe('pending');
// Script's current_version_id and beta state land.
const after = getScript(script.id)!;
expect(after.current_version_id).toBeGreaterThan(0);
const beta = db().prepare(`SELECT state FROM betas WHERE id = ?`).get(betaId) as any;
expect(beta.state).toBe('promoted');
});
});
describe('rollback beta on ready-promote marks version test-failed', () => {
it('flips script_versions.state to test-failed when the version has no other promoting beta', async () => {
await seedFleet();
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
name: 'flag experiment',
targetRelPath: 'flag.gsc',
scopeType: 'map',
scopeValue: 'zombie_theater',
content: Buffer.from('main(){}\n')
});
const v = getLatestVersion(script.id)!;
const { betaId } = await openBeta({
scriptId: script.id,
scriptVersionId: v.id,
servers: ['t5_kino_1'],
waitForHealth: false,
waitForMatch: false,
triggerKind: 'mark-required'
});
// Force ready-promote so the state machine gate is closed.
db().prepare(`UPDATE beta_servers SET stage = 'ready' WHERE beta_id = ?`).run(betaId);
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
// Simulate the beta-runner's tested-state transition.
db().prepare(`UPDATE script_versions SET state = 'tested' WHERE id = ?`).run(v.id);
await rollbackBeta(betaId);
const after = db().prepare(`SELECT state FROM script_versions WHERE id = ?`).get(v.id) as any;
expect(after.state).toBe('test-failed');
});
});
describe('rollback promote deployment', () => {
it('restores current_version_id + files and creates an action=rollback deployment with pending servers', async () => {
await seedFleet();
const { createScript, getScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
name: 'roll me back',
targetRelPath: 'rb.gsc',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('main(){}\n')
});
const v = getLatestVersion(script.id)!;
const { betaId } = await openBeta({
scriptId: script.id,
scriptVersionId: v.id,
servers: ['t5_kino_1'],
waitForHealth: false,
waitForMatch: false,
triggerKind: 'mark-required'
});
db().prepare(`UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?`)
.run(new Date().toISOString(), betaId);
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
const promote = await promoteBeta(betaId);
// Sanity: script is promoted.
expect(getScript(script.id)!.current_version_id).toBe(v.id);
const result = await rollbackPromoteDeployment(promote.deploymentId);
expect(result.deploymentId).toMatch(/^dep-.*-rollback-/);
expect(new Set(result.affectedServers)).toEqual(new Set(['t5_kino_1', 't5_ascension_1']));
// Script's current_version_id reverted (was null before the promote).
expect(getScript(script.id)!.current_version_id).toBeNull();
// Original promote is now marked rolled_back_by.
const promoteRow = db().prepare(`SELECT rolled_back_by FROM deployments WHERE id = ?`).get(promote.deploymentId) as any;
expect(promoteRow.rolled_back_by).toBe(result.deploymentId);
// Rollback deployment_servers rows are pending.
const rbServers = db()
.prepare(`SELECT restart_state FROM deployment_servers WHERE deployment_id = ?`)
.all(result.deploymentId) as any[];
expect(rbServers.length).toBe(2);
for (const s of rbServers) expect(s.restart_state).toBe('pending');
// Second rollback attempt is rejected.
await expect(rollbackPromoteDeployment(promote.deploymentId)).rejects.toMatchObject({ kind: 'already-rolled-back' });
});
});