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
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
interface BetaRow {
id: number;
script_id: number;
script_version_id: number;
trigger_kind: string;
wait_for_health: number;
wait_for_match: number;
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
opened_at: string;
closed_at: string | null;
notes: string | null;
}
interface BetaServerRow {
beta_id: number;
server: string;
had_original: number;
prior_version_id: number | null;
stage: string;
error: string | null;
deployed_at: string | null;
restarted_at: string | null;
healthy_at: string | null;
match_seen_at: string | null;
updated_at: string;
}
interface Script { id: number; name: string; target_rel_path: string }
interface Version { id: number; version: number; sha256: string; size: number }
let beta = $state<BetaRow | null>(null);
let script = $state<Script | null>(null);
let version = $state<Version | null>(null);
let servers = $state<BetaServerRow[]>([]);
let loading = $state(true);
let flash = $state<string | null>(null);
let acting = $state<string | null>(null);
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
async function load(): Promise<void> {
const res = await fetch(`/api/betas/${id}`);
if (!res.ok) { loading = false; return; }
const body = (await res.json()) as {
beta: BetaRow;
script: Script;
version: Version;
servers: BetaServerRow[];
};
beta = body.beta;
script = body.script;
version = body.version;
servers = body.servers;
loading = false;
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
load();
poll = setInterval(load, 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
async function promote(): Promise<void> {
acting = 'promote';
flash = null;
try {
const res = await fetch(`/api/betas/${id}/promote`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'promote failed'; return; }
await load();
} finally { acting = null; }
}
async function rollback(): Promise<void> {
if (!confirm('Rollback this beta?')) return;
acting = 'rollback';
flash = null;
try {
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
await load();
} finally { acting = null; }
}
async function retry(server: string): Promise<void> {
acting = `retry:${server}`;
flash = null;
try {
const res = await fetch(`/api/betas/${id}/retry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server })
});
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'retry failed'; return; }
await load();
} finally { acting = null; }
}
</script>
{#if loading}
<p class="empty">loading…</p>
{:else if !beta || !script || !version}
<p class="empty">beta not found</p>
{:else}
<a class="back" href="/betas">← all betas</a>
<div class="head-row">
<h1>Beta #{beta.id}</h1>
<span class="tag state-{beta.state}">{beta.state}</span>
</div>
<div class="sub">
<a href="/scripts/{script.id}">{script.name}</a>
<span class="mono">· {script.target_rel_path}</span>
<span class="mono">· v{version.version}</span>
</div>
<section class="panel">
<h2>Options</h2>
<div class="opts mono">
<span>trigger: <strong>{beta.trigger_kind}</strong></span>
<span>wait for health: <strong>{beta.wait_for_health ? 'yes' : 'no'}</strong></span>
<span>wait for match: <strong>{beta.wait_for_match ? 'yes' : 'no'}</strong></span>
</div>
</section>
<section class="panel">
<h2>Server progress</h2>
<div class="rows">
{#each servers as bs (bs.server)}
<div class="srv-row srv-{bs.stage}">
<span class="srv-name mono">{bs.server}</span>
<span class="tag stage-{bs.stage}">{bs.stage}</span>
<div class="ticks mono">
<span class:on={bs.deployed_at}>deploy</span>
<span class:on={bs.stage !== 'awaiting-empty' && bs.stage !== 'deploying'}>empty</span>
<span class:on={bs.restarted_at}>restart</span>
<span class:on={bs.healthy_at}>healthy</span>
{#if beta.wait_for_match}
<span class:on={bs.match_seen_at}>match</span>
{/if}
</div>
{#if bs.error}
<span class="err mono">{bs.error}</span>
{/if}
{#if bs.stage === 'failed'}
<button class="mini" onclick={() => retry(bs.server)} disabled={acting === `retry:${bs.server}`}>
{acting === `retry:${bs.server}` ? 'retrying…' : 'retry'}
</button>
{/if}
</div>
{/each}
</div>
</section>
<section class="panel">
<h2>Actions</h2>
<div class="action-row">
<button
class="primary"
onclick={promote}
disabled={beta.state !== 'ready-promote' || acting !== null}
title={beta.state !== 'ready-promote' ? 'all servers must reach ready first' : ''}
>
{acting === 'promote' ? 'promoting…' : 'promote'}
</button>
<button
class="danger"
onclick={rollback}
disabled={beta.state === 'promoted' || beta.state === 'rolled-back' || acting !== null}
>
{acting === 'rollback' ? 'rolling back…' : 'rollback'}
</button>
</div>
</section>
{#if flash}<p class="flash">{flash}</p>{/if}
{/if}
<style>
.back { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono-sm); text-decoration: none; }
.back:hover { color: var(--ink-100); }
.head-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 4px; }
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 0; }
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; display: flex; gap: 8px; align-items: baseline; }
.sub a { color: var(--spark-500); text-decoration: none; }
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); }
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; }
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
.state-ready-promote { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
.state-promoted { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.28); color: var(--cream); }
.state-rolled-back { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
.opts { display: flex; gap: 24px; color: var(--ink-100); font-size: var(--text-mono); }
.opts strong { color: var(--cream); }
.rows { display: flex; flex-direction: column; gap: 8px; }
.srv-row { display: grid; grid-template-columns: 220px 130px 1fr auto auto; gap: 14px; padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-sm); align-items: center; }
.srv-ready { border-left: 3px solid var(--spark-500); }
.srv-failed { border-left: 3px solid var(--danger-500); }
.srv-awaiting-empty, .srv-restarting, .srv-awaiting-health, .srv-awaiting-match, .srv-deploying { border-left: 3px solid var(--amber-500); }
.srv-name { color: var(--cream); font-size: var(--text-md); }
.stage-deploying, .stage-awaiting-empty, .stage-restarting, .stage-awaiting-health, .stage-awaiting-match { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.3); color: var(--amber-500); }
.stage-ready { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
.stage-failed { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
.ticks { display: flex; gap: 14px; font-size: var(--text-mono-sm); color: var(--ink-500); }
.ticks span { position: relative; padding-left: 14px; }
.ticks span::before { content: '○'; position: absolute; left: 0; color: var(--ink-500); }
.ticks span.on { color: var(--cream); }
.ticks span.on::before { content: '●'; color: var(--spark-500); }
.err { color: var(--danger-500); font-size: var(--text-mono-sm); word-break: break-word; }
.mini { background: var(--bg-nav); color: var(--cream); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 6px 12px; font-family: var(--font-body); font-size: var(--text-xs); cursor: pointer; }
.action-row { display: flex; gap: 12px; }
.primary { background: linear-gradient(180deg, var(--blue-500), var(--blue-600)); border: 1px solid var(--blue-400); color: var(--cream); border-radius: var(--r-sm); padding: 12px 20px; font-family: var(--font-body); font-weight: 600; cursor: pointer; }
.primary:disabled { opacity: 0.4; cursor: default; }
.danger { background: var(--bg-nav); border: 1px solid rgba(255, 48, 80, 0.4); color: var(--danger-500); border-radius: var(--r-sm); padding: 12px 20px; font-family: var(--font-body); cursor: pointer; }
.danger:disabled { opacity: 0.4; cursor: default; }
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
.mono { font-family: var(--font-mono); }
</style>