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
-271
View File
@@ -1,271 +0,0 @@
<script lang="ts">
interface StagedHint {
base: string;
hits: string[];
}
interface BetaSummary {
slug: string;
name: string;
opened_at: string;
updated_at: string;
revision: number;
servers: string[];
scripts: Array<{ staged_as: string; target: string; revision: number }>;
}
interface Props {
staged: StagedHint[];
betas: BetaSummary[];
}
let { staged, betas }: Props = $props();
let newBetaName = $state('');
let newBetaPatterns = $state('*');
let submitting = $state(false);
let flash = $state<string | null>(null);
async function openNewBeta() {
if (!newBetaName.trim() || submitting) return;
submitting = true;
flash = null;
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newBetaName.trim(),
patterns: newBetaPatterns.split(',').map((p) => p.trim()).filter(Boolean),
create: false
})
});
submitting = false;
const body = (await res.json()) as { ok: boolean; error?: string };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
newBetaName = '';
location.reload();
}
async function act(slug: string, action: 'rollback' | 'promote' | 'update') {
const path =
action === 'update' ? '/api/betas' : `/api/betas/${slug}/${action}`;
const method = action === 'update' ? 'PATCH' : 'POST';
const body = action === 'update' ? JSON.stringify({ name: slug }) : '{}';
const res = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json' },
body
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
flash = err.error ?? `HTTP ${res.status}`;
return;
}
location.reload();
}
</script>
<section class="panel">
<h3>Staged</h3>
{#if staged.length === 0}
<p class="empty">nothing staged</p>
{:else}
<ul class="staged">
{#each staged as s (s.base)}
<li>
<code>{s.base}</code>
{#if s.hits.length === 0}
<span class="tag new">new</span>
{:else if s.hits.length === 1}
<span class="tag replaces">replaces {s.hits[0]}</span>
{:else}
<span class="tag ambig">ambiguous ({s.hits.length} hits)</span>
{/if}
</li>
{/each}
</ul>
<form onsubmit={(e) => { e.preventDefault(); openNewBeta(); }}>
<input type="text" placeholder="Beta name" bind:value={newBetaName} disabled={submitting} />
<input
type="text"
placeholder="Server pattern (*, t5_kino_*, comma-sep)"
bind:value={newBetaPatterns}
disabled={submitting}
/>
<button type="submit" disabled={submitting || !newBetaName}>open beta</button>
</form>
{/if}
<h3>Open betas</h3>
{#if betas.length === 0}
<p class="empty">no open betas</p>
{:else}
{#each betas as b (b.slug)}
<div class="beta">
<div class="head">
<strong>{b.name}</strong>
<span class="rev">rev {b.revision}</span>
</div>
<div class="row"><span class="k">scripts</span> {b.scripts.map((s) => s.target).join(', ')}</div>
<div class="row"><span class="k">servers</span> {b.servers.join(', ')}</div>
<div class="actions">
<button onclick={() => act(b.slug, 'update')}>update</button>
<button class="primary" onclick={() => act(b.slug, 'promote')}>promote</button>
<button class="danger" onclick={() => act(b.slug, 'rollback')}>rollback</button>
</div>
</div>
{/each}
{/if}
{#if flash}
<p class="flash">{flash}</p>
{/if}
</section>
<style>
.panel {
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-panel);
}
h3 {
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);
}
h3:not(:first-of-type) { margin-top: var(--gap-section); }
.empty {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono);
margin: 0;
}
.staged { list-style: none; padding: 0; margin: 0 0 18px; }
.staged li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.staged code {
color: var(--cream);
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.tag {
font-family: var(--font-mono);
font-size: var(--text-xs);
padding: 3px 9px;
border-radius: var(--r-sm);
border: 1px solid transparent;
}
.tag.new {
background: rgba(124, 212, 255, 0.10);
border-color: rgba(124, 212, 255, 0.3);
color: var(--spark-500);
}
.tag.replaces {
background: rgba(124, 192, 245, 0.10);
border-color: rgba(124, 192, 245, 0.28);
color: var(--cream);
}
.tag.ambig {
background: rgba(255, 48, 80, 0.1);
border-color: rgba(255, 48, 80, 0.3);
color: var(--danger-500);
}
form {
display: flex;
gap: 10px;
margin-top: 14px;
}
form input {
flex: 1;
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: var(--text-mono);
}
form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
button {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
button:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
button:disabled { opacity: 0.4; cursor: default; }
button.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
border-color: var(--blue-400);
color: var(--cream);
font-weight: 600;
}
button.danger {
background: var(--bg-nav);
border-color: rgba(255, 48, 80, 0.4);
color: var(--danger-500);
}
.beta {
padding: 16px 18px;
margin: 10px 0;
background: var(--bg-card);
border-radius: var(--r-sm);
border: 1px solid var(--border);
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.head strong { color: var(--cream); font-weight: 600; font-size: var(--text-md); }
.rev {
color: var(--ink-500);
font-size: var(--text-mono-sm);
font-family: var(--font-mono);
}
.row {
font-size: var(--text-sm);
color: var(--ink-300);
margin: 4px 0;
}
.k {
color: var(--ink-500);
margin-right: 10px;
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.actions { display: flex; gap: 8px; margin-top: 14px; }
.flash {
color: var(--danger-500);
font-family: var(--font-mono);
font-size: var(--text-mono);
margin: 18px 0 0;
}
</style>
+2 -2
View File
@@ -5,7 +5,7 @@
interface Held {
target: string;
slug: string;
betaId: number;
}
interface Props {
name: string;
@@ -96,7 +96,7 @@
{#if held.length > 0}
<div class="held">
{#each held as h (h.target)}
<span class="chip" title="held by beta: {h.slug}">β {h.target}</span>
<span class="chip" title="held by beta {h.betaId}">β {h.target}</span>
{/each}
</div>
{/if}