multi script beta

This commit is contained in:
2026-09-12 18:23:56 -04:00
parent 30e59c900c
commit 85bcb05a7d
16 changed files with 1053 additions and 516 deletions
+34
View File
@@ -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;
}
+42 -3
View File
@@ -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
+395 -276
View File
@@ -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<OpenBetaResult> {
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<number>();
const seenTargets = new Map<string, number>();
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<OpenBetaResult> {
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<OpenBetaResult> {
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<RollbackResult> {
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<string>();
const dropped = new Set<string>();
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<RollbackResult> {
.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<PromoteResult> {
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<string>([...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<string, string>();
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<Plan, boolean>();
const serverErr = new Map<string, string>();
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<PromoteResult> {
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<string[]> {
+78 -39
View File
@@ -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 = <new deployment id>.
// - Records a fresh action='rollback' deployment with pending server rows so
// the sweep drains-and-restarts every affected server. Beta servers that
// were still running the promoted bytes get restarted the same as any
// other; the beta itself is not touched (it stays in state='promoted').
// 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<Roll
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');
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');
}
// Source of truth for which scripts (and prior versions) this promote
// touched. Prefer deployment_scripts; fall back to the deprecated
// singular columns for legacy single-script deployments.
let scripts = db()
.prepare(`SELECT * FROM deployment_scripts WHERE deployment_id = ? ORDER BY script_id`)
.all(promoteId) as DepScriptRow[];
if (scripts.length === 0 && dep.script_id !== null && dep.script_version_id !== null) {
scripts = [{
deployment_id: promoteId,
script_id: dep.script_id,
script_version_id: dep.script_version_id,
prior_version_id: dep.prior_version_id
}];
}
const fileRows = db()
.prepare(
@@ -80,10 +102,26 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
)
.all(promoteId) as FileRow[];
// Map rel_path → scriptId (and prior_version_id) so we can restore the
// inventory row's script_version_id correctly during file restore.
const scriptByTarget = new Map<string, DepScriptRow>();
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<number, string>();
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<string, FileRow[]>();
for (const f of fileRows) {
const arr = perServer.get(f.server);
@@ -100,14 +138,15 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
for (const f of files) {
const abs = join(dest, f.rel_path);
await mkdir(join(dest, dirname(f.rel_path)), { recursive: true });
const owningScript = scriptByTarget.get(f.rel_path);
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;
const priorVer = owningScript?.prior_version_id
? getVersion(owningScript.prior_version_id)
: null;
recordScript(
server,
f.rel_path,
@@ -117,7 +156,6 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
`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}`);
}
@@ -132,15 +170,17 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
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)`
VALUES (?, 'rollback', ?, 'ui', NULL, 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
(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)
@@ -148,13 +188,13 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
);
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
);
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<Roll
}
}
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);
// Restore each affected script's current_version_id.
for (const s of scripts) {
db().prepare(`UPDATE scripts SET current_version_id = ? WHERE id = ?`).run(s.prior_version_id, s.script_id);
}
})();
@@ -190,8 +230,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
return {
deploymentId: newDepId,
reverses: promoteId,
scriptId: dep.script_id,
priorVersionId: dep.prior_version_id,
scriptIds: scripts.map((s) => s.script_id),
affectedServers
};
}
+10 -2
View File
@@ -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);
+10 -8
View File
@@ -153,10 +153,11 @@ export function heldByBeta(server: string): Map<string, number> {
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<string, number>();
@@ -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;
+8 -11
View File
@@ -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' });
}
+1 -1
View File
@@ -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;
+30 -20
View File
@@ -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();
+14 -4
View File
@@ -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 });
}
+28 -1
View File
@@ -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<string, typeof scriptRows>();
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 });
+15 -1
View File
@@ -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 });
}
+202 -109
View File
@@ -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<BetaListRow[]>([]);
let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]);
let fleetServers = $state<ServerRow[]>([]);
let pickedScriptId = $state<number | null>(null);
let pickedScriptDetail = $state<{
script: ScriptListRow;
versions: Version[];
matchedServers: string[];
} | null>(null);
let pickedVersionId = $state<number | null>(null);
// Cache script detail (versions + matched servers) per picked scriptId so
// switching versions doesn't refetch.
let detailById = $state<Record<number, ScriptDetail>>({});
let nextKey = 0;
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]);
let pickedServers = $state<Set<string>>(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<void> {
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<void> {
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<void> {
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`;
}
</script>
<h1>Betas</h1>
@@ -192,27 +249,45 @@
<p class="empty">no scripts with untested versions — upload a new version from a script's page</p>
{:else}
<form onsubmit={openNewBeta} class="new-beta-form">
<div class="picks">
{#each picks as row, idx (row.key)}
<div class="pick-row">
<select
bind:value={row.scriptId}
onchange={() => onPickScript(idx)}
>
<option value={null}>— pick a script —</option>
{#each eligibleForRow(idx) as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
<select bind:value={row.versionId} disabled={row.scriptId === null}>
{#if row.scriptId === null}
<option value={null}>—</option>
{:else}
{#each versionsForRow(row) as v (v.id)}
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
{/if}
</select>
<button
type="button"
class="row-x"
onclick={() => removeRow(idx)}
disabled={picks.length === 1}
title={picks.length === 1 ? 'need at least one script' : 'remove this script'}
>×</button>
</div>
{/each}
</div>
<button type="button" class="add-row" onclick={addRow}>+ add script</button>
<label>
<span class="k">script</span>
<select bind:value={pickedScriptId} onchange={onScriptChange}>
<option value={null}>— pick a script —</option>
{#each eligibleScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
{#if pickedScriptDetail}
<label>
<span class="k">version</span>
<select bind:value={pickedVersionId}>
{#each eligibleVersions as v (v.id)}
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
</select>
</label>
<label>
<span class="k">test servers</span>
<div class="server-picker">
<span class="k">test servers</span>
<div class="server-picker">
{#if candidateServers.length === 0}
<span class="empty">no server satisfies all selected scopes — pick different scripts</span>
{:else}
{#each candidateServers as name (name)}
<label class="chk">
<input
@@ -223,29 +298,24 @@
{name}
</label>
{/each}
{#if candidateServers.length === 0}
<span class="empty">no candidate servers</span>
{/if}
</div>
</label>
<div class="opts">
<label class="chk">
<input type="checkbox" bind:checked={waitForHealth} />
wait for server start healthy
</label>
<label class="chk">
<input type="checkbox" bind:checked={waitForMatch} />
wait for server match
</label>
{/if}
</div>
<button
type="submit"
class="primary"
disabled={openingBeta || pickedVersionId === null || pickedServers.size === 0}
>
{openingBeta ? 'opening…' : 'open beta'}
</button>
{/if}
</label>
<div class="opts">
<label class="chk">
<input type="checkbox" bind:checked={waitForHealth} />
wait for server start healthy
</label>
<label class="chk">
<input type="checkbox" bind:checked={waitForMatch} />
wait for server match
</label>
</div>
<button type="submit" class="primary" disabled={!canSubmit}>
{openingBeta ? 'opening…' : 'open beta'}
</button>
</form>
{#if flash}<p class="flash">{flash}</p>{/if}
{/if}
@@ -262,9 +332,7 @@
{#each g.rows as b (b.id)}
<a class="row" href="/betas/{b.id}">
<span class="id">#{b.id}</span>
<span class="script">{b.script?.name ?? '(deleted)'}</span>
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
<span class="mono v">v{b.version?.version ?? '?'}</span>
<span class="script">{summarizeScripts(b)}</span>
<span class="tag state-{b.state}">{b.state}</span>
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
</a>
@@ -285,7 +353,10 @@
.new-beta-form { display: flex; flex-direction: column; gap: 14px; }
.new-beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
.new-beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
.new-beta-form select {
.picks { display: flex; flex-direction: column; gap: 8px; }
.pick-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; }
.pick-row select {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
@@ -294,6 +365,30 @@
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.row-x {
background: var(--bg-card);
color: var(--ink-500);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 0 14px;
font-size: var(--text-md);
cursor: pointer;
}
.row-x:hover:not(:disabled) { color: var(--danger-500); border-color: rgba(255, 48, 80, 0.4); }
.row-x:disabled { opacity: 0.4; cursor: default; }
.add-row {
align-self: flex-start;
background: transparent;
color: var(--ink-300);
border: 1px dashed var(--border);
border-radius: var(--r-sm);
padding: 8px 14px;
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
}
.add-row:hover { color: var(--cream); border-color: var(--accent); }
.server-picker {
display: flex;
flex-wrap: wrap;
@@ -330,7 +425,7 @@
.rows { display: flex; flex-direction: column; gap: 6px; }
.row {
display: grid;
grid-template-columns: 60px 220px 1fr 80px 130px 120px;
grid-template-columns: 60px 1fr 130px 120px;
gap: 14px;
padding: 12px 16px;
background: var(--bg-card);
@@ -344,8 +439,6 @@
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.id { color: var(--ink-500); font-family: var(--font-mono); }
.script { color: var(--cream); font-weight: 600; font-family: var(--font-body); }
.target { color: var(--ink-300); font-family: var(--font-mono); word-break: break-all; }
.v { color: var(--ink-500); }
.servers { color: var(--ink-500); }
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; text-align: center; }
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
+45 -16
View File
@@ -28,12 +28,15 @@
match_seen_at: string | null;
updated_at: string;
}
interface Script { id: number; name: string; target_rel_path: string }
interface Version { id: number; version: number; sha256: string; size: number }
interface BetaScript {
id: number;
name: string;
target_rel_path: string;
version: { id: number; version: number; sha256: string } | null;
}
let beta = $state<BetaRow | null>(null);
let script = $state<Script | null>(null);
let version = $state<Version | null>(null);
let scripts = $state<BetaScript[]>([]);
let servers = $state<BetaServerRow[]>([]);
let loading = $state(true);
let flash = $state<string | null>(null);
@@ -46,13 +49,11 @@
if (!res.ok) { loading = false; return; }
const body = (await res.json()) as {
beta: BetaRow;
script: Script;
version: Version;
scripts: BetaScript[];
servers: BetaServerRow[];
};
beta = body.beta;
script = body.script;
version = body.version;
scripts = body.scripts;
servers = body.servers;
loading = false;
}
@@ -107,7 +108,7 @@
{#if loading}
<p class="empty">loading…</p>
{:else if !beta || !script || !version}
{:else if !beta}
<p class="empty">beta not found</p>
{:else}
<a class="back" href="/betas">← all betas</a>
@@ -115,11 +116,24 @@
<h1>Beta #{beta.id}</h1>
<span class="tag state-{beta.state}">{beta.state}</span>
</div>
<div class="sub">
<a href="/scripts/{script.id}">{script.name}</a>
<span class="mono">· {script.target_rel_path}</span>
<span class="mono">· v{version.version}</span>
</div>
<section class="panel">
<h2>Scripts · {scripts.length}</h2>
{#if scripts.length === 0}
<p class="empty">no scripts bundled in this beta</p>
{:else}
<div class="script-list">
{#each scripts as s (s.id)}
<div class="script-row">
<a class="s-name" href="/scripts/{s.id}">{s.name}</a>
<span class="s-target mono">{s.target_rel_path}</span>
<span class="s-ver mono">v{s.version?.version ?? '?'}</span>
<span class="s-sha mono">{s.version?.sha256.slice(0, 8) ?? ''}</span>
</div>
{/each}
</div>
{/if}
</section>
<section class="panel">
<h2>Options</h2>
@@ -193,10 +207,25 @@
.back:hover { color: var(--ink-100); }
.head-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 4px; }
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 0; }
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; display: flex; gap: 8px; align-items: baseline; }
.sub a { color: var(--spark-500); text-decoration: none; }
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); }
.script-list { display: flex; flex-direction: column; gap: 6px; }
.script-row {
display: grid;
grid-template-columns: 220px 1fr 80px 100px;
gap: 14px;
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
align-items: center;
font-size: var(--text-mono);
}
.s-name { color: var(--spark-500); text-decoration: none; font-weight: 600; font-family: var(--font-body); }
.s-name:hover { text-decoration: underline; }
.s-target { color: var(--ink-300); word-break: break-all; }
.s-ver, .s-sha { color: var(--ink-500); }
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
+25 -6
View File
@@ -8,6 +8,13 @@
players: number | null;
error: string | null;
}
interface DepScript {
deployment_id: string;
script_id: number;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}
interface Deployment {
id: string;
action: string;
@@ -23,6 +30,7 @@
script_name: string | null;
script_version: number | null;
prior_version: number | null;
scripts: DepScript[];
}
interface DriftFile { relPath: string; size: number; sha256: string }
@@ -101,11 +109,24 @@
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
function scriptSummary(d: Deployment): string {
if (d.scripts && d.scripts.length > 0) {
if (d.scripts.length === 1) {
const s = d.scripts[0];
return `${s.script_name ?? '(deleted)'} v${s.script_version ?? '?'}`;
}
return `${d.scripts.length} scripts`;
}
// Legacy fallback: pre-multi-script rows still use the top-level cols.
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
return '';
}
async function rollbackPromote(d: Deployment, ev: Event): Promise<void> {
ev.preventDefault();
ev.stopPropagation();
const priorLabel = d.prior_version === null ? 'nothing (undo the first promotion)' : `v${d.prior_version}`;
const msg = `Rollback ${d.script_name ?? 'this script'} v${d.script_version ?? '?'} on ${d.servers.length} server(s)? Reverts to ${priorLabel}.`;
const summary = scriptSummary(d) || 'this promote';
const msg = `Rollback ${summary} on ${d.servers.length} server(s)?`;
if (!confirm(msg)) return;
rollingBack = d.id;
flash = null;
@@ -183,10 +204,8 @@
<div class="head">
<div class="head-l">
<span class="action">{d.action}</span>
{#if d.action === 'promote' && d.script_name}
<span class="target mono">{d.script_name} v{d.script_version}</span>
{:else if d.action === 'rollback' && d.script_name}
<span class="target mono">{d.script_name} → v{d.script_version ?? '—'}</span>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target mono">{scriptSummary(d)}</span>
{/if}
{#if d.rolled_back_by}
<span class="tag rb">rolled back</span>
+116 -19
View File
@@ -37,11 +37,9 @@ describe('scope options', () => {
describe('promote creates a deployment', () => {
it('records deployment_files for every landed server and marks beta servers done, others pending', async () => {
await seedFleet();
const { createScript, getScript } = await import('../../src/lib/server/fleet/scripts.js');
const { createScript, getScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
// The reconcileBetaState only fires from the beta-runner; short-circuit
// that in the test by flipping beta state manually.
// A game-scoped script matches both fixture servers.
const { script } = createScript({
@@ -51,16 +49,15 @@ describe('promote creates a deployment', () => {
scopeValue: 't5',
content: Buffer.from('main(){}\n')
});
const version = getLatestVersion(script.id)!;
// Open the beta on ONE server; promotion should still write to the
// other server (via scope) and mark it pending for restart.
const { betaId } = await openBeta({
scriptId: script.id,
scriptVersionId: script.current_version_id ?? (await import('../../src/lib/server/fleet/scripts.js')).getLatestVersion(script.id)!.id,
scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
servers: ['t5_kino_1'],
waitForHealth: false,
waitForMatch: false,
triggerKind: 'mark-required'
waitForMatch: false
});
// Force all beta_servers to 'ready' and beta to 'ready-promote'.
@@ -72,13 +69,20 @@ describe('promote creates a deployment', () => {
expect(result.deploymentId).toMatch(/^dep-.*-promote-\d+$/);
expect(new Set(result.landed)).toEqual(new Set(['t5_kino_1', 't5_ascension_1']));
expect(result.scriptIds).toEqual([script.id]);
// Deployment row is action='promote' with the new columns populated.
// Deployment row is action='promote'. The multi-script schema stores
// scripts in deployment_scripts, not the legacy singular columns.
const dep = db().prepare(`SELECT * FROM deployments WHERE id = ?`).get(result.deploymentId) as any;
expect(dep.action).toBe('promote');
expect(dep.script_id).toBe(script.id);
expect(dep.beta_id).toBe(betaId);
expect(dep.prior_version_id).toBeNull(); // never promoted before
expect(dep.script_id).toBeNull(); // new promotes populate deployment_scripts
const depScripts = db().prepare(`SELECT * FROM deployment_scripts WHERE deployment_id = ?`).all(result.deploymentId) as any[];
expect(depScripts.length).toBe(1);
expect(depScripts[0].script_id).toBe(script.id);
expect(depScripts[0].script_version_id).toBe(version.id);
expect(depScripts[0].prior_version_id).toBeNull(); // never promoted before
// deployment_files has one row per landed server.
const files = db().prepare(`SELECT * FROM deployment_files WHERE deployment_id = ?`).all(result.deploymentId) as any[];
@@ -100,7 +104,7 @@ describe('promote creates a deployment', () => {
// Script's current_version_id and beta state land.
const after = getScript(script.id)!;
expect(after.current_version_id).toBeGreaterThan(0);
expect(after.current_version_id).toBe(version.id);
const beta = db().prepare(`SELECT state FROM betas WHERE id = ?`).get(betaId) as any;
expect(beta.state).toBe('promoted');
});
@@ -123,12 +127,10 @@ describe('rollback beta on ready-promote marks version test-failed', () => {
const v = getLatestVersion(script.id)!;
const { betaId } = await openBeta({
scriptId: script.id,
scriptVersionId: v.id,
scripts: [{ scriptId: script.id, scriptVersionId: v.id }],
servers: ['t5_kino_1'],
waitForHealth: false,
waitForMatch: false,
triggerKind: 'mark-required'
waitForMatch: false
});
// Force ready-promote so the state machine gate is closed.
@@ -162,12 +164,10 @@ describe('rollback promote deployment', () => {
const v = getLatestVersion(script.id)!;
const { betaId } = await openBeta({
scriptId: script.id,
scriptVersionId: v.id,
scripts: [{ scriptId: script.id, scriptVersionId: v.id }],
servers: ['t5_kino_1'],
waitForHealth: false,
waitForMatch: false,
triggerKind: 'mark-required'
waitForMatch: false
});
db().prepare(`UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?`)
.run(new Date().toISOString(), betaId);
@@ -180,6 +180,7 @@ describe('rollback promote deployment', () => {
const result = await rollbackPromoteDeployment(promote.deploymentId);
expect(result.deploymentId).toMatch(/^dep-.*-rollback-/);
expect(new Set(result.affectedServers)).toEqual(new Set(['t5_kino_1', 't5_ascension_1']));
expect(result.scriptIds).toEqual([script.id]);
// Script's current_version_id reverted (was null before the promote).
expect(getScript(script.id)!.current_version_id).toBeNull();
@@ -199,3 +200,99 @@ describe('rollback promote deployment', () => {
await expect(rollbackPromoteDeployment(promote.deploymentId)).rejects.toMatchObject({ kind: 'already-rolled-back' });
});
});
describe('multi-script beta', () => {
it('open + promote + rollback bundle two scripts as one atomic unit', async () => {
await seedFleet();
const {
createScript,
getScript,
getLatestVersion
} = await import('../../src/lib/server/fleet/scripts.js');
const {
openBeta,
promoteBeta,
listBetaScripts
} = await import('../../src/lib/server/fleet/beta.js');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const { heldByBeta } = await import('../../src/lib/server/fleet/servers.js');
const { db } = await import('../../src/lib/server/db/index.js');
// Two scripts, both scope=game=t5 so they both land on both fixture servers.
const a = createScript({
name: 'hud',
targetRelPath: 'hud.gsc',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('hud(){}\n')
});
const b = createScript({
name: 'flag',
targetRelPath: 'flag.gsc',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('flag(){}\n')
});
const va = getLatestVersion(a.script.id)!;
const vb = getLatestVersion(b.script.id)!;
const { betaId } = await openBeta({
scripts: [
{ scriptId: a.script.id, scriptVersionId: va.id },
{ scriptId: b.script.id, scriptVersionId: vb.id }
],
servers: ['t5_kino_1', 't5_ascension_1'],
waitForHealth: false,
waitForMatch: false
});
// Both scripts are joined to the beta.
const bundle = listBetaScripts(betaId);
expect(bundle.length).toBe(2);
expect(new Set(bundle.map((r) => r.script_id))).toEqual(new Set([a.script.id, b.script.id]));
// Both target rel paths are held on both servers.
const held1 = heldByBeta('t5_kino_1');
expect(held1.get('hud.gsc')).toBe(betaId);
expect(held1.get('flag.gsc')).toBe(betaId);
// Force ready-promote and simulate tested transition (the beta-runner
// would do this for real).
db().prepare(`UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?`)
.run(new Date().toISOString(), betaId);
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
db().prepare(`UPDATE script_versions SET state = 'tested' WHERE id IN (?, ?)`).run(va.id, vb.id);
const promote = await promoteBeta(betaId);
expect(new Set(promote.scriptIds)).toEqual(new Set([a.script.id, b.script.id]));
// ONE deployment, TWO deployment_scripts rows.
const dep = db().prepare(`SELECT * FROM deployments WHERE id = ?`).get(promote.deploymentId) as any;
expect(dep.action).toBe('promote');
expect(dep.beta_id).toBe(betaId);
const depScripts = db()
.prepare(`SELECT * FROM deployment_scripts WHERE deployment_id = ? ORDER BY script_id`)
.all(promote.deploymentId) as any[];
expect(depScripts.length).toBe(2);
// Two files per server (hud + flag) → 4 deployment_files rows.
const files = db().prepare(`SELECT * FROM deployment_files WHERE deployment_id = ?`).all(promote.deploymentId) as any[];
expect(files.length).toBe(4);
// Both scripts' current_version_id updated.
expect(getScript(a.script.id)!.current_version_id).toBe(va.id);
expect(getScript(b.script.id)!.current_version_id).toBe(vb.id);
// Rollback the whole promote — both scripts revert together.
const rb = await rollbackPromoteDeployment(promote.deploymentId);
expect(new Set(rb.scriptIds)).toEqual(new Set([a.script.id, b.script.id]));
expect(getScript(a.script.id)!.current_version_id).toBeNull();
expect(getScript(b.script.id)!.current_version_id).toBeNull();
// The rollback deployment mirrors both scripts.
const rbScripts = db()
.prepare(`SELECT * FROM deployment_scripts WHERE deployment_id = ?`)
.all(rb.deploymentId) as any[];
expect(rbScripts.length).toBe(2);
});
});