Allow revising active betas with fresh tests and history

This commit is contained in:
2026-10-05 21:02:58 -04:00
parent 9d4ff992a4
commit f8d0ba3936
21 changed files with 713 additions and 41 deletions
@@ -0,0 +1,100 @@
<script lang="ts">
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import DiffViewer from './DiffViewer.svelte';
import ServerIdentity from './ServerIdentity.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
interface FileEntry { id: number; name: string; kind: string; target_rel_path: string;
version: { version: number; release_version: string | null; sha256: string } | null }
let { open = $bindable(false), betaId, revision, files, servers, timeoutHours, onUpdate }:
{ open?: boolean; betaId: number; revision: number; files: FileEntry[]; servers: string[];
timeoutHours?: number; onUpdate: () => Promise<void> } = $props();
interface Replacement { file: File; sha: string; before: string | null; after: string | null }
let replacements = $state<Record<number, Replacement>>({});
let notes = $state(''), error = $state(''), reviewing = $state(false), busy = $state(false);
const count = $derived(Object.keys(replacements).length);
$effect(() => { if (open) { replacements = {}; notes = ''; error = ''; reviewing = false; } });
function fileUrl(scriptId: number) { return `/api/betas/${betaId}/revisions/${revision}/files/${scriptId}`; }
async function choose(entry: FileEntry, file?: File) {
delete replacements[entry.id]; error = '';
if (!file) return;
busy = true;
try {
if (!file.size) throw new Error('Choose a non-empty file.');
const bytes = await file.arrayBuffer();
const inspection = new FormData(); inspection.set('content', file);
const inspected = await fetch('/api/scripts/inspect', { method: 'POST', body: inspection });
const inspectionBody = await inspected.json();
if (!inspected.ok) throw new Error(inspectionBody.error ?? 'Could not inspect replacement file.');
const sha = inspectionBody.sha256 as string;
if (sha === entry.version?.sha256) throw new Error(`${entry.name}: this file is identical to the current version.`);
let before: string | null = null, after: string | null = null;
if (entry.kind !== 'plugin' && bytes.byteLength <= 128 * 1024) {
const response = await fetch(fileUrl(entry.id));
if (!response.ok) throw new Error('Could not load the current file for review. Reload the beta and try again.');
const old = await response.arrayBuffer();
if (old.byteLength <= 128 * 1024 && !new Uint8Array(old).includes(0) && !new Uint8Array(bytes).includes(0)) {
before = new TextDecoder().decode(old); after = new TextDecoder().decode(bytes);
}
}
replacements[entry.id] = { file, sha, before, after };
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function deploy() {
busy = true; error = '';
try {
const form = new FormData();
form.set('expectedRevision', String(revision)); form.set('notes', notes);
for (const [scriptId, replacement] of Object.entries(replacements)) form.set(`file:${scriptId}`, replacement.file);
const response = await fetch(`/api/betas/${betaId}/revisions`, { method: 'POST', body: form });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not update beta.');
open = false; await onUpdate();
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
</script>
<Dialog bind:open title={reviewing ? `Review revision ${revision + 1}` : 'Update beta'}
description={`Replace files in beta #${betaId} and repeat its tests on the same servers.`} {busy} wide>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if !reviewing}
<p>Choose replacements for the files you changed.</p>
<div class="files">{#each files as entry}
<label><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code>{entry.target_rel_path}</code>
<input type="file" disabled={busy} onchange={event => choose(entry, event.currentTarget.files?.[0])} />
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
</label>
{/each}</div>
<label class="notes">Change note (optional)<textarea bind:value={notes} maxlength="1000" rows="3" disabled={busy}></textarea></label>
{:else}
<p><strong>{count} file{count === 1 ? '' : 's'} changed.</strong> All {servers.length} selected servers will repeat the test policy, using its existing wave order. Each server installs the bundle and restarts when empty.</p>
<p>Previous results move to revision history. Promotion requires this revision to pass.
{#if timeoutHours}A fresh {timeoutHours}-hour test window starts when you deploy, including time waiting for empty servers.{/if}
Rejecting the beta still restores the files from before revision 1.</p>
<details open><summary>Servers ({servers.length})</summary><ul>{#each servers as name}<li><ServerIdentity {name}/></li>{/each}</ul></details>
{#each files.filter(entry => replacements[entry.id]) as entry}
{@const replacement = replacements[entry.id]}
<section><h3>{entry.name}</h3><p><code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()} bytes)</p>
<p class="hash">SHA {entry.version?.sha256.slice(0,12)} → {replacement.sha.slice(0,12)}</p>
{#if replacement.after !== null}<DiffViewer before={replacement.before} after={replacement.after} verb="updated" relPath={entry.target_rel_path}/>
{:else}<p>Binary or large file: review the uploaded file and checksum.</p>{/if}
</section>
{/each}
{#if notes}<p>Change note: {notes}</p>{/if}
{/if}
{#snippet actions()}
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
{#if reviewing}<Button onclick={() => reviewing = false} disabled={busy}>Back</Button><Button variant="primary" onclick={deploy} disabled={busy} loading={busy}>Deploy revision {revision + 1}</Button>
{:else}<Button variant="primary" onclick={() => reviewing = true} disabled={busy || !count}>Review update</Button>{/if}
{/snippet}
</Dialog>
<style>
.files, label { display:grid; gap:12px; } .files { margin:20px 0; }
.files label { padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size:13px; }
li { margin:8px 0; } textarea { width:100%; } section { margin-top:24px; }
</style>
@@ -50,7 +50,7 @@
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
</div>{/if}
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.</p>
{#if expired}<p class="guidance">The testing window ended. Use “Update beta” to test changed files in a fresh revision, or roll back and reuse the plan with a longer window.</p>
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
{/if}
{#if error}<p role="alert" class="failed">{error}</p>{/if}