From 1afdfddc87a84d1924db10a1d3600b613add9279 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 14:24:35 -0400 Subject: [PATCH] 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 --- src/lib/server/db/index.ts | 31 +++ src/lib/server/db/schema.sql | 16 +- src/lib/server/fleet/beta.ts | 173 ++++++++++++++- src/lib/server/fleet/rollback.ts | 197 +++++++++++++++++ src/lib/server/fleet/scripts.ts | 30 +++ src/lib/server/pipeline/beta-runner.ts | 16 +- src/routes/api/deployments/+server.ts | 20 +- .../api/deployments/[id]/rollback/+server.ts | 14 ++ src/routes/api/scope-options/+server.ts | 12 ++ src/routes/api/scripts/+server.ts | 1 + src/routes/api/scripts/[id]/+server.ts | 1 + src/routes/betas/[id]/+page.svelte | 14 +- src/routes/deployments/+page.svelte | 68 +++++- src/routes/scripts/+page.svelte | 164 +++++++++++++- src/routes/scripts/[id]/+page.svelte | 97 ++++++++- tests/unit/script-flow.test.ts | 201 ++++++++++++++++++ 16 files changed, 1014 insertions(+), 41 deletions(-) create mode 100644 src/lib/server/fleet/rollback.ts create mode 100644 src/routes/api/deployments/[id]/rollback/+server.ts create mode 100644 src/routes/api/scope-options/+server.ts create mode 100644 tests/unit/script-flow.test.ts diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index c4ffa40..3577255 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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; } diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index b055e57..ebf2c29 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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) ); @@ -96,11 +97,16 @@ CREATE TABLE IF NOT EXISTS beta_servers ( 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, - started_at TEXT NOT NULL, - finished_at TEXT, - triggered_by TEXT + id TEXT PRIMARY KEY, + action TEXT NOT NULL, -- 'drift' | 'promote' | 'rollback' + started_at TEXT NOT NULL, + finished_at 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); diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index 6de7aeb..fd07511 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -331,6 +331,26 @@ export async function rollbackBeta(betaId: number): Promise { 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 { @@ -355,15 +381,86 @@ export async function promoteBeta(betaId: number): Promise { 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(); 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 { 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 { 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 ---------- diff --git a/src/lib/server/fleet/rollback.ts b/src/lib/server/fleet/rollback.ts new file mode 100644 index 0000000..d451739 --- /dev/null +++ b/src/lib/server/fleet/rollback.ts @@ -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 = . +// - 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 { + 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(); + 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(); + + 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 + }; +} diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index 831da00..9abe641 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -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 { + 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 = {}; + for (const r of rows) { + (out[r.game] ??= []).push(r.map); + } + return out; +} diff --git a/src/lib/server/pipeline/beta-runner.ts b/src/lib/server/pipeline/beta-runner.ts index 867135e..747a44e 100644 --- a/src/lib/server/pipeline/beta-runner.ts +++ b/src/lib/server/pipeline/beta-runner.ts @@ -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' }); } } diff --git a/src/routes/api/deployments/+server.ts b/src/routes/api/deployments/+server.ts index 911dac4..b19d1bb 100644 --- a/src/routes/api/deployments/+server.ts +++ b/src/routes/api/deployments/+server.ts @@ -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() diff --git a/src/routes/api/deployments/[id]/rollback/+server.ts b/src/routes/api/deployments/[id]/rollback/+server.ts new file mode 100644 index 0000000..f687ca2 --- /dev/null +++ b/src/routes/api/deployments/[id]/rollback/+server.ts @@ -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; + } +} diff --git a/src/routes/api/scope-options/+server.ts b/src/routes/api/scope-options/+server.ts new file mode 100644 index 0000000..b5f3a9b --- /dev/null +++ b/src/routes/api/scope-options/+server.ts @@ -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() + }); +} diff --git a/src/routes/api/scripts/+server.ts b/src/routes/api/scripts/+server.ts index ddb2eb8..db0db0e 100644 --- a/src/routes/api/scripts/+server.ts +++ b/src/routes/api/scripts/+server.ts @@ -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, diff --git a/src/routes/api/scripts/[id]/+server.ts b/src/routes/api/scripts/[id]/+server.ts index 93887a8..5f76ca0 100644 --- a/src/routes/api/scripts/[id]/+server.ts +++ b/src/routes/api/scripts/[id]/+server.ts @@ -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 diff --git a/src/routes/betas/[id]/+page.svelte b/src/routes/betas/[id]/+page.svelte index 1dc55f2..3824c2e 100644 --- a/src/routes/betas/[id]/+page.svelte +++ b/src/routes/betas/[id]/+page.svelte @@ -75,7 +75,11 @@ } finally { acting = null; } } async function rollback(): Promise { - 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} diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index e5ba745..8d6c548 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -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(null); let scanning = $state(false); let running = $state(false); + let rollingBack = $state(null); let flash = $state(null); async function loadDeployments(): Promise { @@ -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 { + 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; + } + }

Deployments

@@ -153,8 +181,25 @@ {#snippet deployRow(d: Deployment)}
- {d.action} - {ago(d.started_at)} +
+ {d.action} + {#if d.action === 'promote' && d.script_name} + {d.script_name} v{d.script_version} + {:else if d.action === 'rollback' && d.script_name} + {d.script_name} → v{d.script_version ?? '—'} + {/if} + {#if d.rolled_back_by} + rolled back + {/if} +
+
+ {#if d.action === 'promote' && !d.rolled_back_by} + + {/if} + {ago(d.started_at)} +
{#each d.servers as s (s.server)} @@ -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; diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte index 66223d2..ba56386 100644 --- a/src/routes/scripts/+page.svelte +++ b/src/routes/scripts/+page.svelte @@ -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; + } + let scripts = $state([]); let loading = $state(true); let flash = $state(null); let uploading = $state(false); + let scopeOptions = $state({ 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(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>([]); + 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 { - 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 { 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; + }

Scripts

Upload a script

+ {#if successBanner} +
+ {/if}
+ {#if newScopeType !== 'none' && scopeValue} + {@const matches = matchingServers ?? []} + {#if matches.length === 0} +

⚠ no matching servers in the fleet — the promoted script will land nowhere

+ {:else} +

{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}

+ {/if} + {/if}