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
+66 -3
View File
@@ -64,6 +64,8 @@
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;
@@ -74,7 +76,15 @@
health: Health;
endpoint: RconEndpoint | null;
replySnippet: string | null;
held: Array<{ target: string; slug: string }>;
held: Array<{ target: string; betaId: number }>;
}
interface InventoryEntry {
relPath: string;
scriptVersionId: number | null;
sha256: string;
size: number;
updatedAt: string;
source: string | null;
}
interface Integrations {
rcon: boolean;
@@ -93,6 +103,7 @@
let server = $state<Server | null>(null);
let integrations = $state<Integrations | null>(null);
let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]);
let name = $derived($page.params.name ?? '');
let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false);
@@ -102,12 +113,20 @@
let probingRcon = $state(false);
onMount(async () => {
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
const [srvRes, depRes, invRes] = await Promise.all([
fetch('/api/servers'),
fetch('/api/deployments'),
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
]);
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
server = srvBody.servers.find((s) => s.name === name) ?? null;
integrations = srvBody.integrations;
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
if (invRes.ok) {
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
inventory = invBody.scripts;
}
});
let live = $derived($statusByServer.get(name));
@@ -420,15 +439,41 @@
<div class="k">game_port</div>
<div class="v mono">{server.game_port ?? '(unresolved)'}</div>
</div>
<div class="stat">
<div class="k">game</div>
<div class="v mono">{server.game ?? '(unset)'}</div>
</div>
<div class="stat">
<div class="k">map</div>
<div class="v mono">{server.map ?? '(unset)'}</div>
</div>
{#if server.held.length > 0}
<div class="stat">
<div class="k">held by beta</div>
<div class="v mono">{server.held.map((h) => h.target).join(', ')}</div>
<div class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</div>
</div>
{/if}
</div>
</section>
<section class="panel">
<h2>Scripts on this server ({inventory.filter((f) => !f.relPath.endsWith('.OLD') && !/-beta\./.test(f.relPath)).length})</h2>
{#if inventory.length === 0}
<p class="empty">no scripts tracked — upload one from the Scripts page and open a beta to deploy it here</p>
{:else}
<div class="inv-list">
{#each inventory as f (f.relPath)}
<div class="inv-row inv-{f.relPath.endsWith('.OLD') ? 'old' : /-beta\./.test(f.relPath) ? 'beta' : 'live'}">
<span class="rel mono">{f.relPath}</span>
<span class="size mono">{f.size}B</span>
<span class="src mono">{f.source ?? '—'}</span>
<span class="sha mono">{f.sha256.slice(0, 12)}</span>
</div>
{/each}
</div>
{/if}
</section>
<section class="panel">
<h2>Deployment history</h2>
{#if history.length === 0}
@@ -843,4 +888,22 @@
font-family: var(--font-mono);
font-size: var(--text-base);
}
.inv-list { display: flex; flex-direction: column; gap: 4px; }
.inv-row {
display: grid;
grid-template-columns: 1fr 90px 160px 140px;
gap: 14px;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: var(--text-mono-sm);
align-items: center;
}
.inv-row.inv-old { opacity: 0.55; border-left: 3px solid var(--ink-500); }
.inv-row.inv-beta { border-left: 3px solid var(--amber-500); }
.inv-row.inv-live { border-left: 3px solid var(--spark-500); }
.inv-row .rel { color: var(--cream); word-break: break-all; }
.inv-row .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
</style>