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
+83
View File
@@ -0,0 +1,83 @@
import { json } from '@sveltejs/kit';
import {
createScript,
listScripts,
listVersions,
serversMatchingScope,
ScriptError,
type ScopeType
} from '$lib/server/fleet/scripts.js';
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
if (v === null) return null;
const s = String(v);
if (s === '' || s === 'none') return null;
if (s === 'game' || s === 'map') return s;
throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope');
}
export async function GET() {
const scripts = listScripts().map((s) => {
const versions = listVersions(s.id);
const matched = s.scope_type && s.scope_value
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
: [];
const runningBeta = findRunningBetaForScript(s.id);
return {
...s,
latestVersion: versions[0]
? {
id: versions[0].id,
version: versions[0].version,
sha256: versions[0].sha256,
size: versions[0].size,
uploaded_at: versions[0].uploaded_at
}
: null,
versionCount: versions.length,
servers: matched,
runningBetaId: runningBeta?.id ?? null
};
});
return json({ scripts });
}
// Multipart POST — creates a script + version 1 from a single file upload.
// Fields: name (text), targetRelPath (text), scopeType (optional: 'game'|'map'),
// scopeValue (optional string), notes (optional), content (file).
export async function POST({ request }: { request: Request }) {
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const targetRelPath = String(form.get('targetRelPath') ?? '').trim();
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
let scopeType: ScopeType | null;
try {
scopeType = scopeTypeOrNull(form.get('scopeType'));
} catch (err) {
return json({ ok: false, error: (err as Error).message }, { status: 400 });
}
const scopeValueRaw = form.get('scopeValue');
const scopeValue = scopeValueRaw ? String(scopeValueRaw).trim() : null;
const file = form.get('content');
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
const content = Buffer.from(await file.arrayBuffer());
try {
const { script, version } = createScript({
name,
targetRelPath,
scopeType,
scopeValue,
content,
notes
});
return json({ ok: true, script, versionId: version.id });
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
}
throw err;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { json } from '@sveltejs/kit';
import {
getScript,
listVersions,
deleteScript,
serversMatchingScope,
ScriptError,
type ScopeType
} from '$lib/server/fleet/scripts.js';
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
function parseId(idRaw: string): number | null {
const n = Number.parseInt(idRaw, 10);
return Number.isFinite(n) ? n : null;
}
export async function GET({ params }: { params: { id: string } }) {
const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const script = getScript(id);
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
const versions = listVersions(id).map((v) => ({
id: v.id,
version: v.version,
sha256: v.sha256,
size: v.size,
notes: v.notes,
uploaded_at: v.uploaded_at
}));
const matched = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
: [];
const runningBeta = findRunningBetaForScript(id);
return json({
script,
versions,
matchedServers: matched,
runningBetaId: runningBeta?.id ?? null
});
}
export async function DELETE({ params }: { params: { id: string } }) {
const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
try {
deleteScript(id);
return json({ ok: true });
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
}
throw err;
}
}
@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
import { unsetScopeAndCleanup } from '$lib/server/fleet/beta.js';
function parseId(raw: string): number | null {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
// POST body: { scopeType: 'game' | 'map' | null, scopeValue?: string }
// Setting/changing scope on a script with a current_version_id returns a
// 409 with kind='scope-requires-beta'; unsetting cleans the file off matching
// servers.
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const scriptId = parseId(params.id);
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const script = getScript(scriptId);
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
const body = (await request.json().catch(() => ({}))) as {
scopeType?: ScopeType | null;
scopeValue?: string | null;
};
const scopeType = body.scopeType ?? null;
const scopeValue = body.scopeValue ?? null;
try {
if (scopeType === null) {
const cleaned = await unsetScopeAndCleanup(scriptId);
return json({ ok: true, cleaned });
}
const updated = setScope({ scriptId, scopeType, scopeValue });
return json({ ok: true, script: updated });
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
}
throw err;
}
}
@@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
function parseId(idRaw: string): number | null {
const n = Number.parseInt(idRaw, 10);
return Number.isFinite(n) ? n : null;
}
// Multipart POST — creates a new version from an uploaded file. Fields:
// content (file), notes (optional). Response includes a "betaRequired" hint
// when the script has a scope so the UI can nudge the operator to open a beta.
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const scriptId = parseId(params.id);
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const script = getScript(scriptId);
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
const form = await request.formData();
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
const file = form.get('content');
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
const content = Buffer.from(await file.arrayBuffer());
try {
const version = uploadNewVersion(scriptId, content, notes);
return json({
ok: true,
version: {
id: version.id,
version: version.version,
sha256: version.sha256,
size: version.size,
notes: version.notes,
uploaded_at: version.uploaded_at
},
betaRequired: script.scope_type !== null
});
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
}
throw err;
}
}
@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import { deleteVersion, ScriptError } from '$lib/server/fleet/scripts.js';
function parseId(raw: string): number | null {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
export async function DELETE({ params }: { params: { id: string; versionId: string } }) {
const versionId = parseId(params.versionId);
if (versionId === null) return json({ ok: false, error: 'bad version id' }, { status: 400 });
try {
deleteVersion(versionId);
return json({ ok: true });
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
}
throw err;
}
}