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
+60 -42
View File
@@ -1,55 +1,73 @@
import { json } from '@sveltejs/kit';
import { openBeta, updateBeta, listBetas, listBetaScripts, listBetaServers, BetaError } from '$lib/server/fleet/beta.js';
import { stagedFiles, findTargets } from '$lib/server/fleet/files.js';
import {
listBetas,
listBetaServers,
openBeta,
BetaError,
type BetaTriggerKind
} from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
import { kick } from '$lib/server/pipeline/worker.js';
export async function GET() {
const staged = await stagedFiles();
const withHints = await Promise.all(
staged.map(async (base) => ({
base,
hits: await findTargets(base)
}))
);
const betas = listBetas().map((b) => ({
...b,
servers: listBetaServers(b.slug),
scripts: listBetaScripts(b.slug)
}));
return json({ staged: withHints, betas });
export async function GET({ url }: { url: URL }) {
const stateFilter = url.searchParams.get('state') as
| 'running'
| 'ready-promote'
| 'promoted'
| 'rolled-back'
| null;
const rows = listBetas(stateFilter ?? undefined).map((b) => {
const script = getScript(b.script_id);
const version = getVersion(b.script_version_id);
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,
servers: listBetaServers(b.id)
};
});
return json({ betas: rows });
}
// POST body: { scriptId, scriptVersionId, servers[], waitForHealth, waitForMatch, triggerKind, notes? }
export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as {
name?: string;
patterns?: string[];
dryRun?: boolean;
create?: boolean;
scriptId?: number;
scriptVersionId?: number;
servers?: string[];
waitForHealth?: boolean;
waitForMatch?: boolean;
triggerKind?: BetaTriggerKind;
notes?: string;
};
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
try {
const res = await openBeta({
name: body.name,
patterns: body.patterns ?? ['*'],
dryRun: body.dryRun ?? false,
create: body.create ?? false
});
if (!res.dryRun) void kick();
return json({ ok: true, result: res });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
}
throw err;
}
}
export async function PATCH({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as { name?: string; dryRun?: boolean };
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
if (
typeof body.scriptId !== 'number' ||
typeof body.scriptVersionId !== 'number' ||
!Array.isArray(body.servers)
) {
return json(
{ ok: false, error: 'scriptId, scriptVersionId, 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';
try {
const res = await updateBeta(body.name, body.dryRun ?? false);
return json({ ok: true, result: res });
const result = await openBeta({
scriptId: body.scriptId,
scriptVersionId: body.scriptVersionId,
servers: body.servers,
waitForHealth: body.waitForHealth ?? true,
waitForMatch: body.waitForMatch ?? false,
triggerKind,
notes: body.notes
});
void kick();
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });