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
+4 -2
View File
@@ -15,9 +15,11 @@ function num(name: string, fallback: number): number {
}
export const config = {
sharedScripts: req('SHARED_SCRIPTS', '/root/zombies/shared/scripts'),
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
// Where on-disk copies of active beta files live for operator visibility.
// Cleared per-beta on promote/rollback.
betaArchiveDir: req('BETA_ARCHIVE_DIR', '/root/zombies/beta-archive'),
dbPath: req('DB_PATH', './data/cherry.db'),
deployKeepDays: num('DEPLOY_KEEP_DAYS', 7),
httpTimeoutMs: num('HTTP_TIMEOUT_MS', 10_000),
@@ -46,5 +48,5 @@ export function rconConfigured(): boolean {
return !!config.rcon.password;
}
export const betaSrc = () => `${config.sharedScripts}/betas`;
export const destOf = (server: string) => `${config.serversRoot}/${server}/${config.relDest}`;
export const betaArchiveOf = (betaId: number) => `${config.betaArchiveDir}/beta-${betaId}`;
+38 -1
View File
@@ -25,7 +25,10 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' },
{ name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' },
{ name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' },
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' }
{ name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' },
// Script-scope columns — static per server, populated via server-edit UI.
{ name: 'game', ddl: 'ALTER TABLE servers ADD COLUMN game TEXT' },
{ name: 'map', ddl: 'ALTER TABLE servers ADD COLUMN map TEXT' }
];
function migrateServers(handle: Database.Database): void {
@@ -38,6 +41,39 @@ function migrateServers(handle: Database.Database): void {
}
}
// 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
// detect and drop them first. Any outstanding rows abort the boot so the
// operator can promote/rollback in the old app before upgrading.
function dropLegacyBetaTables(handle: Database.Database): void {
const legacy = handle
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('betas','beta_scripts','beta_deploys')")
.all() as Array<{ name: string }>;
if (legacy.length === 0) return;
const legacyNames = legacy.map((r) => r.name);
// The old `betas` table had a `slug` column; the new one has `id` + `script_id`.
// If we see `slug`, we know it's the old shape.
const betasCols = handle.prepare("PRAGMA table_info(betas)").all() as Array<{ name: string }>;
const isLegacyShape = betasCols.some((c) => c.name === 'slug');
if (!isLegacyShape) return;
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
if (!legacyNames.includes(table)) continue;
const row = handle.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number };
if (row.n > 0) {
throw new Error(
`legacy ${table} still has ${row.n} rows — promote or rollback outstanding betas in the previous version before upgrading`
);
}
}
// Drop in FK-safe order.
for (const table of ['beta_deploys', 'beta_scripts', 'betas']) {
if (legacyNames.includes(table)) handle.exec(`DROP TABLE ${table}`);
}
}
export function db(): Database.Database {
if (_db) return _db;
@@ -45,6 +81,7 @@ export function db(): Database.Database {
_db = new Database(config.dbPath);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
dropLegacyBetaTables(_db);
_db.exec(schemaSql);
migrateServers(_db);
+70 -29
View File
@@ -1,5 +1,7 @@
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
-- Mirrors the shapes tracked in the legacy script_metadata.json but in real SQL.
-- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this
-- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model
-- lives here.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
@@ -11,6 +13,8 @@ CREATE TABLE IF NOT EXISTS servers (
komodo_server TEXT,
game_port INTEGER, -- UDP RCON port; lazy-filled from Komodo
rcon_host TEXT, -- optional per-server override of RCON_HOST
game TEXT, -- 't5' etc.; static per-server (no map rotation)
map TEXT, -- 'zombie_theater' etc.; static per-server
last_players INTEGER,
last_checked_at TEXT,
last_map TEXT, -- from RCON getstatus mapname
@@ -22,37 +26,74 @@ CREATE TABLE IF NOT EXISTS servers (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- One row per script (a logical thing, e.g. "HUD tweaks"). Name is unique and
-- doubles as the slug in URLs.
CREATE TABLE IF NOT EXISTS scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
target_rel_path TEXT NOT NULL, -- 'maps/mp_kino/hud.gsc' or 'hud.gsc'
scope_type TEXT, -- NULL | 'game' | 'map'
scope_value TEXT, -- NULL | 't5' | 'zombie_theater'
current_version_id INTEGER, -- FK → script_versions(id) after first promote
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_scripts_scope ON scripts(scope_type, scope_value);
CREATE TABLE IF NOT EXISTS script_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
content BLOB NOT NULL,
notes TEXT,
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(script_id, version)
);
CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id);
CREATE TABLE IF NOT EXISTS server_scripts (
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
rel_path TEXT NOT NULL,
script_version_id INTEGER REFERENCES script_versions(id) ON DELETE SET NULL,
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
updated_at TEXT NOT NULL,
source TEXT, -- 'beta:<id>' | 'promote:<id>' | 'rollback:<id>'
PRIMARY KEY (server, rel_path)
);
CREATE INDEX IF NOT EXISTS idx_server_scripts_server ON server_scripts(server);
CREATE TABLE IF NOT EXISTS betas (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL,
opened_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1
id INTEGER PRIMARY KEY AUTOINCREMENT,
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'
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'
opened_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
closed_at TEXT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_betas_state ON betas(state);
CREATE INDEX IF NOT EXISTS idx_betas_script ON betas(script_id);
CREATE TABLE IF NOT EXISTS beta_scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
beta_slug TEXT NOT NULL REFERENCES betas(slug) ON DELETE CASCADE,
staged_as TEXT NOT NULL,
target TEXT NOT NULL,
beta_file TEXT NOT NULL,
backup TEXT NOT NULL,
sha256 TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1
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),
stage TEXT NOT NULL, -- 'deploying'|'awaiting-empty'|'restarting'|'awaiting-health'|'awaiting-match'|'ready'|'failed'
error TEXT,
deployed_at TEXT,
restarted_at TEXT,
healthy_at TEXT,
match_seen_at TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (beta_id, server)
);
CREATE INDEX IF NOT EXISTS idx_beta_scripts_slug ON beta_scripts(beta_slug);
CREATE TABLE IF NOT EXISTS beta_deploys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
beta_script_id INTEGER NOT NULL REFERENCES beta_scripts(id) ON DELETE CASCADE,
server TEXT NOT NULL REFERENCES servers(name),
had_original INTEGER NOT NULL,
UNIQUE(beta_script_id, server)
);
CREATE INDEX IF NOT EXISTS idx_beta_deploys_server ON beta_deploys(server);
CREATE INDEX IF NOT EXISTS idx_beta_servers_stage ON beta_servers(stage);
CREATE TABLE IF NOT EXISTS deployments (
id TEXT PRIMARY KEY,
@@ -83,7 +124,7 @@ CREATE TABLE IF NOT EXISTS deployment_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
server TEXT NOT NULL,
verb TEXT NOT NULL, -- added | updated | removed | held
verb TEXT NOT NULL, -- added | updated | removed
rel_path TEXT NOT NULL,
sha256_before TEXT,
sha256_after TEXT,
+355 -373
View File
@@ -1,455 +1,437 @@
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, posix } from 'node:path';
import { betaSrc, config, destOf } from '../config.js';
import { betaArchiveOf, config, destOf } from '../config.js';
import { db } from '../db/index.js';
import { betaFile, oldFile, sha256Buffer } from './files.js';
import { ensureDest, getServer, heldByBeta } from './servers.js';
import {
betaFile,
findTargets,
listSourceFiles,
oldFile,
sha256,
slugify,
stagedFiles
} from './files.js';
import { allServers, ensureDest, matchServers, ownerOf } from './servers.js';
import { doPush } from './push.js';
getScript,
getVersion,
listVersions,
serversMatchingScope,
type ScriptRow,
type ScriptVersionRow
} from './scripts.js';
import { recordScript, removeScript, getServerScript } from './inventory.js';
import { emit } from '../pipeline/bus.js';
import type { BetaStage } from '../pipeline/bus.js';
// -- shared row shapes --
export type BetaTriggerKind = 'mark-required' | 'version-update' | 'unscoped';
interface BetaRow {
slug: string;
name: string;
folder: string;
opened_at: string;
updated_at: string;
revision: number;
}
interface BetaScriptRow {
export interface BetaRow {
id: number;
beta_slug: string;
staged_as: string;
target: string;
beta_file: string;
backup: string;
sha256: string;
revision: number;
script_id: number;
script_version_id: number;
trigger_kind: BetaTriggerKind;
wait_for_health: number;
wait_for_match: number;
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
opened_at: string;
closed_at: string | null;
notes: string | null;
}
interface BetaDeployRow {
beta_script_id: number;
export interface BetaServerRow {
beta_id: number;
server: string;
had_original: number;
target: string;
beta_file: string;
backup: string;
}
function nowIso(): string {
return new Date().toISOString();
}
function getBeta(slug: string): BetaRow | undefined {
return db().prepare('SELECT * FROM betas WHERE slug = ?').get(slug) as BetaRow | undefined;
}
export function listBetas(): BetaRow[] {
return db().prepare('SELECT * FROM betas ORDER BY opened_at').all() as BetaRow[];
}
export function listBetaScripts(slug: string): BetaScriptRow[] {
return db()
.prepare('SELECT * FROM beta_scripts WHERE beta_slug = ? ORDER BY id')
.all(slug) as BetaScriptRow[];
}
export function listBetaDeploys(slug: string): BetaDeployRow[] {
return db()
.prepare(
`SELECT bd.beta_script_id, bd.server, bd.had_original,
bs.target, bs.beta_file, bs.backup
FROM beta_deploys bd
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
WHERE bs.beta_slug = ?`
)
.all(slug) as BetaDeployRow[];
}
export function listBetaServers(slug: string): string[] {
return (
db()
.prepare(
`SELECT DISTINCT bd.server AS server
FROM beta_deploys bd
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
WHERE bs.beta_slug = ?
ORDER BY bd.server`
)
.all(slug) as Array<{ server: string }>
).map((r) => r.server);
}
// -- open a beta (do_beta, push_scripts.sh:983) --
export interface OpenBetaOptions {
name: string;
patterns?: string[];
dryRun?: boolean;
create?: boolean;
}
export interface OpenBetaResult {
slug: string;
name: string;
servers: string[];
scripts: Array<{ stagedAs: string; target: string }>;
dryRun: boolean;
prior_version_id: number | null;
stage: BetaStage;
error: string | null;
deployed_at: string | null;
restarted_at: string | null;
healthy_at: string | null;
match_seen_at: string | null;
updated_at: string;
}
export class BetaError extends Error {
constructor(
message: string,
public readonly kind:
| 'name-empty'
| 'already-open'
| 'stale-beta-dir'
| 'no-staging-dir'
| 'nothing-staged'
| 'ambiguous'
| 'no-such-script'
| 'no-such-version'
| 'version-script-mismatch'
| 'no-servers'
| 'unknown-server'
| 'out-of-scope'
| 'conflict'
| 'blocked-backup'
| 'empty-plan'
| 'missing-staged'
| 'no-such-beta'
| 'no-match',
| 'not-ready-promote'
| 'not-running'
| 'dest-fail'
| 'invalid-state',
public readonly details?: unknown
) {
super(message);
}
}
export async function openBeta(opts: OpenBetaOptions): Promise<OpenBetaResult> {
const { name, patterns = ['*'], dryRun = false, create = false } = opts;
const slug = slugify(name);
if (!slug) throw new BetaError('beta name needs at least one letter or number', 'name-empty');
function nowIso(): string {
return new Date().toISOString();
}
const betaDir = `${betaSrc()}/${slug}`;
// ---------- Reads ----------
if (getBeta(slug)) throw new BetaError(`a beta called '${name}' is already open`, 'already-open');
if (existsSync(betaDir)) {
throw new BetaError(
`${betaDir} already exists but no beta is open for it — move or delete it first`,
'stale-beta-dir'
export function getBeta(id: number): BetaRow | undefined {
return db().prepare('SELECT * FROM betas WHERE id = ?').get(id) as BetaRow | undefined;
}
export function listBetas(state?: BetaRow['state']): BetaRow[] {
if (state) {
return db().prepare('SELECT * FROM betas WHERE state = ? ORDER BY opened_at DESC').all(state) as BetaRow[];
}
return db().prepare('SELECT * FROM betas ORDER BY opened_at DESC').all() as BetaRow[];
}
export function listBetaServers(betaId: number): BetaServerRow[] {
return db()
.prepare('SELECT * FROM beta_servers WHERE beta_id = ? ORDER BY server')
.all(betaId) as BetaServerRow[];
}
export function findRunningBetaForScript(scriptId: number): BetaRow | undefined {
return db()
.prepare(`SELECT * FROM betas WHERE script_id = ? AND state = 'running' LIMIT 1`)
.get(scriptId) as BetaRow | undefined;
}
// ---------- openBeta ----------
export interface OpenBetaInput {
scriptId: number;
scriptVersionId: number;
servers: string[];
waitForHealth: boolean;
waitForMatch: boolean;
triggerKind: BetaTriggerKind;
notes?: string;
}
export interface OpenBetaResult {
betaId: number;
servers: string[];
}
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.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 }> = [];
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 (!existsSync(betaSrc())) {
throw new BetaError(
`no staging folder at ${betaSrc()} — create it and drop the scripts you want to test`,
'no-staging-dir'
);
}
const staged = await stagedFiles();
if (staged.length === 0) {
throw new BetaError(`nothing staged in ${betaSrc()}`, 'nothing-staged');
}
const targets: Array<{ base: string; target: string }> = [];
const ambiguous: Array<{ base: string; hits: string[] }> = [];
for (const base of staged) {
const hits = await findTargets(base);
if (hits.length === 0) targets.push({ base, target: base });
else if (hits.length === 1) targets.push({ base, target: hits[0] });
else ambiguous.push({ base, hits });
}
if (ambiguous.length > 0) {
throw new BetaError('more than one script matches — cannot tell which to replace', 'ambiguous', ambiguous);
}
const servers = matchServers(await allServers(), patterns);
if (servers.length === 0) throw new BetaError('no servers matched', 'no-servers');
// build the plan (server × target) while checking conflicts and stale backups
const plan: Array<{ server: string; base: string; target: string; had: boolean }> = [];
const conflicts: Array<{ server: string; target: string; owner: string }> = [];
const blockedBackups: Array<{ server: string; ofile: string }> = [];
for (const server of servers) {
const dest = await ensureDest(server, create);
if (!dest.ok) continue;
for (const { base, target } of targets) {
const owner = ownerOf(server, target);
if (owner) {
conflicts.push({ server, target, owner });
continue;
}
const had = existsSync(join(dest.path, target));
const ofile = oldFile(target);
if (had && existsSync(join(dest.path, ofile))) {
blockedBackups.push({ server, ofile });
continue;
}
plan.push({ server, base, target, had });
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)
);
}
}
if (conflicts.length > 0)
throw new BetaError('script already belongs to another beta', 'conflict', conflicts);
if (blockedBackups.length > 0)
throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blockedBackups);
if (plan.length === 0) throw new BetaError('no usable servers — nothing was changed', 'empty-plan');
// Per-server conflict check: is another running beta already holding this
// target on this server?
const conflicts: Array<{ server: string; betaId: 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 (dryRun) {
return {
slug,
name,
servers: [...new Set(plan.map((p) => p.server))],
scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })),
dryRun: true
};
// 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) });
}
}
if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts);
if (blocked.length > 0) throw new BetaError('stale .OLD backups in the way', 'blocked-backup', blocked);
// Ensure destinations exist (create=true so a fresh server picks up cleanly).
for (const name of input.servers) {
const ok = await ensureDest(name, true);
if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail');
}
// ---- commit ----
const opened = nowIso();
// Insert rows in a transaction — disk work happens after commit so we can
// await file I/O without holding the DB lock.
const insertBeta = db().prepare(
`INSERT INTO betas (slug, name, folder, opened_at, updated_at, revision)
VALUES (?, ?, ?, ?, ?, 1)`
`INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes)
VALUES (?, ?, ?, ?, ?, 'running', ?)`
);
const insertScript = db().prepare(
`INSERT INTO beta_scripts (beta_slug, staged_as, target, beta_file, backup, sha256, revision)
VALUES (?, ?, ?, ?, ?, ?, 1)`
);
const insertDeploy = db().prepare(
`INSERT INTO beta_deploys (beta_script_id, server, had_original) VALUES (?, ?, ?)`
const insertBetaServer = db().prepare(
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
VALUES (?, ?, ?, ?, 'deploying', ?)`
);
const scriptIdByTarget = new Map<string, number>();
const tx = db().transaction(() => {
insertBeta.run(slug, name, `betas/${slug}`, opened, opened);
for (const { base, target } of targets) {
const bfile = betaFile(target);
const backup = oldFile(target);
const sha = 'pending'; // filled after we compute below
const res = insertScript.run(slug, base, target, bfile, backup, sha);
scriptIdByTarget.set(target, Number(res.lastInsertRowid));
}
for (const { server, target, had } of plan) {
insertDeploy.run(scriptIdByTarget.get(target)!, server, had ? 1 : 0);
}
});
tx();
let betaId!: number;
db().transaction(() => {
const res = insertBeta.run(
script.id,
version.id,
input.triggerKind,
input.waitForHealth ? 1 : 0,
input.waitForMatch ? 1 : 0,
input.notes ?? null
);
betaId = Number(res.lastInsertRowid);
// Compute shas outside tx (I/O), then patch
for (const { base, target } of targets) {
const sha = await sha256(`${betaSrc()}/${base}`);
db().prepare('UPDATE beta_scripts SET sha256 = ? WHERE beta_slug = ? AND target = ?')
.run(sha, slug, target);
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()
);
}
})();
// 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);
const archive = betaArchiveOf(betaId);
await mkdir(archive, { recursive: true });
await writeFile(join(archive, posix.basename(bfile)), version.content);
const updateStage = db().prepare(
`UPDATE beta_servers
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
WHERE beta_id = ? AND server = ?`
);
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}`);
}
}
await writeFile(betaPath, version.content);
recordScript(name, bfile, version.id, version.sha256, version.size, `beta:${betaId}`);
const t = nowIso();
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}` });
}
}
// Move files on disk
for (const { server, base, target, had } of plan) {
const dest = destOf(server);
const bfile = betaFile(target);
const ofile = oldFile(target);
await mkdir(join(dest, dirname(target)), { recursive: true });
if (had) {
await rename(join(dest, target), join(dest, ofile));
}
await copyFile(join(betaSrc(), base), join(dest, bfile));
}
emit({ type: 'beta-opened', betaId, scriptId: script.id, servers: input.servers });
// Move staged files into betas/<slug>/ under their beta_file name
await mkdir(betaDir, { recursive: true });
for (const { base, target } of targets) {
const bfile = betaFile(target);
await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile)));
}
emit({
type: 'deployment-opened',
deploymentId: `beta:${slug}`,
action: `beta:${slug}`,
servers: [...new Set(plan.map((p) => p.server))]
});
return {
slug,
name,
servers: [...new Set(plan.map((p) => p.server))],
scripts: targets.map((t) => ({ stagedAs: t.base, target: t.target })),
dryRun: false
};
return { betaId, servers: input.servers };
}
// -- update an open beta (do_update_beta, push_scripts.sh:1153) --
export interface UpdateBetaResult {
slug: string;
replaced: string[];
skipped: string[];
revision: number;
dryRun: boolean;
}
export async function updateBeta(name: string, dryRun = false): Promise<UpdateBetaResult> {
const slug = slugify(name);
const beta = getBeta(slug);
if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
const staged = await stagedFiles();
if (staged.length === 0) throw new BetaError('nothing staged', 'nothing-staged');
const known = listBetaScripts(slug);
const matched: Array<{ base: string; target: string; betaFile: string }> = [];
const skipped: string[] = [];
for (const base of staged) {
const hit = known.find((k) => k.staged_as === base || posix.basename(k.target) === base);
if (hit) matched.push({ base, target: hit.target, betaFile: hit.beta_file });
else skipped.push(base);
}
if (matched.length === 0) throw new BetaError('none of the staged files belong to this beta', 'no-match');
if (dryRun) {
return { slug, replaced: matched.map((m) => m.target), skipped, revision: beta.revision, dryRun: true };
}
const betaDir = `${betaSrc()}/${slug}`;
const now = nowIso();
// For each matched target, copy staged file to every deployed server,
// move staged file into betas/<slug>/, bump revision.
for (const { base, target, betaFile: bfile } of matched) {
const deploys = listBetaDeploys(slug).filter((d) => d.target === target);
for (const d of deploys) {
const dest = destOf(d.server);
if (!existsSync(dest)) continue;
await mkdir(join(dest, dirname(target)), { recursive: true });
await copyFile(join(betaSrc(), base), join(dest, bfile));
}
await rename(join(betaSrc(), base), join(betaDir, posix.basename(bfile)));
const sha = await sha256(join(betaDir, posix.basename(bfile)));
db()
.prepare(
`UPDATE beta_scripts
SET sha256 = ?, revision = revision + 1
WHERE beta_slug = ? AND target = ?`
)
.run(sha, slug, target);
}
db()
.prepare('UPDATE betas SET revision = revision + 1, updated_at = ? WHERE slug = ?')
.run(now, slug);
const updated = getBeta(slug)!;
return { slug, replaced: matched.map((m) => m.target), skipped, revision: updated.revision, dryRun: false };
}
// -- rollback (do_rollback, push_scripts.sh:1253) --
// ---------- rollback ----------
export interface RollbackResult {
slug: string;
betaId: number;
restored: string[];
dropped: string[];
warnings: string[];
dryRun: boolean;
}
export async function rollbackBeta(name: string, dryRun = false): Promise<RollbackResult> {
const slug = slugify(name);
if (!getBeta(slug)) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
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 betaDir = `${betaSrc()}/${slug}`;
const deploys = listBetaDeploys(slug);
const rows = listBetaServers(betaId);
const restored: string[] = [];
const dropped: string[] = [];
const warnings: string[] = [];
if (dryRun) {
for (const d of deploys) {
if (d.had_original) restored.push(d.target);
else dropped.push(d.target);
}
return { slug, restored, dropped, warnings, dryRun: true };
}
for (const bs of rows) {
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 d of deploys) {
const dest = destOf(d.server);
const bpath = join(dest, d.beta_file);
const opath = join(dest, d.backup);
const tpath = join(dest, d.target);
if (existsSync(bpath)) await rm(bpath, { force: true });
if (d.had_original) {
if (existsSync(opath)) {
await rename(opath, tpath);
restored.push(d.target);
// 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}`);
} 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}`
);
}
restored.push(bs.server);
} else {
warnings.push(`${d.server}: no ${d.backup} to restore`);
warnings.push(`${bs.server}: no ${ofile} to restore`);
}
} else {
dropped.push(d.target);
dropped.push(bs.server);
}
}
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug); // cascades to scripts/deploys
// Wipe archive + close out beta.
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
return { slug, restored, dropped, warnings, dryRun: false };
db()
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
.run(nowIso(), betaId);
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
return { betaId, restored, dropped, warnings };
}
// -- promote (do_promote, push_scripts.sh:1325) --
// ---------- promote ----------
export interface PromoteResult {
slug: string;
landed: string[];
dryRun: boolean;
betaId: number;
landed: string[]; // servers we wrote the promoted target to
versionId: number;
}
export async function promoteBeta(name: string, dryRun = false): Promise<PromoteResult> {
const slug = slugify(name);
const beta = getBeta(slug);
if (!beta) throw new BetaError(`no open beta called '${name}'`, 'no-such-beta');
const betaDir = `${betaSrc()}/${slug}`;
const scripts = listBetaScripts(slug);
// Sanity: every beta_file must exist in the beta dir on disk
const lost: string[] = [];
for (const s of scripts) {
const p = join(betaDir, posix.basename(s.beta_file));
if (!existsSync(p)) lost.push(posix.basename(s.beta_file));
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'ready-promote') {
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
}
if (lost.length > 0)
throw new BetaError('scripts missing from betas dir — cannot promote', 'missing-staged', lost);
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 deploys = listBetaDeploys(slug);
const betaServers = listBetaServers(betaId).map((r) => r.server);
// For scoped scripts, promotion applies to every matching server (whether
// or not they were in the beta). For unscoped, just the beta servers.
const scopeServers = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value)
: [];
const allTargets = Array.from(new Set([...betaServers, ...scopeServers]));
if (dryRun) {
return { slug, landed: scripts.map((s) => s.target), dryRun: true };
const landed: string[] = [];
for (const name of allTargets) {
const dest = destOf(name);
const targetPath = join(dest, 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);
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `promote:${betaId}`);
landed.push(name);
} catch (err) {
// Best-effort continue; error will surface via the beta detail UI.
console.error(`[promote] ${name} failed:`, err);
}
}
// Clear beta files + backups off every server
for (const d of deploys) {
const dest = destOf(d.server);
await rm(join(dest, d.beta_file), { force: true });
if (d.had_original) await rm(join(dest, d.backup), { force: true });
}
// 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);
// Move staged betas/<slug>/*.gsc back into shared/scripts under real name
for (const s of scripts) {
await mkdir(join(config.sharedScripts, dirname(s.target)), { recursive: true });
await rename(join(betaDir, posix.basename(s.beta_file)), join(config.sharedScripts, s.target));
}
const archive = betaArchiveOf(betaId);
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
if (existsSync(betaDir)) await rm(betaDir, { recursive: true, force: true });
db().prepare('DELETE FROM betas WHERE slug = ?').run(slug);
emit({ type: 'beta-state', betaId, state: 'promoted' });
// Then push to every server — same behavior as the shell script's promote (line 1391)
await doPush({ patterns: ['*'], action: `promote:${slug}`, create: false });
return { slug, landed: scripts.map((s) => s.target), dryRun: false };
return { betaId, landed, versionId: version.id };
}
// ---------- helpers used by API layer ----------
// Latest of the script's versions — useful for the "open beta with this
// version" default in the UI.
export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | null {
const rows = listVersions(script.id);
return rows[0] ?? null;
}
/**
* 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
* server names that had files deleted.
*/
export async function unsetScopeAndCleanup(scriptId: number): Promise<string[]> {
const script = getScript(scriptId);
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
if (!script.scope_type || !script.scope_value) return [];
const matched = serversMatchingScope(script.scope_type, script.scope_value);
const cleaned: string[] = [];
for (const name of matched) {
const dest = destOf(name);
const targetPath = join(dest, script.target_rel_path);
if (existsSync(targetPath)) {
await rm(targetPath, { force: true });
removeScript(name, script.target_rel_path);
cleaned.push(name);
}
}
db().prepare('UPDATE scripts SET scope_type = NULL, scope_value = NULL WHERE id = ?').run(scriptId);
return cleaned;
}
+11 -99
View File
@@ -1,93 +1,12 @@
import { createHash } from 'node:crypto';
import { readdir, readFile, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, posix, relative, sep } from 'node:path';
import { betaSrc, config } from '../config.js';
import { readFile, stat } from 'node:fs/promises';
import { posix } from 'node:path';
// Ported from push_scripts.sh:414-464. These are the exact filter rules —
// change one and the parity check against the shell script will fail.
const EXCLUDE_NAMES_EXACT = new Set(['.git']);
const EXCLUDE_SUFFIXES = ['.swp', '.OLD'];
const EXCLUDE_TRAILING = ['~'];
function isExcluded(name: string): boolean {
if (EXCLUDE_NAMES_EXACT.has(name)) return true;
if (name.startsWith('.')) return false; // dotfiles allowed except .git
for (const s of EXCLUDE_SUFFIXES) if (name.endsWith(s)) return true;
for (const t of EXCLUDE_TRAILING) if (name.endsWith(t)) return true;
// *-beta.* — script line 417 excludes any file with "-beta." in its name
if (/-beta\./.test(name)) return true;
return false;
}
function toPosix(p: string): string {
return p.split(sep).join(posix.sep);
}
async function walk(root: string, prune: (rel: string) => boolean): Promise<string[]> {
const out: string[] = [];
async function recur(dir: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = join(dir, e.name);
const rel = toPosix(relative(root, abs));
if (prune(rel)) continue;
if (e.isDirectory()) {
await recur(abs);
} else if (e.isFile()) {
if (isExcluded(e.name)) continue;
out.push(rel);
}
}
}
await recur(root);
return out.sort();
}
// Naming rules for beta artifacts, preserved from the legacy shell script.
/**
* Every file under SHARED_SCRIPTS except the betas/ tree.
* Mirrors `list_source_files` in push_scripts.sh:414.
*/
export async function listSourceFiles(): Promise<string[]> {
return walk(config.sharedScripts, (rel) => rel === 'betas' || rel.startsWith('betas/'));
}
/**
* Every file directly in betas/ (one level deep, ignore subfolders — those
* are open betas). Mirrors `staged_files` in push_scripts.sh:420.
*/
export async function stagedFiles(): Promise<string[]> {
if (!existsSync(betaSrc())) return [];
let entries;
try {
entries = await readdir(betaSrc(), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isFile() && !isExcluded(e.name) && !e.name.startsWith('.'))
.map((e) => e.name)
.sort();
}
/**
* Find every source file whose basename matches `base`.
* Mirrors `find_targets` in push_scripts.sh:426.
*/
export async function findTargets(base: string): Promise<string[]> {
const all = await listSourceFiles();
return all.filter((rel) => posix.basename(rel) === base);
}
/**
* `foo/bar.gsc` -> `foo/bar-beta.gsc`
* Mirrors `beta_file` in push_scripts.sh:450.
* `foo/bar.gsc` → `foo/bar-beta.gsc`. Used to name the beta copy that lives
* next to (or in place of) the target file on each server during a beta.
*/
export function betaFile(rel: string): string {
const dir = posix.dirname(rel);
@@ -100,8 +19,8 @@ export function betaFile(rel: string): string {
}
/**
* `foo/bar.gsc` -> `foo/bar.OLD`
* Mirrors `old_file` in push_scripts.sh:458.
* `foo/bar.gsc` → `foo/bar.OLD`. Used to back up the previous target when a
* beta lands on top of it, so rollback can restore.
*/
export function oldFile(rel: string): string {
const dir = posix.dirname(rel);
@@ -112,17 +31,6 @@ export function oldFile(rel: string): string {
return dir === '.' ? named : `${dir}/${named}`;
}
/**
* Slugify a beta name the same way the shell script does.
* Mirrors `slugify` in push_scripts.sh:399.
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export async function sha256(path: string): Promise<string> {
const buf = await readFile(path);
return createHash('sha256').update(buf).digest('hex');
@@ -136,3 +44,7 @@ export async function sha256OrNull(path: string): Promise<string | null> {
}
return sha256(path);
}
export function sha256Buffer(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
+58
View File
@@ -0,0 +1,58 @@
import { db } from '../db/index.js';
export interface ServerScriptRow {
server: string;
rel_path: string;
script_version_id: number | null;
sha256: string;
size: number;
updated_at: string;
source: string | null;
}
// Upsert an inventory row for a file that just landed on a server. Called from
// every writer (beta open, beta update, promote, rollback) so the DB tracks
// exactly what's on-disk in each server's sp/ directory.
export function recordScript(
server: string,
relPath: string,
scriptVersionId: number | null,
sha256: string,
size: number,
source: string
): void {
db()
.prepare(
`INSERT INTO server_scripts (server, rel_path, script_version_id, sha256, size, updated_at, source)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(server, rel_path) DO UPDATE SET
script_version_id = excluded.script_version_id,
sha256 = excluded.sha256,
size = excluded.size,
updated_at = excluded.updated_at,
source = excluded.source`
)
.run(server, relPath, scriptVersionId, sha256, size, new Date().toISOString(), source);
}
export function removeScript(server: string, relPath: string): void {
db().prepare('DELETE FROM server_scripts WHERE server = ? AND rel_path = ?').run(server, relPath);
}
export function getServerScript(server: string, relPath: string): ServerScriptRow | undefined {
return db()
.prepare('SELECT * FROM server_scripts WHERE server = ? AND rel_path = ?')
.get(server, relPath) as ServerScriptRow | undefined;
}
export function listServerScripts(server: string): ServerScriptRow[] {
return db()
.prepare('SELECT * FROM server_scripts WHERE server = ? ORDER BY rel_path')
.all(server) as ServerScriptRow[];
}
export function listAllScripts(): ServerScriptRow[] {
return db()
.prepare('SELECT * FROM server_scripts ORDER BY server, rel_path')
.all() as ServerScriptRow[];
}
+38
View File
@@ -0,0 +1,38 @@
import { resolveLogPath, tailLog } from './logs.js';
// Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
// A REAL completed match always emits `ExitLevel: executed` right before
// `ShutdownGame:`. Idle map cycles print `InitGame:` / `ShutdownGame:` at
// 0:00 with no ExitLevel — those don't count as a played match.
//
// So the reliable discriminator is `ExitLevel: executed`: seeing that line
// after `healthy_at` means a game actually ended normally (no crash — a crash
// would kill the container before ExitLevel fires and take RCON with it).
export const MATCH_START = /InitGame:/;
export const MATCH_END = /ExitLevel:\s*executed/;
/**
* Poll a server's games_sp.log tail for match markers. Returns whether a
* completed match end has been observed since `sinceIso` (the server's
* `healthy_at` timestamp).
*/
export async function scanForMatchEnd(
serverName: string,
sinceIso: string
): Promise<{ ended: boolean; started: boolean; reason?: string }> {
const path = resolveLogPath(serverName, 'games_sp');
if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
const tail = await tailLog(path, 2000);
if (!tail) return { ended: false, started: false, reason: 'log not written yet' };
if (tail.mtime < sinceIso) return { ended: false, started: false, reason: 'no log activity since healthy' };
let started = false;
let ended = false;
for (const line of tail.lines) {
if (MATCH_START.test(line)) started = true;
if (MATCH_END.test(line)) ended = true;
}
return { ended, started };
}
-239
View File
@@ -1,239 +0,0 @@
import { copyFile, mkdir, readdir, readFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { config, destOf } from '../config.js';
import { db } from '../db/index.js';
import { listSourceFiles, sha256, sha256OrNull } from './files.js';
import { allServers, ensureDest, heldByBeta, matchServers } from './servers.js';
import { emit } from '../pipeline/bus.js';
export type FileVerb = 'added' | 'updated' | 'removed' | 'held';
export interface FileChange {
verb: FileVerb;
relPath: string;
sha256Before: string | null;
sha256After: string | null;
// held rows carry the beta slug in `error` for the audit log
heldBy?: string;
}
export interface ServerPlan {
server: string;
dest: string;
changes: FileChange[];
heldTargets: string[];
skipped?: string;
}
export interface PushOptions {
patterns?: string[]; // server-name globs; default ['*']
dryRun?: boolean;
delete?: boolean; // remove dest files not in source
create?: boolean; // create sp/ if missing
triggeredBy?: 'ui' | 'sweep' | 'cron';
action?: string; // 'push' | 'beta:<slug>' | ...
}
export interface PushResult {
deploymentId: string | null; // null when dryRun or nothing changed
plans: ServerPlan[];
totalServers: number;
totalChangedServers: number;
totalFiles: number;
}
function nowIso(): string {
return new Date().toISOString().replace(/\.\d+Z$/, (m) => m.replace(/\d{3}Z$/, 'Z'));
}
let _depSeq = 0;
function deploymentId(): string {
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
// Seconds-precision timestamp + monotonic counter — two pushes in the
// same second (e.g. a beta open followed by its post-promote push) must
// still get distinct IDs. Same shape as `dep-<utc>-<n>` in the plan.
return `dep-${stamp}-${(++_depSeq).toString(36)}`;
}
/**
* Compute the change set for a single server without touching the disk.
* Splitting plan-from-apply is what lets dry-run share code with commit.
*/
async function planServer(
server: string,
sourceFiles: string[],
opts: { deleteExtras: boolean; create: boolean }
): Promise<ServerPlan | null> {
const dest = destOf(server);
const ok = await ensureDest(server, opts.create);
if (!ok.ok) return { server, dest, changes: [], heldTargets: [], skipped: ok.reason };
const held = heldByBeta(server);
const heldTargets = [...held.keys()];
const sourceSet = new Set(sourceFiles);
const changes: FileChange[] = [];
for (const rel of sourceFiles) {
if (held.has(rel)) continue;
const src = join(config.sharedScripts, rel);
const dst = join(dest, rel);
const dstSha = await sha256OrNull(dst);
const srcSha = await sha256(src);
if (dstSha === null) {
changes.push({ verb: 'added', relPath: rel, sha256Before: null, sha256After: srcSha });
} else if (dstSha !== srcSha) {
changes.push({ verb: 'updated', relPath: rel, sha256Before: dstSha, sha256After: srcSha });
}
// matching sha → skip
}
if (opts.deleteExtras) {
// walk dest and remove files that aren't in the source set (and
// aren't held-by-beta artifacts, which include -beta.gsc and .OLD)
const destFiles = await walkDest(dest);
for (const rel of destFiles) {
if (rel.endsWith('.OLD')) continue;
if (/-beta\./.test(rel)) continue;
if (sourceSet.has(rel)) continue;
if (held.has(rel)) continue;
const before = await sha256OrNull(join(dest, rel));
changes.push({ verb: 'removed', relPath: rel, sha256Before: before, sha256After: null });
}
}
return { server, dest, changes, heldTargets };
}
async function walkDest(root: string): Promise<string[]> {
const out: string[] = [];
async function recur(dir: string, prefix: string): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const rel = prefix ? `${prefix}/${e.name}` : e.name;
if (e.isDirectory()) await recur(join(dir, e.name), rel);
else if (e.isFile()) out.push(rel);
}
}
await recur(root, '');
return out.sort();
}
/**
* Apply a plan: write files, capture content blobs for the audit log.
*/
async function applyPlan(plan: ServerPlan): Promise<Array<FileChange & { contentBefore: Buffer | null; contentAfter: Buffer | null }>> {
const applied: Array<FileChange & { contentBefore: Buffer | null; contentAfter: Buffer | null }> = [];
for (const change of plan.changes) {
const dst = join(plan.dest, change.relPath);
if (change.verb === 'added' || change.verb === 'updated') {
const src = join(config.sharedScripts, change.relPath);
const contentBefore = change.verb === 'updated' ? await readFile(dst) : null;
const contentAfter = await readFile(src);
await mkdir(dirname(dst), { recursive: true });
await copyFile(src, dst);
applied.push({ ...change, contentBefore, contentAfter });
} else if (change.verb === 'removed') {
const contentBefore = existsSync(dst) ? await readFile(dst) : null;
await rm(dst, { force: true });
applied.push({ ...change, contentBefore, contentAfter: null });
}
}
return applied;
}
/**
* The main push entry point. Ports `do_push` in push_scripts.sh:897.
* Uses node:fs (not shelling out to rsync) but matches the same
* added/updated/removed classification and held-by-beta exclusion rules.
*/
export async function doPush(opts: PushOptions = {}): Promise<PushResult> {
const { patterns = ['*'], dryRun = false, delete: deleteExtras = false, create = false } = opts;
const sourceFiles = await listSourceFiles();
const servers = matchServers(await allServers(), patterns);
const plans: ServerPlan[] = [];
let totalServers = 0;
let totalChangedServers = 0;
let totalFiles = 0;
for (const server of servers) {
const plan = await planServer(server, sourceFiles, { deleteExtras, create });
if (!plan) continue;
totalServers++;
if (!plan.skipped && plan.changes.length > 0) {
totalChangedServers++;
totalFiles += plan.changes.length;
}
plans.push(plan);
}
if (dryRun) {
return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles };
}
// No changes → no deployment row. Same behavior as `deploy_finish` line 806.
const changedPlans = plans.filter((p) => !p.skipped && p.changes.length > 0);
if (changedPlans.length === 0) {
return { deploymentId: null, plans, totalServers, totalChangedServers, totalFiles };
}
const depId = deploymentId();
const action = opts.action ?? 'push';
const started = nowIso();
db().prepare('INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, ?, ?, ?)')
.run(depId, action, started, opts.triggeredBy ?? 'ui');
const insServer = db().prepare(
`INSERT INTO deployment_servers
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
VALUES (?, ?, ?, 'pending', NULL, NULL, NULL, 0, NULL)`
);
const insFile = db().prepare(
`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const plan of changedPlans) {
const applied = await applyPlan(plan);
const tx = db().transaction(() => {
insServer.run(depId, plan.server, plan.changes.length);
for (const a of applied) {
insFile.run(
depId,
plan.server,
a.verb,
a.relPath,
a.sha256Before,
a.sha256After,
a.contentBefore,
a.contentAfter
);
}
for (const heldRel of plan.heldTargets) {
insFile.run(depId, plan.server, 'held', heldRel, null, null, null, null);
}
});
tx();
emit({ type: 'server-changed', deploymentId: depId, server: plan.server, files: plan.changes.length });
}
emit({ type: 'deployment-opened', deploymentId: depId, action, servers: changedPlans.map((p) => p.server) });
return {
deploymentId: depId,
plans,
totalServers,
totalChangedServers,
totalFiles
};
}
+235
View File
@@ -0,0 +1,235 @@
import { createHash } from 'node:crypto';
import { db } from '../db/index.js';
export type ScopeType = 'game' | 'map';
export interface ScriptRow {
id: number;
name: string;
target_rel_path: string;
scope_type: ScopeType | null;
scope_value: string | null;
current_version_id: number | null;
created_at: string;
}
export interface ScriptVersionRow {
id: number;
script_id: number;
version: number;
sha256: string;
size: number;
content: Buffer;
notes: string | null;
uploaded_at: string;
}
// The script name is the primary user-facing identifier; slugify to derive a
// URL-safe form. Same rules as the old beta slugify — lowercase, letters + digits.
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export class ScriptError extends Error {
constructor(
message: string,
public readonly kind:
| 'name-empty'
| 'name-taken'
| 'path-empty'
| 'no-such-script'
| 'no-such-version'
| 'scope-requires-beta'
| 'invalid-scope'
| 'in-use-current'
| 'in-use-beta'
| 'has-scope'
| 'has-active-beta'
) {
super(message);
}
}
function sha256(buf: Buffer): string {
return createHash('sha256').update(buf).digest('hex');
}
export function getScript(id: number): ScriptRow | undefined {
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
}
export function getScriptByName(name: string): ScriptRow | undefined {
return db().prepare('SELECT * FROM scripts WHERE name = ?').get(name) as ScriptRow | undefined;
}
export function listScripts(): ScriptRow[] {
return db().prepare('SELECT * FROM scripts ORDER BY name').all() as ScriptRow[];
}
export function listVersions(scriptId: number): ScriptVersionRow[] {
return db()
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC')
.all(scriptId) as ScriptVersionRow[];
}
export function getVersion(id: number): ScriptVersionRow | undefined {
return db().prepare('SELECT * FROM script_versions WHERE id = ?').get(id) as
| ScriptVersionRow
| undefined;
}
// Latest version is 'max(version)' — used everywhere we want the freshest
// upload, whether or not it has been promoted (current_version_id).
export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined {
return db()
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC LIMIT 1')
.get(scriptId) as ScriptVersionRow | undefined;
}
export interface CreateScriptInput {
name: string;
targetRelPath: string;
scopeType?: ScopeType | null;
scopeValue?: string | null;
content: Buffer;
notes?: string;
}
// Insert script + version 1 in one transaction. Scope may be set at creation
// because the script has no current_version_id yet (nothing to gate).
export function createScript(input: CreateScriptInput): { script: ScriptRow; version: ScriptVersionRow } {
if (!input.name.trim()) throw new ScriptError('script name is required', 'name-empty');
if (!input.targetRelPath.trim()) throw new ScriptError('target rel path is required', 'path-empty');
validateScope(input.scopeType ?? null, input.scopeValue ?? null);
if (getScriptByName(input.name)) throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
const sha = sha256(input.content);
const size = input.content.byteLength;
const insertScript = db().prepare(
`INSERT INTO scripts (name, target_rel_path, scope_type, scope_value)
VALUES (?, ?, ?, ?)`
);
const insertVersion = db().prepare(
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes)
VALUES (?, 1, ?, ?, ?, ?)`
);
let scriptId!: number;
let versionId!: number;
db().transaction(() => {
const s = insertScript.run(input.name, input.targetRelPath, input.scopeType ?? null, input.scopeValue ?? null);
scriptId = Number(s.lastInsertRowid);
const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null);
versionId = Number(v.lastInsertRowid);
})();
return {
script: getScript(scriptId)!,
version: getVersion(versionId)!
};
}
export function uploadNewVersion(scriptId: number, content: Buffer, notes?: string): ScriptVersionRow {
const script = getScript(scriptId);
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
const latest = getLatestVersion(scriptId);
const nextVersion = (latest?.version ?? 0) + 1;
const sha = sha256(content);
const res = db()
.prepare(
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null);
return getVersion(Number(res.lastInsertRowid))!;
}
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
if (scopeType === null && scopeValue === null) return;
if (scopeType === 'game' || scopeType === 'map') {
if (!scopeValue || !scopeValue.trim()) {
throw new ScriptError(`scope_value required for scope_type=${scopeType}`, 'invalid-scope');
}
return;
}
throw new ScriptError(`unknown scope_type: ${scopeType}`, 'invalid-scope');
}
export interface SetScopeInput {
scriptId: number;
scopeType: ScopeType | null;
scopeValue: string | null;
}
// Setting or changing a scope while the script already has a promoted version
// requires a beta first (per plan §"Beta trigger"). Un-scoping is free.
export function setScope(input: SetScopeInput): ScriptRow {
const script = getScript(input.scriptId);
if (!script) throw new ScriptError(`no such script (id=${input.scriptId})`, 'no-such-script');
validateScope(input.scopeType, input.scopeValue);
const changed =
input.scopeType !== script.scope_type || (input.scopeValue ?? null) !== (script.scope_value ?? null);
const isSetOrChange = input.scopeType !== null && changed;
if (isSetOrChange && script.current_version_id !== null) {
throw new ScriptError(
'setting or changing scope requires opening a beta first',
'scope-requires-beta'
);
}
db()
.prepare('UPDATE scripts SET scope_type = ?, scope_value = ? WHERE id = ?')
.run(input.scopeType, input.scopeValue, input.scriptId);
return getScript(input.scriptId)!;
}
// Version delete is metadata-only: it never touches server disks. But deleting
// the current version or a version referenced by a running beta would break
// downstream state, so those are blocked.
export function deleteVersion(versionId: number): void {
const version = getVersion(versionId);
if (!version) throw new ScriptError(`no such version (id=${versionId})`, 'no-such-version');
const script = getScript(version.script_id)!;
if (script.current_version_id === versionId) {
throw new ScriptError('cannot delete the current version', 'in-use-current');
}
const beta = db()
.prepare(`SELECT id FROM betas WHERE script_version_id = ? AND state = 'running'`)
.get(versionId) as { id: number } | undefined;
if (beta) throw new ScriptError(`beta ${beta.id} is still running against this version`, 'in-use-beta');
db().prepare('DELETE FROM script_versions WHERE id = ?').run(versionId);
}
export function deleteScript(scriptId: number): void {
const script = getScript(scriptId);
if (!script) throw new ScriptError(`no such script (id=${scriptId})`, 'no-such-script');
if (script.scope_type !== null) {
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'`)
.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);
}
// Servers that match a script's scope. Empty for unscoped scripts — those are
// only ever pushed to explicitly chosen beta targets.
export function serversMatchingScope(scopeType: ScopeType | null, scopeValue: string | null): string[] {
if (scopeType === null || scopeValue === null) return [];
const rows = db()
.prepare(`SELECT name FROM servers WHERE ${scopeType} = ? ORDER BY name`)
.all(scopeValue) as Array<{ name: string }>;
return rows.map((r) => r.name);
}
+34 -39
View File
@@ -4,8 +4,8 @@ import { config, destOf } from '../config.js';
import { db } from '../db/index.js';
/**
* All server folders under SERVERS_ROOT. Mirrors `all_servers` in
* push_scripts.sh:430.
* All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill
* the servers table for any on-disk server directory that isn't yet tracked.
*/
export async function allServers(): Promise<string[]> {
let entries;
@@ -22,8 +22,7 @@ export async function allServers(): Promise<string[]> {
/**
* Match a server name against a list of glob-like patterns (supports `*` and
* `?` and comma-separated lists). Ports `match_servers` in
* push_scripts.sh:438.
* `?` and comma-separated lists).
*/
export function matchServers(all: string[], patterns: string[]): string[] {
if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*')) {
@@ -36,10 +35,7 @@ export function matchServers(all: string[], patterns: string[]): string[] {
}
/**
* Ensure a server has an sp/ destination. Mirrors `ensure_dest` in
* push_scripts.sh:466. Returns `{ ok: true }` when the dir exists (or was
* just created with `create: true`), or `{ ok: false, reason }` when it can't
* be used.
* Ensure a server has an sp/ destination. Creates it when create=true.
*/
export async function ensureDest(
server: string,
@@ -60,7 +56,7 @@ export async function ensureDest(
return { ok: true, path: dest };
}
// -- server registry (was .servers map in script_metadata.json) --
// -- server registry --
export interface ServerRow {
name: string;
@@ -69,22 +65,19 @@ export interface ServerRow {
komodo_server: string | null;
game_port: number | null;
rcon_host: string | null;
game: string | null;
map: string | null;
last_players: number | null;
last_checked_at: string | null;
last_map: string | null;
last_gametype: string | null;
last_roster: string | null; // JSON array of RconPlayer, or null
last_seen_at: string | null; // last successful RCON reply
last_error: string | null; // cleared on success
last_reply_snippet: string | null; // hex+ASCII of last bad-reply payload
last_roster: string | null;
last_seen_at: string | null;
last_error: string | null;
last_reply_snippet: string | null;
created_at: string;
}
/**
* Seed the servers table with rows for any folder under SERVERS_ROOT that
* isn't yet tracked. Uses folder-name-with-dashes as the default komodo_name,
* mirroring `server_reg_seed` in push_scripts.sh:564.
*/
export async function seedServerRegistry(): Promise<number> {
const rows = await allServers();
const insert = db().prepare(
@@ -114,13 +107,14 @@ export function setServerField(
field: keyof ServerRow,
value: string | number | null
): void {
// whitelist columns — never interpolate arbitrary strings into SQL
const allowed: Array<keyof ServerRow> = [
'komodo_type',
'komodo_name',
'komodo_server',
'game_port',
'rcon_host',
'game',
'map',
'last_players',
'last_checked_at',
'last_map',
@@ -135,37 +129,38 @@ export function setServerField(
}
/**
* Which relative-path targets are currently held by an open beta on this
* server? Returns `Map<relPath, betaSlug>`. Mirrors `held_by_beta` in
* push_scripts.sh:378.
* Which target rel_paths on this server are currently held by a running beta?
* Returns Map<relPath, betaId>. A target held by beta X blocks other betas AND
* blocks unrelated writes to that same target on that server.
*/
export function heldByBeta(server: string): Map<string, string> {
export function heldByBeta(server: string): Map<string, number> {
const rows = db()
.prepare(
`SELECT bs.target AS target, bs.beta_slug AS slug
FROM beta_deploys bd
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
WHERE bd.server = ?`
`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'`
)
.all(server) as Array<{ target: string; slug: string }>;
const map = new Map<string, string>();
for (const r of rows) map.set(r.target, r.slug);
.all(server) as Array<{ target: string; beta_id: number }>;
const map = new Map<string, number>();
for (const r of rows) map.set(r.target, r.beta_id);
return map;
}
/**
* Which beta (if any) owns this target on this server?
* Mirrors `owner_of` in push_scripts.sh:366.
* Which running beta (if any) owns a specific target rel_path on a server?
*/
export function ownerOf(server: string, target: string): string | null {
export function ownerOf(server: string, target: string): number | null {
const row = db()
.prepare(
`SELECT bs.beta_slug AS slug
FROM beta_deploys bd
JOIN beta_scripts bs ON bs.id = bd.beta_script_id
WHERE bd.server = ? AND bs.target = ?
`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'
LIMIT 1`
)
.get(server, target) as { slug: string } | undefined;
return row?.slug ?? null;
.get(server, target) as { beta_id: number } | undefined;
return row?.beta_id ?? null;
}
+97
View File
@@ -251,6 +251,103 @@ export async function komodoResolveGamePort(serverName: string): Promise<number
return result.ok ? result.port : null;
}
// ---------- Health probe ----------
export type KomodoHealthResult =
| { ok: true; state: string }
| { ok: false; reason: string };
// Walks a Komodo read response looking for a container state string, matching
// the shapes we've seen: docker-inspect ({ State: { Status, Running } }),
// GetDeployment ({ state } / { status: { state } }), and GetStack similar.
function extractContainerState(payload: unknown): string | null {
const seen = new Set<unknown>();
let found: string | null = null;
const walk = (v: unknown): void => {
if (found || v === null || typeof v !== 'object' || seen.has(v)) return;
seen.add(v);
if (Array.isArray(v)) {
for (const item of v) walk(item);
return;
}
const obj = v as Record<string, unknown>;
// Prefer the explicit docker inspect shape.
const s = obj['State'];
if (s && typeof s === 'object') {
const status = (s as Record<string, unknown>)['Status'];
const running = (s as Record<string, unknown>)['Running'];
if (typeof status === 'string') {
found = status;
return;
}
if (running === true) {
found = 'running';
return;
}
}
if (typeof obj['state'] === 'string') {
found = obj['state'] as string;
return;
}
for (const val of Object.values(obj)) walk(val);
};
walk(payload);
return found;
}
/**
* Ask Komodo whether the server's container is currently running. Used by the
* beta runner during the `awaiting-health` stage after a restart. A truthy
* `ok` doesn't imply the game is fully ready — the runner also gates on RCON
* before advancing.
*/
export async function komodoInspectHealthy(serverName: string): Promise<KomodoHealthResult> {
if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' };
const row = getServer(serverName);
if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' };
const type = row.komodo_type ?? 'deployment';
let path: string;
let body: Record<string, string>;
switch (type) {
case 'stack':
path = 'read/GetStack';
body = { stack: row.komodo_name };
break;
case 'container':
if (!row.komodo_server) return { ok: false, reason: 'container type needs komodo_server' };
path = 'read/InspectContainer';
body = { server: row.komodo_server, container: row.komodo_name };
break;
case 'deployment':
default:
path = 'read/GetDeployment';
body = { deployment: row.komodo_name };
break;
}
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
let res: Response;
try {
res = await komodoCall(path, body);
} catch (err) {
return { ok: false, reason: describeFetchError(err, url) };
}
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
let json: unknown;
try {
json = await res.json();
} catch (err) {
return { ok: false, reason: `unparseable json: ${(err as Error).message}` };
}
const state = extractContainerState(json);
if (!state) return { ok: false, reason: 'no container state found in response' };
const healthy = state.toLowerCase() === 'running' || state.toLowerCase() === 'healthy';
if (!healthy) return { ok: false, reason: `container state: ${state}` };
return { ok: true, state };
}
// ---------- Extractor ----------
// Property names Komodo/Docker use for the collection of port mappings.
+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;
}
}
}
+21 -1
View File
@@ -20,6 +20,17 @@ export interface ServerStatusEntry {
replySnippet: string | null;
}
export type BetaStage =
| 'deploying'
| 'awaiting-empty'
| 'restarting'
| 'awaiting-health'
| 'awaiting-match'
| 'ready'
| 'failed';
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
export type PipelineEvent =
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
| { type: 'server-changed'; deploymentId: string; server: string; files: number }
@@ -32,7 +43,16 @@ export type PipelineEvent =
error: string | null;
}
| { type: 'sweep-tick'; frozen: boolean; reason?: string }
| { type: 'server-status'; entries: ServerStatusEntry[] };
| { type: 'server-status'; entries: ServerStatusEntry[] }
| { type: 'beta-opened'; betaId: number; scriptId: number; servers: string[] }
| {
type: 'beta-server-stage';
betaId: number;
server: string;
stage: BetaStage;
error: string | null;
}
| { type: 'beta-state'; betaId: number; state: BetaState };
class Bus extends EventEmitter {
constructor() {
+6
View File
@@ -1,4 +1,5 @@
import { sweep } from './sweep.js';
import { betaTick } from './beta-runner.js';
import { emit } from './bus.js';
import type { ServerStatusEntry } from './bus.js';
import { rconStatusResult } from '../integrations/rcon.js';
@@ -22,6 +23,11 @@ async function tick(): Promise<void> {
} catch (err) {
console.error('[worker] sweep error:', err);
}
try {
await betaTick();
} catch (err) {
console.error('[worker] beta-runner error:', err);
}
}
/**