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
+9 -62
View File
@@ -5,7 +5,7 @@
import { statusByServer } from '$ui/stores/servers.js';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
interface HeldRow { target: string; slug: string; }
interface HeldRow { target: string; betaId: number; }
interface ServerRow {
name: string;
komodo_type: string;
@@ -24,8 +24,6 @@
}
let servers = $state<ServerRow[]>([]);
let pushing = $state(false);
let flash = $state<string | null>(null);
onMount(async () => {
const res = await fetch('/api/servers');
@@ -33,25 +31,6 @@
servers = body.servers;
});
async function push(dryRun: boolean) {
pushing = true;
flash = null;
const res = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ patterns: ['*'], dryRun, create: false, delete: false })
});
pushing = false;
const body = (await res.json()) as { totalChangedServers?: number; totalFiles?: number; deploymentId?: string | null };
if (!res.ok) {
flash = `push failed: HTTP ${res.status}`;
return;
}
flash = dryRun
? `dry run: ${body.totalChangedServers ?? 0} servers would change, ${body.totalFiles ?? 0} files`
: `pushed to ${body.totalChangedServers ?? 0} servers · ${body.totalFiles ?? 0} files`;
}
// Live server view — merge SSE data over the initial GET
function liveFor(name: string, fallback: ServerRow) {
const live = $statusByServer.get(name);
@@ -85,12 +64,8 @@
<h1>Fleet</h1>
<span class="count">{servers.length} server{servers.length === 1 ? '' : 's'}</span>
</div>
<div class="actions">
<button onclick={() => push(true)} disabled={pushing}>dry run</button>
<button class="primary" onclick={() => push(false)} disabled={pushing}>push all</button>
</div>
<a class="scripts-link" href="/scripts">manage scripts →</a>
</header>
{#if flash}<p class="flash">{flash}</p>{/if}
{#if servers.length === 0}
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
{:else}
@@ -150,35 +125,17 @@
color: var(--ink-500);
letter-spacing: 0.5px;
}
.actions { display: flex; gap: 10px; }
button {
background: var(--bg-card);
color: var(--ink-100);
.scripts-link {
color: var(--spark-400);
text-decoration: none;
font-family: var(--font-body);
font-size: var(--text-sm);
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 20px;
cursor: pointer;
font-family: var(--font-body);
font-size: var(--text-base);
font-weight: 500;
letter-spacing: 0.3px;
transition: border-color 0.15s, box-shadow 0.15s;
}
button:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
button.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
color: var(--cream);
border-color: var(--blue-400);
font-weight: 600;
}
button.primary:hover:not(:disabled) {
border-color: var(--spark-500);
box-shadow: var(--spark-glow), 0 0 20px rgba(46, 134, 209, 0.45);
}
button:disabled { opacity: 0.4; cursor: default; }
.scripts-link:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.cards {
display: grid;
gap: var(--gap-cards);
@@ -193,14 +150,4 @@
border: 1px dashed var(--border);
border-radius: var(--r-md);
}
.flash {
color: var(--spark-400);
margin: 0 0 20px;
font-family: var(--font-mono);
font-size: var(--text-mono);
padding: 12px 16px;
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.25);
border-radius: var(--r-sm);
}
</style>