From 85bcb05a7d385a1781fef6fc96ce011667533946 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 18:23:56 -0400 Subject: [PATCH] multi script beta --- src/lib/server/db/index.ts | 34 ++ src/lib/server/db/schema.sql | 45 +- src/lib/server/fleet/beta.ts | 671 ++++++++++++--------- src/lib/server/fleet/rollback.ts | 117 ++-- src/lib/server/fleet/scripts.ts | 12 +- src/lib/server/fleet/servers.ts | 18 +- src/lib/server/pipeline/beta-runner.ts | 19 +- src/lib/server/pipeline/bus.ts | 2 +- src/routes/api/betas/+server.ts | 50 +- src/routes/api/betas/[id]/+server.ts | 18 +- src/routes/api/deployments/+server.ts | 29 +- src/routes/api/deployments/[id]/+server.ts | 16 +- src/routes/betas/+page.svelte | 311 ++++++---- src/routes/betas/[id]/+page.svelte | 61 +- src/routes/deployments/+page.svelte | 31 +- tests/unit/script-flow.test.ts | 135 ++++- 16 files changed, 1053 insertions(+), 516 deletions(-) diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 3577255..0a9dc63 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -70,6 +70,39 @@ function migrateTable(handle: Database.Database, table: string, cols: Array<{ na } } +// Backfill the multi-script join tables from the legacy single-script columns. +// Idempotent — every INSERT is guarded by a NOT EXISTS clause on the target +// row's primary key. Safe to run on every boot even after the deprecated +// columns are eventually dropped. +function backfillMultiScriptJoins(handle: Database.Database): void { + handle.exec( + `INSERT INTO beta_scripts (beta_id, script_id, script_version_id) + SELECT b.id, b.script_id, b.script_version_id + FROM betas b + WHERE b.script_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM beta_scripts bs WHERE bs.beta_id = b.id)` + ); + handle.exec( + `INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id) + SELECT bs.beta_id, bs.server, b.script_id, bs.had_original, bs.prior_version_id + FROM beta_servers bs + JOIN betas b ON b.id = bs.beta_id + WHERE b.script_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM beta_server_scripts x + WHERE x.beta_id = bs.beta_id AND x.server = bs.server AND x.script_id = b.script_id + )` + ); + handle.exec( + `INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id) + SELECT d.id, d.script_id, d.script_version_id, d.prior_version_id + FROM deployments d + WHERE d.script_id IS NOT NULL + AND d.script_version_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM deployment_scripts ds WHERE ds.deployment_id = d.id)` + ); +} + // 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 @@ -115,6 +148,7 @@ export function db(): Database.Database { migrateServers(_db); migrateTable(_db, 'script_versions', SCRIPT_VERSIONS_COLUMNS); migrateTable(_db, 'deployments', DEPLOYMENTS_COLUMNS); + backfillMultiScriptJoins(_db); return _db; } diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index ebf2c29..2f0a71b 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -67,9 +67,12 @@ CREATE INDEX IF NOT EXISTS idx_server_scripts_server ON server_scripts(server); CREATE TABLE IF NOT EXISTS betas ( id INTEGER PRIMARY KEY AUTOINCREMENT, + -- DEPRECATED: multi-script betas are represented by beta_scripts. These + -- three columns are populated from the first bundled script to satisfy + -- NOT NULL on legacy schemas; the fleet code doesn't read them anymore. script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, - trigger_kind TEXT NOT NULL, -- 'mark-required' | 'version-update' | 'unscoped' + trigger_kind TEXT NOT NULL, wait_for_health INTEGER NOT NULL DEFAULT 1, wait_for_match INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL, -- 'running' | 'ready-promote' | 'promoted' | 'rolled-back' @@ -83,8 +86,8 @@ CREATE INDEX IF NOT EXISTS idx_betas_script ON betas(script_id); CREATE TABLE IF NOT EXISTS beta_servers ( beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, server TEXT NOT NULL REFERENCES servers(name), - had_original INTEGER NOT NULL, - prior_version_id INTEGER REFERENCES script_versions(id), + had_original INTEGER NOT NULL, -- deprecated; see beta_server_scripts + prior_version_id INTEGER REFERENCES script_versions(id), -- deprecated; see beta_server_scripts stage TEXT NOT NULL, -- 'deploying'|'awaiting-empty'|'restarting'|'awaiting-health'|'awaiting-match'|'ready'|'failed' error TEXT, deployed_at TEXT, @@ -96,6 +99,30 @@ CREATE TABLE IF NOT EXISTS beta_servers ( ); CREATE INDEX IF NOT EXISTS idx_beta_servers_stage ON beta_servers(stage); +-- Many-to-many: which scripts (and pinned versions) are bundled into a beta. +-- Replaces the deprecated betas.script_id / betas.script_version_id columns. +CREATE TABLE IF NOT EXISTS beta_scripts ( + beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, + script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, + script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, + PRIMARY KEY (beta_id, script_id) +); +CREATE INDEX IF NOT EXISTS idx_beta_scripts_beta ON beta_scripts(beta_id); +CREATE INDEX IF NOT EXISTS idx_beta_scripts_script ON beta_scripts(script_id); + +-- Per (beta, server, script) prior-file state. A server row in beta_servers +-- covers restart progress; this table covers what each script's target +-- looked like on that server before the beta wrote its -beta.gsc. +CREATE TABLE IF NOT EXISTS beta_server_scripts ( + beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, + server TEXT NOT NULL REFERENCES servers(name), + script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, + had_original INTEGER NOT NULL, + prior_version_id INTEGER REFERENCES script_versions(id), + PRIMARY KEY (beta_id, server, script_id) +); +CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_beta ON beta_server_scripts(beta_id); + CREATE TABLE IF NOT EXISTS deployments ( id TEXT PRIMARY KEY, action TEXT NOT NULL, -- 'drift' | 'promote' | 'rollback' @@ -140,6 +167,18 @@ CREATE TABLE IF NOT EXISTS deployment_files ( CREATE INDEX IF NOT EXISTS idx_deployment_files_dep ON deployment_files(deployment_id); +-- Per-script rows on aggregate promote/rollback deployments. For legacy +-- single-script deployments this may also be backfilled from the deprecated +-- deployments.script_id / script_version_id / prior_version_id columns. +CREATE TABLE IF NOT EXISTS deployment_scripts ( + deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE, + script_version_id INTEGER NOT NULL REFERENCES script_versions(id) ON DELETE CASCADE, + prior_version_id INTEGER REFERENCES script_versions(id), + PRIMARY KEY (deployment_id, script_id) +); +CREATE INDEX IF NOT EXISTS idx_deployment_scripts_dep ON deployment_scripts(deployment_id); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index fd07511..858f955 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -1,7 +1,7 @@ -import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, posix } from 'node:path'; -import { betaArchiveOf, config, destOf } from '../config.js'; +import { betaArchiveOf, destOf } from '../config.js'; import { db } from '../db/index.js'; import { betaFile, oldFile, sha256Buffer } from './files.js'; import { ensureDest, getServer, heldByBeta } from './servers.js'; @@ -17,13 +17,14 @@ import { recordScript, removeScript, getServerScript } from './inventory.js'; import { emit } from '../pipeline/bus.js'; import type { BetaStage } from '../pipeline/bus.js'; -export type BetaTriggerKind = 'mark-required' | 'version-update' | 'unscoped'; - export interface BetaRow { id: number; + // Deprecated: the columns are still populated (NOT NULL on legacy schemas) + // from the first bundled script's identity so existing data continues to + // roundtrip, but new code reads beta_scripts for the authoritative list. script_id: number; script_version_id: number; - trigger_kind: BetaTriggerKind; + trigger_kind: string; wait_for_health: number; wait_for_match: number; state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; @@ -35,8 +36,8 @@ export interface BetaRow { export interface BetaServerRow { beta_id: number; server: string; - had_original: number; - prior_version_id: number | null; + had_original: number; // deprecated; per-script data lives in beta_server_scripts + prior_version_id: number | null; // deprecated stage: BetaStage; error: string | null; deployed_at: string | null; @@ -46,6 +47,20 @@ export interface BetaServerRow { updated_at: string; } +export interface BetaScriptRow { + beta_id: number; + script_id: number; + script_version_id: number; +} + +export interface BetaServerScriptRow { + beta_id: number; + server: string; + script_id: number; + had_original: number; + prior_version_id: number | null; +} + export class BetaError extends Error { constructor( message: string, @@ -53,6 +68,7 @@ export class BetaError extends Error { | 'no-such-script' | 'no-such-version' | 'version-script-mismatch' + | 'no-scripts' | 'no-servers' | 'unknown-server' | 'out-of-scope' @@ -62,7 +78,9 @@ export class BetaError extends Error { | 'not-ready-promote' | 'not-running' | 'dest-fail' - | 'invalid-state', + | 'invalid-state' + | 'duplicate-script' + | 'duplicate-target', public readonly details?: unknown ) { super(message); @@ -92,79 +110,133 @@ export function listBetaServers(betaId: number): BetaServerRow[] { .all(betaId) as BetaServerRow[]; } +export function listBetaScripts(betaId: number): BetaScriptRow[] { + return db() + .prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id') + .all(betaId) as BetaScriptRow[]; +} + +export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined { + return db() + .prepare('SELECT * FROM beta_server_scripts WHERE beta_id = ? AND server = ? AND script_id = ?') + .get(betaId, server, scriptId) as BetaServerScriptRow | undefined; +} + +// A script is "in a running beta" if beta_scripts joins to a running beta row. +// Existing callers only care about the running-beta id, so return that shape. export function findRunningBetaForScript(scriptId: number): BetaRow | undefined { return db() - .prepare(`SELECT * FROM betas WHERE script_id = ? AND state = 'running' LIMIT 1`) + .prepare( + `SELECT b.* FROM betas b + JOIN beta_scripts bs ON bs.beta_id = b.id + WHERE bs.script_id = ? AND b.state = 'running' + LIMIT 1` + ) .get(scriptId) as BetaRow | undefined; } // ---------- openBeta ---------- -export interface OpenBetaInput { +export interface OpenBetaScript { scriptId: number; scriptVersionId: number; +} + +export interface OpenBetaInput { + scripts: OpenBetaScript[]; servers: string[]; waitForHealth: boolean; waitForMatch: boolean; - triggerKind: BetaTriggerKind; notes?: string; } export interface OpenBetaResult { betaId: number; servers: string[]; + scripts: OpenBetaScript[]; } export async function openBeta(input: OpenBetaInput): Promise { - const script = getScript(input.scriptId); - if (!script) throw new BetaError(`no such script (id=${input.scriptId})`, 'no-such-script'); - - const version = getVersion(input.scriptVersionId); - if (!version) throw new BetaError(`no such version (id=${input.scriptVersionId})`, 'no-such-version'); - if (version.script_id !== script.id) { - throw new BetaError('version does not belong to this script', 'version-script-mismatch'); + if (!input.scripts || input.scripts.length === 0) { + throw new BetaError('at least one script is required', 'no-scripts'); } - if (!input.servers || input.servers.length === 0) { throw new BetaError('at least one server is required', 'no-servers'); } - // Look up server rows + validate scope up front. - const rows: Array<{ name: string; game: string | null; map: string | null }> = []; + // Resolve every (script, version) pair and validate belonging. + interface Resolved { script: ScriptRow; version: ScriptVersionRow } + const resolved: Resolved[] = []; + const seenScriptIds = new Set(); + const seenTargets = new Map(); + for (const s of input.scripts) { + if (seenScriptIds.has(s.scriptId)) { + throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script'); + } + seenScriptIds.add(s.scriptId); + const script = getScript(s.scriptId); + if (!script) throw new BetaError(`no such script (id=${s.scriptId})`, 'no-such-script'); + const version = getVersion(s.scriptVersionId); + if (!version) throw new BetaError(`no such version (id=${s.scriptVersionId})`, 'no-such-version'); + if (version.script_id !== script.id) { + throw new BetaError( + `version ${s.scriptVersionId} does not belong to script ${s.scriptId}`, + 'version-script-mismatch' + ); + } + // Two scripts writing to the same target rel_path in one beta is an + // internal race — reject up front. + if (seenTargets.has(script.target_rel_path)) { + throw new BetaError( + `scripts ${seenTargets.get(script.target_rel_path)} and ${script.id} share target ${script.target_rel_path}`, + 'duplicate-target' + ); + } + seenTargets.set(script.target_rel_path, script.id); + resolved.push({ script, version }); + } + + // Look up server rows once. + const serverRows: Array<{ name: string; game: string | null; map: string | null }> = []; for (const name of input.servers) { const row = getServer(name); if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server'); - rows.push({ name, game: row.game, map: row.map }); - } - if (script.scope_type && script.scope_value) { - const outOfScope = rows.filter( - (r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value - ); - if (outOfScope.length > 0) { - throw new BetaError( - `servers not in scope ${script.scope_type}=${script.scope_value}`, - 'out-of-scope', - outOfScope.map((r) => r.name) - ); - } + serverRows.push({ name, game: row.game, map: row.map }); } - // Per-server conflict check: is another running beta already holding this - // target on this server? - const conflicts: Array<{ server: string; betaId: number }> = []; + // Per-script scope validation. Aggregate all violations into one error. + const outOfScope: Array<{ scriptId: number; scope: string; servers: string[] }> = []; + for (const { script } of resolved) { + if (!script.scope_type || !script.scope_value) continue; + const bad = serverRows.filter( + (r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value + ); + if (bad.length > 0) { + outOfScope.push({ + scriptId: script.id, + scope: `${script.scope_type}=${script.scope_value}`, + servers: bad.map((r) => r.name) + }); + } + } + if (outOfScope.length > 0) { + throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope); + } + + // Per-(server, script) conflict + stale-.OLD check. + const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = []; const blocked: Array<{ server: string; ofile: string }> = []; for (const name of input.servers) { const held = heldByBeta(name); - const ownerId = held.get(script.target_rel_path); - if (ownerId) conflicts.push({ server: name, betaId: ownerId }); - - // If the target exists on-disk and .OLD already exists, refuse — that - // .OLD is either from a crashed prior beta or manual editing. - const dest = destOf(name); - const targetPath = join(dest, script.target_rel_path); - const backupPath = join(dest, oldFile(script.target_rel_path)); - if (existsSync(targetPath) && existsSync(backupPath)) { - blocked.push({ server: name, ofile: oldFile(script.target_rel_path) }); + for (const { script } of resolved) { + const owner = held.get(script.target_rel_path); + if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner }); + const dest = destOf(name); + const targetPath = join(dest, script.target_rel_path); + const backupPath = join(dest, oldFile(script.target_rel_path)); + if (existsSync(targetPath) && existsSync(backupPath)) { + blocked.push({ server: name, ofile: oldFile(script.target_rel_path) }); + } } } if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts); @@ -176,49 +248,63 @@ export async function openBeta(input: OpenBetaInput): Promise { if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail'); } - // Insert rows in a transaction — disk work happens after commit so we can - // await file I/O without holding the DB lock. + // DB writes in one transaction. + const primary = resolved[0]; // populates deprecated betas.* NOT NULL cols const insertBeta = db().prepare( `INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes) - VALUES (?, ?, ?, ?, ?, 'running', ?)` + VALUES (?, ?, 'bundle', ?, ?, 'running', ?)` + ); + const insertBetaScript = db().prepare( + `INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)` ); const insertBetaServer = db().prepare( `INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at) - VALUES (?, ?, ?, ?, 'deploying', ?)` + VALUES (?, ?, 0, NULL, 'deploying', ?)` + ); + const insertBetaServerScript = db().prepare( + `INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id) + VALUES (?, ?, ?, ?, ?)` ); let betaId!: number; db().transaction(() => { const res = insertBeta.run( - script.id, - version.id, - input.triggerKind, + primary.script.id, + primary.version.id, input.waitForHealth ? 1 : 0, input.waitForMatch ? 1 : 0, input.notes ?? null ); betaId = Number(res.lastInsertRowid); - + for (const r of resolved) { + insertBetaScript.run(betaId, r.script.id, r.version.id); + } for (const name of input.servers) { - const prior = getServerScript(name, script.target_rel_path); - insertBetaServer.run( - betaId, - name, - prior ? 1 : 0, - prior?.script_version_id ?? null, - nowIso() - ); + insertBetaServer.run(betaId, name, nowIso()); + for (const r of resolved) { + const prior = getServerScript(name, r.script.target_rel_path); + insertBetaServerScript.run( + betaId, + name, + r.script.id, + prior ? 1 : 0, + prior?.script_version_id ?? null + ); + } } })(); - // On-disk deploy: for each server, backup target → .OLD (if any) and write - // -beta.gsc from the version's content. - const bfile = betaFile(script.target_rel_path); - const ofile = oldFile(script.target_rel_path); + // Archive: one -beta.gsc per script. const archive = betaArchiveOf(betaId); await mkdir(archive, { recursive: true }); - await writeFile(join(archive, posix.basename(bfile)), version.content); + for (const r of resolved) { + const bfile = betaFile(r.script.target_rel_path); + await writeFile(join(archive, posix.basename(bfile)), r.version.content); + } + // On-disk deploy per (server, script). If any script write fails on a + // server, mark the whole server 'failed' — we can't restart a partially + // deployed server safely. Remaining scripts on that server are skipped. const updateStage = db().prepare( `UPDATE beta_servers SET stage = ?, error = ?, deployed_at = ?, updated_at = ? @@ -227,38 +313,52 @@ export async function openBeta(input: OpenBetaInput): Promise { for (const name of input.servers) { const dest = destOf(name); - const targetPath = join(dest, script.target_rel_path); - const backupPath = join(dest, ofile); - const betaPath = join(dest, bfile); - - try { - await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true }); - if (existsSync(targetPath)) { - await rename(targetPath, backupPath); - // Move inventory row: target → .OLD (keep prior sha/version). - const prior = getServerScript(name, script.target_rel_path); - removeScript(name, script.target_rel_path); - if (prior) { - recordScript(name, ofile, prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`); + let firstErr: string | null = null; + for (const { script, version } of resolved) { + if (firstErr) break; + const bfile = betaFile(script.target_rel_path); + const ofile = oldFile(script.target_rel_path); + const targetPath = join(dest, script.target_rel_path); + const backupPath = join(dest, ofile); + const betaPath = join(dest, bfile); + try { + await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true }); + if (existsSync(targetPath)) { + await rename(targetPath, backupPath); + const prior = getServerScript(name, script.target_rel_path); + removeScript(name, script.target_rel_path); + if (prior) { + recordScript(name, ofile, prior.script_version_id, prior.sha256, prior.size, `beta:${betaId}`); + } } + await writeFile(betaPath, version.content); + recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`); + } catch (err) { + firstErr = `${script.name}: ${(err as Error).message}`; } - await writeFile(betaPath, version.content); - recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`); - - const t = nowIso(); + } + const t = nowIso(); + if (firstErr) { + updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name); + emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${firstErr}` }); + } else { updateStage.run('awaiting-empty', null, t, t, betaId, name); emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null }); - } catch (err) { - const t = nowIso(); - const msg = (err as Error).message; - updateStage.run('failed', `deploy: ${msg}`, t, t, betaId, name); - emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${msg}` }); } } - emit({ type: 'beta-opened', betaId, scriptId: script.id, servers: input.servers }); + emit({ + type: 'beta-opened', + betaId, + scriptIds: resolved.map((r) => r.script.id), + servers: input.servers + }); - return { betaId, servers: input.servers }; + return { + betaId, + servers: input.servers, + scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id })) + }; } // ---------- rollback ---------- @@ -276,51 +376,59 @@ export async function rollbackBeta(betaId: number): Promise { if (beta.state !== 'running' && beta.state !== 'ready-promote') { throw new BetaError(`beta is ${beta.state}, cannot rollback`, 'invalid-state'); } - const script = getScript(beta.script_id)!; - const bfile = betaFile(script.target_rel_path); - const ofile = oldFile(script.target_rel_path); + const bundleRows = listBetaScripts(betaId); + if (bundleRows.length === 0) { + throw new BetaError('beta has no scripts', 'no-scripts'); + } + const bundle = bundleRows.map((br) => ({ + script: getScript(br.script_id)!, + versionId: br.script_version_id + })); - const rows = listBetaServers(betaId); - const restored: string[] = []; - const dropped: string[] = []; + const servers = listBetaServers(betaId); + const restored = new Set(); + const dropped = new Set(); const warnings: string[] = []; - for (const bs of rows) { + for (const bs of servers) { const dest = destOf(bs.server); - const betaPath = join(dest, bfile); - const backupPath = join(dest, ofile); - const targetPath = join(dest, script.target_rel_path); + for (const { script } of bundle) { + const bfile = betaFile(script.target_rel_path); + const ofile = oldFile(script.target_rel_path); + const betaPath = join(dest, bfile); + const backupPath = join(dest, ofile); + const targetPath = join(dest, script.target_rel_path); - // Remove -beta.gsc regardless of stage. - if (existsSync(betaPath)) await rm(betaPath, { force: true }); - removeScript(bs.server, bfile); + // Remove -beta.gsc regardless of stage. + if (existsSync(betaPath)) await rm(betaPath, { force: true }); + removeScript(bs.server, bfile); - if (bs.had_original) { - if (existsSync(backupPath)) { - await rename(backupPath, targetPath); - removeScript(bs.server, ofile); - // Restore inventory row for target with prior version bytes. - const prior = bs.prior_version_id ? getVersion(bs.prior_version_id) : null; - if (prior) { - recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`); + const bss = getBetaServerScript(betaId, bs.server, script.id); + if (bss?.had_original) { + if (existsSync(backupPath)) { + await rename(backupPath, targetPath); + removeScript(bs.server, ofile); + const prior = bss.prior_version_id ? getVersion(bss.prior_version_id) : null; + if (prior) { + recordScript(bs.server, script.target_rel_path, prior.id, prior.sha256, prior.size, `rollback:${betaId}`); + } else { + const buf = await readFile(targetPath); + recordScript( + bs.server, + script.target_rel_path, + null, + sha256Buffer(buf), + buf.byteLength, + `rollback:${betaId}` + ); + } + restored.add(bs.server); } else { - // We had_original but lost the prior version reference — hash the file to record what's there. - const buf = await readFile(targetPath); - recordScript( - bs.server, - script.target_rel_path, - null, - sha256Buffer(buf), - buf.byteLength, - `rollback:${betaId}` - ); + warnings.push(`${bs.server}/${script.name}: no ${ofile} to restore`); } - restored.push(bs.server); } else { - warnings.push(`${bs.server}: no ${ofile} to restore`); + dropped.add(bs.server); } - } else { - dropped.push(bs.server); } } @@ -332,37 +440,44 @@ export async function rollbackBeta(betaId: number): Promise { .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); + // Per-version test-failed transition. Skip a version if (a) another beta + // still gates it at ready-promote/promoted, or (b) it's the currently- + // promoted version of its script (already-live code isn't "failed" by + // rejecting a redundant beta). + for (const { script, versionId } of bundle) { + const stillOk = db() + .prepare( + `SELECT 1 FROM beta_scripts bs + JOIN betas b ON b.id = bs.beta_id + WHERE bs.script_version_id = ? + AND b.id <> ? + AND b.state IN ('ready-promote','promoted') + LIMIT 1` + ) + .get(versionId, betaId); + const isCurrent = script.current_version_id === versionId; + if (!stillOk && !isCurrent) { + db().prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`).run(versionId); + } } emit({ type: 'beta-state', betaId, state: 'rolled-back' }); - return { betaId, restored, dropped, warnings }; + return { + betaId, + restored: [...restored].sort(), + dropped: [...dropped].sort(), + warnings + }; } // ---------- promote ---------- export interface PromoteResult { betaId: number; - landed: string[]; // servers we wrote the promoted target to - versionId: number; deploymentId: string; + landed: string[]; // unique servers that received at least one file + scriptIds: number[]; } function promoteDeploymentId(betaId: number): string { @@ -376,127 +491,114 @@ export async function promoteBeta(betaId: number): Promise { if (beta.state !== 'ready-promote') { throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote'); } - const script = getScript(beta.script_id)!; - const version = getVersion(beta.script_version_id)!; - const bfile = betaFile(script.target_rel_path); - const ofile = oldFile(script.target_rel_path); + const bundleRows = listBetaScripts(betaId); + if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts'); + const bundle = bundleRows.map((br) => ({ + script: getScript(br.script_id)!, + version: getVersion(br.script_version_id)! + })); 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([...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 { + // Build a per-(script, server) plan: prior snapshot + intended write. + interface Plan { + script: ScriptRow; + version: ScriptVersionRow; 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; + const plans: Plan[] = []; + + for (const { script, version } of bundle) { + const scopeServers = script.scope_type && script.scope_value + ? serversMatchingScope(script.scope_type, script.scope_value) + : []; + const targets = new Set([...betaServerSet, ...scopeServers]); + + for (const name of targets) { + let plan: Plan; + if (betaServerSet.has(name)) { + const bss = getBetaServerScript(betaId, name, script.id); + if (bss?.had_original) { + const dest = destOf(name); + const backupPath = join(dest, oldFile(script.target_rel_path)); + let priorContent: Buffer | null = null; + let priorSha: string | null = null; + if (existsSync(backupPath)) { + priorContent = await readFile(backupPath); + priorSha = sha256Buffer(priorContent); + } else if (bss.prior_version_id) { + const pv = getVersion(bss.prior_version_id); + if (pv) { priorContent = pv.content; priorSha = pv.sha256; } } + plan = { + script, version, server: name, + priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true + }; + } else { + plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false }; } - 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 }); + // Non-beta matching-scope server: current on-disk file (if any) + // is the "before" state for a promote-then-rollback round trip. + const dest = destOf(name); + const targetPath = join(dest, script.target_rel_path); + if (existsSync(targetPath)) { + const priorContent = await readFile(targetPath); + const inv = getServerScript(name, script.target_rel_path); + plan = { + script, version, server: name, + priorVersionId: inv?.script_version_id ?? null, + priorSha: sha256Buffer(priorContent), + priorContent, hadFile: true + }; + } else { + plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false }; + } } + plans.push(plan); } } - // 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); + // Write files. Track per-plan success and per-server first-error. + const planWritten = new Map(); + const serverErr = new Map(); + for (const p of plans) { + const dest = destOf(p.server); + const bfile = betaFile(p.script.target_rel_path); + const ofile = oldFile(p.script.target_rel_path); + const targetPath = join(dest, p.script.target_rel_path); const betaPath = join(dest, bfile); const backupPath = join(dest, ofile); - try { - await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true }); - if (existsSync(betaPath)) { - await rm(betaPath, { force: true }); - removeScript(name, bfile); - } - if (existsSync(backupPath)) { - await rm(backupPath, { force: true }); - removeScript(name, ofile); - } - await writeFile(targetPath, version.content); - landed.push(name); + await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true }); + if (existsSync(betaPath)) { await rm(betaPath, { force: true }); removeScript(p.server, bfile); } + if (existsSync(backupPath)) { await rm(backupPath, { force: true }); removeScript(p.server, ofile); } + await writeFile(targetPath, p.version.content); + planWritten.set(p, true); } catch (err) { - const msg = (err as Error).message; - failures.set(name, msg); - console.error(`[promote] ${name} failed:`, err); + planWritten.set(p, false); + const msg = `${p.script.name}: ${(err as Error).message}`; + if (!serverErr.has(p.server)) serverErr.set(p.server, msg); + console.error(`[promote] ${p.server}/${p.script.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. + // Build the aggregate deployment. 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', ?, ?, ?, ?)` + VALUES (?, 'promote', ?, 'ui', NULL, NULL, NULL, ?)` + ); + const insertDepScript = db().prepare( + `INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id) + VALUES (?, ?, ?, ?)` ); const insertDepServer = db().prepare( `INSERT INTO deployment_servers @@ -509,59 +611,77 @@ export async function promoteBeta(betaId: number): Promise { VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); + const uniqueServers = Array.from(new Set(plans.map((p) => p.server))); + 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'; + insertDep.run(depId, startedAt, betaId); + for (const { script, version } of bundle) { + insertDepScript.run(depId, script.id, version.id, script.current_version_id); + } + // deployment_files — one row per successful (script, server) write. + for (const p of plans) { + if (!planWritten.get(p)) continue; + const verb = p.hadFile ? 'updated' : 'added'; insertDepFile.run( depId, - snap.server, + p.server, verb, - script.target_rel_path, - snap.priorSha, - version.sha256, - snap.priorContent, - version.content + p.script.target_rel_path, + p.priorSha, + p.version.sha256, + p.priorContent, + p.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)) { + recordScript( + p.server, + p.script.target_rel_path, + p.version.id, + p.version.sha256, + p.version.size, + `promote:${depId}` + ); + } + // deployment_servers — one row per unique server. Beta servers are + // already restarted; scope-only servers still need the sweep to drain + // and restart them. + for (const name of uniqueServers) { + const err = serverErr.get(name); + if (err) { + insertDepServer.run(depId, name, 0, 'failed', null, err); + continue; + } + const filesChanged = plans.filter((p) => p.server === name && planWritten.get(p)).length; + if (betaServerSet.has(name)) { 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); + .get(betaId, name) as { restarted_at: string | null } | undefined; + insertDepServer.run(depId, name, filesChanged, 'done', bsRestartedAt?.restarted_at ?? startedAt, null); } else { - insertDepServer.run(depId, snap.server, 1, 'pending', null, null); + insertDepServer.run(depId, name, filesChanged, 'pending', null, null); } - - recordScript(snap.server, script.target_rel_path, version.id, version.sha256, version.size, `promote:${depId}`); + } + // Update each script's current_version_id in the same transaction. + for (const { script, version } of bundle) { + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); } })(); - // 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); const archive = betaArchiveOf(betaId); 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 - }); + const landed = Array.from(new Set(plans.filter((p) => planWritten.get(p)).map((p) => p.server))).sort(); - return { betaId, landed, versionId: version.id, deploymentId: depId }; + emit({ type: 'beta-state', betaId, state: 'promoted' }); + emit({ type: 'deployment-opened', deploymentId: depId, action: 'promote', servers: landed }); + + return { + betaId, + deploymentId: depId, + landed, + scriptIds: bundle.map((b) => b.script.id) + }; } // ---------- helpers used by API layer ---------- @@ -575,8 +695,7 @@ export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | nu /** * Un-set a script's scope. When scope is being removed and matching servers - * currently have the script, remove it from them for consistency (per plan: - * "un-marking auto-removes from matching servers, no beta"). Returns the + * currently have the script, remove it from them for consistency. Returns the * server names that had files deleted. */ export async function unsetScopeAndCleanup(scriptId: number): Promise { diff --git a/src/lib/server/fleet/rollback.ts b/src/lib/server/fleet/rollback.ts index d451739..50f9764 100644 --- a/src/lib/server/fleet/rollback.ts +++ b/src/lib/server/fleet/rollback.ts @@ -7,19 +7,11 @@ 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'). +// Roll a promoted deployment back to whatever versions were current before +// the promote landed. Reads deployment_scripts as the source of truth for +// which scripts were involved (and their prior versions); falls back to the +// deprecated deployments.script_id/version_id/prior_version_id columns for +// pre-multi-script legacy rows. export class RollbackError extends Error { constructor(message: string, public readonly kind: 'no-such-deployment' | 'not-promote' | 'already-rolled-back') { @@ -36,6 +28,13 @@ interface DeploymentRow { rolled_back_by: string | null; } +interface DepScriptRow { + deployment_id: string; + script_id: number; + script_version_id: number; + prior_version_id: number | null; +} + interface FileRow { id: number; server: string; @@ -47,6 +46,11 @@ interface FileRow { content_after: Buffer | null; } +interface ScriptTargetRow { + id: number; + target_rel_path: string; +} + function nowIso(): string { return new Date().toISOString(); } @@ -57,10 +61,9 @@ function rollbackDeploymentId(promoteId: string): string { } export interface RollbackDeploymentResult { - deploymentId: string; // the new rollback deployment - reverses: string; // the promote deployment id - scriptId: number | null; - priorVersionId: number | null; + deploymentId: string; // the new rollback deployment + reverses: string; // the promote deployment id + scriptIds: number[]; // scripts whose current_version_id got reverted affectedServers: string[]; } @@ -70,8 +73,27 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise(); + if (scripts.length > 0) { + const placeholders = scripts.map(() => '?').join(','); + const targetRows = db() + .prepare(`SELECT id, target_rel_path FROM scripts WHERE id IN (${placeholders})`) + .all(...scripts.map((s) => s.script_id)) as ScriptTargetRow[]; + const targetById = new Map(); + for (const t of targetRows) targetById.set(t.id, t.target_rel_path); + for (const s of scripts) { + const t = targetById.get(s.script_id); + if (t) scriptByTarget.set(t, s); + } + } + const newDepId = rollbackDeploymentId(promoteId); const startedAt = nowIso(); - // Group by server so each server gets exactly one deployment_servers row. + // Group files 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); @@ -100,14 +138,15 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise { - 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 - ); + insertDep.run(newDepId, startedAt); + // Mirror the promote's script list — swapped: the "current" version + // after rollback is the promote's prior; the "prior" of the rollback + // is what was current up until this call (i.e. the promote's version). + for (const s of scripts) { + insertDepScript.run(newDepId, s.script_id, s.prior_version_id ?? s.script_version_id, s.script_version_id); + } for (const [server, files] of perServer) { if (perServerFailures.has(server)) { insertDepServer.run(newDepId, server, 0, 'failed', perServerFailures.get(server)!); @@ -179,9 +219,9 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise s.script_id), affectedServers }; } diff --git a/src/lib/server/fleet/scripts.ts b/src/lib/server/fleet/scripts.ts index 9abe641..592a6cb 100644 --- a/src/lib/server/fleet/scripts.ts +++ b/src/lib/server/fleet/scripts.ts @@ -207,7 +207,11 @@ export function deleteVersion(versionId: number): void { } const beta = db() - .prepare(`SELECT id FROM betas WHERE script_version_id = ? AND state = 'running'`) + .prepare( + `SELECT b.id FROM betas b + JOIN beta_scripts bs ON bs.beta_id = b.id + WHERE bs.script_version_id = ? AND b.state = 'running' LIMIT 1` + ) .get(versionId) as { id: number } | undefined; if (beta) throw new ScriptError(`beta ${beta.id} is still running against this version`, 'in-use-beta'); @@ -221,7 +225,11 @@ export function deleteScript(scriptId: number): void { throw new ScriptError('un-set scope before deleting a script', 'has-scope'); } const beta = db() - .prepare(`SELECT id FROM betas WHERE script_id = ? AND state = 'running'`) + .prepare( + `SELECT b.id FROM betas b + JOIN beta_scripts bs ON bs.beta_id = b.id + WHERE bs.script_id = ? AND b.state = 'running' LIMIT 1` + ) .get(scriptId) as { id: number } | undefined; if (beta) throw new ScriptError(`beta ${beta.id} is still running against this script`, 'has-active-beta'); db().prepare('DELETE FROM scripts WHERE id = ?').run(scriptId); diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index 470555e..8163d4f 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -153,10 +153,11 @@ export function heldByBeta(server: string): Map { const rows = db() .prepare( `SELECT s.target_rel_path AS target, b.id AS beta_id - FROM beta_servers bs - JOIN betas b ON b.id = bs.beta_id - JOIN scripts s ON s.id = b.script_id - WHERE bs.server = ? AND b.state = 'running'` + FROM beta_servers bsv + JOIN betas b ON b.id = bsv.beta_id + JOIN beta_scripts bs ON bs.beta_id = b.id + JOIN scripts s ON s.id = bs.script_id + WHERE bsv.server = ? AND b.state = 'running'` ) .all(server) as Array<{ target: string; beta_id: number }>; const map = new Map(); @@ -171,10 +172,11 @@ export function ownerOf(server: string, target: string): number | null { const row = db() .prepare( `SELECT b.id AS beta_id - FROM beta_servers bs - JOIN betas b ON b.id = bs.beta_id - JOIN scripts s ON s.id = b.script_id - WHERE bs.server = ? AND s.target_rel_path = ? AND b.state = 'running' + FROM beta_servers bsv + JOIN betas b ON b.id = bsv.beta_id + JOIN beta_scripts bs ON bs.beta_id = b.id + JOIN scripts s ON s.id = bs.script_id + WHERE bsv.server = ? AND s.target_rel_path = ? AND b.state = 'running' LIMIT 1` ) .get(server, target) as { beta_id: number } | undefined; diff --git a/src/lib/server/pipeline/beta-runner.ts b/src/lib/server/pipeline/beta-runner.ts index 747a44e..c54ff09 100644 --- a/src/lib/server/pipeline/beta-runner.ts +++ b/src/lib/server/pipeline/beta-runner.ts @@ -70,18 +70,15 @@ function reconcileBetaState(betaId: number): void { .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 + // Every version bundled into this beta just cleared its gate. + // 'tested' 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); - } + // demotes back to test-failed per-version). + const versionRows = db() + .prepare(`SELECT script_version_id FROM beta_scripts WHERE beta_id = ?`) + .all(betaId) as Array<{ script_version_id: number }>; + const markTested = db().prepare(`UPDATE script_versions SET state = 'tested' WHERE id = ?`); + for (const v of versionRows) markTested.run(v.script_version_id); } emit({ type: 'beta-state', betaId, state: 'ready-promote' }); } diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index c7ebda5..be0693f 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -44,7 +44,7 @@ export type PipelineEvent = } | { type: 'sweep-tick'; frozen: boolean; reason?: string } | { type: 'server-status'; entries: ServerStatusEntry[] } - | { type: 'beta-opened'; betaId: number; scriptId: number; servers: string[] } + | { type: 'beta-opened'; betaId: number; scriptIds: number[]; servers: string[] } | { type: 'beta-server-stage'; betaId: number; diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts index 10af95b..725e814 100644 --- a/src/routes/api/betas/+server.ts +++ b/src/routes/api/betas/+server.ts @@ -2,9 +2,9 @@ import { json } from '@sveltejs/kit'; import { listBetas, listBetaServers, + listBetaScripts, openBeta, - BetaError, - type BetaTriggerKind + BetaError } from '$lib/server/fleet/beta.js'; import { getScript, getVersion } from '$lib/server/fleet/scripts.js'; import { kick } from '$lib/server/pipeline/worker.js'; @@ -17,53 +17,63 @@ export async function GET({ url }: { url: URL }) { | 'rolled-back' | null; const rows = listBetas(stateFilter ?? undefined).map((b) => { - const script = getScript(b.script_id); - const version = getVersion(b.script_version_id); + const scripts = listBetaScripts(b.id).map((bs) => { + const script = getScript(bs.script_id); + const version = getVersion(bs.script_version_id); + return { + id: bs.script_id, + name: script?.name ?? '(deleted)', + target_rel_path: script?.target_rel_path ?? '', + version: version + ? { id: version.id, version: version.version, sha256: version.sha256 } + : null + }; + }); return { ...b, - script: script ? { id: script.id, name: script.name, target_rel_path: script.target_rel_path } : null, - version: version ? { id: version.id, version: version.version, sha256: version.sha256 } : null, + scripts, servers: listBetaServers(b.id) }; }); return json({ betas: rows }); } -// POST body: { scriptId, scriptVersionId, servers[], waitForHealth, waitForMatch, triggerKind, notes? } +// POST body: { scripts: [{ scriptId, scriptVersionId }], servers[], waitForHealth, waitForMatch, notes? } export async function POST({ request }: { request: Request }) { const body = (await request.json().catch(() => ({}))) as { - scriptId?: number; - scriptVersionId?: number; + scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>; servers?: string[]; waitForHealth?: boolean; waitForMatch?: boolean; - triggerKind?: BetaTriggerKind; notes?: string; }; - if ( - typeof body.scriptId !== 'number' || - typeof body.scriptVersionId !== 'number' || - !Array.isArray(body.servers) - ) { + if (!Array.isArray(body.scripts) || body.scripts.length === 0 || !Array.isArray(body.servers)) { return json( - { ok: false, error: 'scriptId, scriptVersionId, and servers[] are required' }, + { ok: false, error: 'scripts[] (non-empty) and servers[] are required' }, { status: 400 } ); } if (body.servers.length === 0) { return json({ ok: false, error: 'at least one server is required' }, { status: 400 }); } - const triggerKind: BetaTriggerKind = body.triggerKind ?? 'unscoped'; + const normalized: Array<{ scriptId: number; scriptVersionId: number }> = []; + for (const s of body.scripts) { + if (typeof s.scriptId !== 'number' || typeof s.scriptVersionId !== 'number') { + return json( + { ok: false, error: 'each scripts[] entry needs scriptId and scriptVersionId' }, + { status: 400 } + ); + } + normalized.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId }); + } try { const result = await openBeta({ - scriptId: body.scriptId, - scriptVersionId: body.scriptVersionId, + scripts: normalized, servers: body.servers, waitForHealth: body.waitForHealth ?? true, waitForMatch: body.waitForMatch ?? false, - triggerKind, notes: body.notes }); void kick(); diff --git a/src/routes/api/betas/[id]/+server.ts b/src/routes/api/betas/[id]/+server.ts index 187a494..c1474a0 100644 --- a/src/routes/api/betas/[id]/+server.ts +++ b/src/routes/api/betas/[id]/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js'; +import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js'; import { getScript, getVersion } from '$lib/server/fleet/scripts.js'; function parseId(raw: string): number | null { @@ -13,9 +13,19 @@ export async function GET({ params }: { params: { id: string } }) { const beta = getBeta(id); if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 }); - const script = getScript(beta.script_id); - const version = getVersion(beta.script_version_id); + const scripts = listBetaScripts(id).map((bs) => { + const script = getScript(bs.script_id); + const version = getVersion(bs.script_version_id); + return { + id: bs.script_id, + name: script?.name ?? '(deleted)', + target_rel_path: script?.target_rel_path ?? '', + version: version + ? { id: version.id, version: version.version, sha256: version.sha256 } + : null + }; + }); const servers = listBetaServers(id); - return json({ beta, script, version, servers }); + return json({ beta, scripts, servers }); } diff --git a/src/routes/api/deployments/+server.ts b/src/routes/api/deployments/+server.ts index b19d1bb..956f2bd 100644 --- a/src/routes/api/deployments/+server.ts +++ b/src/routes/api/deployments/+server.ts @@ -57,8 +57,35 @@ export async function GET({ url }: { url: URL }) { grouped.set(s.deployment_id, arr); } + const scriptRows = db() + .prepare( + `SELECT ds.deployment_id, ds.script_id, s.name AS script_name, + sv.version AS script_version, pv.version AS prior_version + FROM deployment_scripts ds + LEFT JOIN scripts s ON s.id = ds.script_id + LEFT JOIN script_versions sv ON sv.id = ds.script_version_id + LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id` + ) + .all() as Array<{ + deployment_id: string; + script_id: number; + script_name: string | null; + script_version: number | null; + prior_version: number | null; + }>; + const scriptsByDep = new Map(); + for (const s of scriptRows) { + const arr = scriptsByDep.get(s.deployment_id) ?? []; + arr.push(s); + scriptsByDep.set(s.deployment_id, arr); + } + const rows = deployments - .map((d) => ({ ...d, servers: grouped.get(d.id) ?? [] })) + .map((d) => ({ + ...d, + servers: grouped.get(d.id) ?? [], + scripts: scriptsByDep.get(d.id) ?? [] + })) .filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true)); return json({ deployments: rows }); diff --git a/src/routes/api/deployments/[id]/+server.ts b/src/routes/api/deployments/[id]/+server.ts index 754883d..7aed1fc 100644 --- a/src/routes/api/deployments/[id]/+server.ts +++ b/src/routes/api/deployments/[id]/+server.ts @@ -24,5 +24,19 @@ export async function GET({ params }: { params: { id: string } }) { ) .all(params.id); - return json({ deployment: dep, servers, files }); + const scripts = db() + .prepare( + `SELECT ds.script_id, s.name AS script_name, s.target_rel_path, + ds.script_version_id, sv.version AS script_version, + ds.prior_version_id, pv.version AS prior_version + FROM deployment_scripts ds + LEFT JOIN scripts s ON s.id = ds.script_id + LEFT JOIN script_versions sv ON sv.id = ds.script_version_id + LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id + WHERE ds.deployment_id = ? + ORDER BY ds.script_id` + ) + .all(params.id); + + return json({ deployment: dep, servers, files, scripts }); } diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 284e5f6..1778e1c 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -4,16 +4,17 @@ interface BetaListRow { id: number; - script_id: number; - script_version_id: number; - trigger_kind: string; + state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; wait_for_health: number; wait_for_match: number; - state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; opened_at: string; closed_at: string | null; - script: { id: number; name: string; target_rel_path: string } | null; - version: { id: number; version: number; sha256: string } | null; + scripts: Array<{ + id: number; + name: string; + target_rel_path: string; + version: { id: number; version: number; sha256: string } | null; + }>; servers: Array<{ server: string; stage: string; error: string | null }>; } @@ -41,18 +42,30 @@ } interface ServerRow { name: string; game: string | null; map: string | null } + interface ScriptDetail { + script: ScriptListRow; + versions: Version[]; + matchedServers: string[]; + } + + // One row in the multi-script picker. + interface Pick { + key: number; // stable key for keyed each block + scriptId: number | null; + versionId: number | null; + } + let betas = $state([]); let loading = $state(true); let scripts = $state([]); let fleetServers = $state([]); - let pickedScriptId = $state(null); - let pickedScriptDetail = $state<{ - script: ScriptListRow; - versions: Version[]; - matchedServers: string[]; - } | null>(null); - let pickedVersionId = $state(null); + // Cache script detail (versions + matched servers) per picked scriptId so + // switching versions doesn't refetch. + let detailById = $state>({}); + + let nextKey = 0; + let picks = $state([{ key: nextKey++, scriptId: null, versionId: null }]); let pickedServers = $state>(new Set()); let waitForHealth = $state(true); let waitForMatch = $state(false); @@ -79,9 +92,8 @@ } onMount(load); - // A script is eligible for a new beta when it has an unpromoted version and - // isn't already in a beta. Fully-promoted scripts have nothing to test; an - // active beta blocks concurrent runs on the same script. + // Scripts eligible for a new beta: has an unpromoted version and isn't in + // a running beta already. const eligibleScripts = $derived( scripts.filter( (s) => @@ -91,32 +103,46 @@ ) ); - async function onScriptChange(): Promise { - pickedScriptDetail = null; - pickedVersionId = null; - pickedServers = new Set(); - flash = null; - if (pickedScriptId === null) return; - const res = await fetch(`/api/scripts/${pickedScriptId}`); - if (!res.ok) { flash = 'failed to load script detail'; return; } - const body = (await res.json()) as { - script: ScriptListRow; - versions: Version[]; - matchedServers: string[]; - }; - pickedScriptDetail = body; - - // Default to the newest non-current version — the one the operator - // most likely wants to test. - const eligibleVersions = body.versions.filter((v) => v.id !== body.script.current_version_id); - pickedVersionId = eligibleVersions[0]?.id ?? null; - - // Default test-server selection: matched-scope servers if scoped, - // whole fleet otherwise. - const defaults = body.script.scope_type ? body.matchedServers : fleetServers.map((s) => s.name); - pickedServers = new Set(defaults); + // Scripts eligible for a specific picker row: eligible AND not already + // chosen in a different row. Keeps the dropdown from letting you double- + // add the same script. + function eligibleForRow(rowIdx: number): ScriptListRow[] { + const takenIds = new Set( + picks + .map((p, i) => (i === rowIdx ? null : p.scriptId)) + .filter((id): id is number => id !== null) + ); + return eligibleScripts.filter((s) => !takenIds.has(s.id)); } + async function onPickScript(rowIdx: number): Promise { + flash = null; + const pick = picks[rowIdx]; + pick.versionId = null; + picks = [...picks]; + if (pick.scriptId === null) return; + if (!detailById[pick.scriptId]) { + const res = await fetch(`/api/scripts/${pick.scriptId}`); + if (!res.ok) { flash = 'failed to load script detail'; return; } + const body = (await res.json()) as ScriptDetail; + detailById = { ...detailById, [pick.scriptId]: body }; + } + // Default: latest non-current version. + const detail = detailById[pick.scriptId]; + const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id); + pick.versionId = eligibleVersions[0]?.id ?? null; + picks = [...picks]; + // Reset server selection to the recomputed candidate set. + pickedServers = new Set(candidateServers); + } + + function addRow(): void { + picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }]; + } + function removeRow(idx: number): void { + picks = picks.filter((_, i) => i !== idx); + pickedServers = new Set(candidateServers); + } function toggleServer(name: string): void { const next = new Set(pickedServers); if (next.has(name)) next.delete(name); @@ -124,40 +150,62 @@ pickedServers = next; } - const eligibleVersions = $derived( - pickedScriptDetail - ? pickedScriptDetail.versions.filter((v) => v.id !== pickedScriptDetail!.script.current_version_id) - : [] - ); - const candidateServers = $derived.by(() => { - if (!pickedScriptDetail) return [] as string[]; - return pickedScriptDetail.script.scope_type - ? pickedScriptDetail.matchedServers + function versionsForRow(row: Pick): Version[] { + if (row.scriptId === null) return []; + const detail = detailById[row.scriptId]; + if (!detail) return []; + return detail.versions.filter((v) => v.id !== detail.script.current_version_id); + } + + // Servers each pick would land on if promoted alone. Scoped scripts → + // matched servers; unscoped scripts → whole fleet. + function candidatesFor(row: Pick): string[] | null { + if (row.scriptId === null) return null; + const detail = detailById[row.scriptId]; + if (!detail) return null; + return detail.script.scope_type + ? detail.matchedServers : fleetServers.map((s) => s.name); + } + + // Intersection of every picked script's candidate set. A beta with two + // scripts must run on servers that are in-scope for BOTH. + const candidateServers = $derived.by(() => { + const perPick: string[][] = []; + for (const p of picks) { + const c = candidatesFor(p); + if (c === null) continue; // row not yet picked — don't constrain + perPick.push(c); + } + if (perPick.length === 0) return [] as string[]; + const [first, ...rest] = perPick; + return first.filter((name) => rest.every((set) => set.includes(name))); }); + // Ready to submit when every row has a script + version and at least one + // server is picked from the intersection. + const canSubmit = $derived( + !openingBeta && + picks.length > 0 && + picks.every((p) => p.scriptId !== null && p.versionId !== null) && + candidateServers.length > 0 && + pickedServers.size > 0 + ); + async function openNewBeta(e: Event): Promise { e.preventDefault(); - if (!pickedScriptDetail || pickedVersionId === null || pickedServers.size === 0 || openingBeta) return; + if (!canSubmit) return; openingBeta = true; flash = null; - const s = pickedScriptDetail.script; - const triggerKind = !s.scope_type - ? 'unscoped' - : s.current_version_id === null - ? 'mark-required' - : 'version-update'; try { const res = await fetch('/api/betas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - scriptId: s.id, - scriptVersionId: pickedVersionId, + scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })), servers: [...pickedServers], waitForHealth, - waitForMatch, - triggerKind + waitForMatch }) }); const body = (await res.json()) as { @@ -179,6 +227,15 @@ let grouped = $derived( stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) })) ); + + function summarizeScripts(b: BetaListRow): string { + if (b.scripts.length === 0) return '(no scripts)'; + if (b.scripts.length === 1) { + const s = b.scripts[0]; + return `${s.name} · v${s.version?.version ?? '?'}`; + } + return `${b.scripts.length} scripts`; + }

Betas

@@ -192,27 +249,45 @@

no scripts with untested versions — upload a new version from a script's page

{:else}
+
+ {#each picks as row, idx (row.key)} +
+ + + +
+ {/each} +
+ + - {#if pickedScriptDetail} - -