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
+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.