Rework as scripts-centric, upload-only, gated beta lifecycle

Scripts become first-class: named, versioned, uploaded via the UI, optionally
scoped to a game or map. Any change that would land on scope-matched servers
(marking-required or new version of a scoped script) must first pass a beta.
The beta runs a per-server state machine — deploy -beta.gsc → wait empty →
Komodo restart → wait healthy → optionally wait for a real match end
(games_sp.log 'ExitLevel: executed') — before it's eligible for promotion.
Rollback is always available and restores from .OLD.

The shared/scripts mirror and its betas/ staging folder are gone. Every
server now has a tracked script inventory (server_scripts table), populated
only by explicit upload flows.

New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas +
/api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts
(rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy().
Removed: push.ts, /api/push, BetaPanel, legacy importer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 13:25:06 -04:00
co-authored by Claude Opus 4.7
parent 47051dc79a
commit 93d5a8e2e5
52 changed files with 2605 additions and 1689 deletions
+145
View File
@@ -0,0 +1,145 @@
import { db } from '../db/index.js';
import { emit } from './bus.js';
import type { BetaStage } from './bus.js';
import { komodoConfigured, rconConfigured } from '../config.js';
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
import { komodoRestart, komodoInspectHealthy } from '../integrations/komodo.js';
import { scanForMatchEnd } from '../fleet/match-log.js';
interface BetaServerActiveRow {
beta_id: number;
server: string;
stage: BetaStage;
healthy_at: string | null;
wait_for_health: number;
wait_for_match: number;
}
function nowIso(): string {
return new Date().toISOString();
}
function integrationsOn(): boolean {
return komodoConfigured() && rconConfigured();
}
const setStage = () =>
db().prepare(
`UPDATE beta_servers
SET stage = ?, error = ?, updated_at = ?,
restarted_at = CASE WHEN ? = 'awaiting-health' OR ? = 'ready' THEN COALESCE(restarted_at, ?) ELSE restarted_at END,
healthy_at = CASE WHEN ? = 'awaiting-match' OR (? = 'ready' AND healthy_at IS NULL) THEN COALESCE(healthy_at, ?) ELSE healthy_at END,
match_seen_at = CASE WHEN ? = 'ready' AND match_seen_at IS NULL THEN ? ELSE match_seen_at END
WHERE beta_id = ? AND server = ?`
);
function transition(
betaId: number,
server: string,
stage: BetaStage,
error: string | null = null
): void {
const t = nowIso();
setStage().run(
stage,
error,
t,
stage,
stage,
t,
stage,
stage,
t,
stage,
t,
betaId,
server
);
emit({ type: 'beta-server-stage', betaId, server, stage, error });
}
// Bump beta.state to ready-promote when every one of its beta_servers is ready.
// A single failed row is enough to keep the beta in 'running' (operator retries).
function reconcileBetaState(betaId: number): void {
const rows = db()
.prepare(`SELECT stage FROM beta_servers WHERE beta_id = ?`)
.all(betaId) as Array<{ stage: BetaStage }>;
if (rows.length === 0) return;
if (rows.every((r) => r.stage === 'ready')) {
db()
.prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ? AND state = 'running'`)
.run(betaId);
emit({ type: 'beta-state', betaId, state: 'ready-promote' });
}
}
export async function betaTick(): Promise<{ processed: number }> {
if (!integrationsOn()) return { processed: 0 };
const rows = db()
.prepare(
`SELECT bs.beta_id AS beta_id, bs.server AS server, bs.stage AS stage,
bs.healthy_at AS healthy_at,
b.wait_for_health AS wait_for_health, b.wait_for_match AS wait_for_match
FROM beta_servers bs
JOIN betas b ON b.id = bs.beta_id
WHERE b.state = 'running'
AND bs.stage IN ('awaiting-empty','restarting','awaiting-health','awaiting-match')`
)
.all() as BetaServerActiveRow[];
if (rows.length === 0) return { processed: 0 };
const touchedBetas = new Set<number>();
for (const row of rows) {
try {
await advance(row);
} catch (err) {
transition(row.beta_id, row.server, 'failed', `runner: ${(err as Error).message}`);
}
touchedBetas.add(row.beta_id);
}
for (const id of touchedBetas) reconcileBetaState(id);
return { processed: rows.length };
}
async function advance(row: BetaServerActiveRow): Promise<void> {
switch (row.stage) {
case 'awaiting-empty': {
const players = await rconGetPlayerCount(row.server);
if (players === null) return; // hold — RCON unreachable, try next tick
if (players > 0) return; // hold — still populated
transition(row.beta_id, row.server, 'restarting');
// Fall through to restart on the same tick for lower latency.
await advance({ ...row, stage: 'restarting' });
return;
}
case 'restarting': {
const r = await komodoRestart(row.server);
if (!r.ok) {
transition(row.beta_id, row.server, 'failed', `komodo: ${r.message}`);
return;
}
const next = row.wait_for_health ? 'awaiting-health' : row.wait_for_match ? 'awaiting-match' : 'ready';
transition(row.beta_id, row.server, next);
return;
}
case 'awaiting-health': {
const h = await komodoInspectHealthy(row.server);
if (!h.ok) return; // hold — try next tick
const rcon = await rconStatusResult(row.server);
if (!rcon.ok) return; // container reports up but game not answering yet
const next = row.wait_for_match ? 'awaiting-match' : 'ready';
transition(row.beta_id, row.server, next);
return;
}
case 'awaiting-match': {
const since = row.healthy_at ?? nowIso();
const result = await scanForMatchEnd(row.server, since);
if (!result.ended) return;
transition(row.beta_id, row.server, 'ready');
return;
}
}
}