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
+2 -1
View File
@@ -10,6 +10,7 @@ import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
import { kick } from '$lib/server/pipeline/worker.js';
import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
import { storedPlan } from '$lib/server/fleet/beta-tests.js';
import { currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
export async function GET({ url }: { url: URL }) {
const stateFilter = url.searchParams.get('state') as
@@ -38,7 +39,7 @@ export async function GET({ url }: { url: URL }) {
...b,
testOutcome: test?.outcome ?? null,
testReason: test?.reason ?? null,
deadlineAt: plan ? new Date(Date.parse(b.opened_at) + plan.timeoutHours * 3600000).toISOString() : null,
deadlineAt: plan ? new Date(Date.parse(currentBetaRevision(b.id).started_at) + plan.timeoutHours * 3600000).toISOString() : null,
scripts,
servers: listBetaServers(b.id)
};
+3 -1
View File
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db/index.js';
import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
import { testView } from '$lib/server/fleet/beta-tests.js';
import { betaRevisionHistory, currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
function parseId(raw: string): number | null {
const n = Number.parseInt(raw, 10);
@@ -37,5 +38,6 @@ export async function GET({ params }: { params: { id: string } }) {
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
.get(id) as { id: string } | undefined;
return json({ beta, scripts, servers, events, test: testView(id), promotion, rollback: rollback ?? null });
return json({ beta, scripts, servers, events, test: testView(id), promotion, rollback: rollback ?? null,
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
}
@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import { BetaError, reviseBeta, type BetaRevisionUpload } from '$lib/server/fleet/beta.js';
import { ScriptError } from '$lib/server/fleet/scripts.js';
import { kick } from '$lib/server/pipeline/worker.js';
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
try {
const form = await request.formData();
const expectedRevision = Number(form.get('expectedRevision'));
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw new Error('A reviewed revision is required.');
const uploads: BetaRevisionUpload[] = [];
for (const [key, value] of form) {
if (!key.startsWith('file:')) continue;
const scriptId = Number(key.slice(5));
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File)) throw new Error('Invalid replacement file.');
uploads.push({ scriptId, content: Buffer.from(await value.arrayBuffer()),
releaseVersion: form.has(`release:${scriptId}`) ? String(form.get(`release:${scriptId}`)) : undefined });
}
const result = await reviseBeta(id, expectedRevision, uploads, String(form.get('notes') ?? ''));
void kick();
return json({ ok: true, result });
} catch (error) {
return json({ ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 });
}
}
@@ -0,0 +1,16 @@
import { db } from '$lib/server/db/index.js';
/** Current files can be reviewed before the first revision is archived. */
export async function GET({ params }: { params: { id: string; revision: string; scriptId: string } }) {
const values = [params.id, params.revision, params.scriptId].map(Number);
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
const [id, revision, scriptId] = values;
const file = db().prepare('SELECT content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?')
.get(id, revision, scriptId) as { content: Buffer } | undefined
?? (revision === 1 ? db().prepare(`SELECT v.content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`)
.get(id, scriptId, id) as { content: Buffer } | undefined : undefined);
if (!file) return new Response('File not found', { status: 404 });
return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
}
+2 -3
View File
@@ -50,9 +50,8 @@ export async function POST({ params, request }: { params: { id: string }; reques
}
setOutcome(id, 'active', null);
} else if (body.action === 'retry') {
// Fresh attempts keep their predecessors as audit history. Preserve the original deadline:
// operators open another beta if the overall testing window has expired.
if (stored.outcome === 'inconclusive') throw new Error('The testing window ended. Roll back and open a new beta with a longer deadline');
// Fresh attempts keep history and the current revision's deadline.
if (stored.outcome === 'inconclusive') throw new Error('The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline');
if (stagedFileChanges('beta', String(id)).some(change => change.state === 'applying')) {
throw new Error('A file write was interrupted. Inspect the server and roll back before opening a fresh beta');
}
+26 -6
View File
@@ -11,6 +11,8 @@
import type { ActivityEvent } from '$lib/operation-flow.js';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import type { BetaRevision } from '$lib/beta-revision.js';
import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte';
import BetaTestProgress from '$ui/components/BetaTestProgress.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
@@ -38,10 +40,13 @@
let acting = $state<string|null>(null), request = 0;
let reviewOpen = $state(false), preview = $state<BetaPromotionPreview|null>(null), reviewError = $state('');
let now = $state(Date.now());
let revision = $state({ revision: 1, started_at: '' }), revisions = $state<BetaRevision[]>([]);
let updateOpen = $state(false), updateRevision = $state(1), updateFiles = $state<BetaFile[]>([]);
function openUpdate() { updateRevision = revision.revision; updateFiles = [...scripts]; updateOpen = true; }
const id = $derived(Number($page.params.id));
const closed = $derived(beta?.state === 'promoted' || beta?.state === 'rolled-back');
const hasDeletedServers = $derived(servers.some(server => server.server_deleted_at));
const deadline = $derived(beta && test ? new Date(Date.parse(beta.opened_at) + test.plan.timeoutHours * 3600000) : null);
const deadline = $derived(beta && test ? new Date(Date.parse(revision.started_at || beta.opened_at) + test.plan.timeoutHours * 3600000) : null);
const remaining = $derived(deadline ? Math.max(0, Math.ceil((deadline.getTime() - now) / 60000)) : 0);
const deadlineText = $derived(remaining > 0 ? `${Math.floor(remaining / 60)}h ${remaining % 60}m remaining` : 'Testing window ended');
const passed = $derived(test?.servers.filter(server => server.passed).length ?? servers.filter(server => server.stage === 'ready').length);
@@ -49,7 +54,7 @@
const waiting = $derived(ranked.filter(server => server.stage !== 'ready'));
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the campaign to check server activation.' : rollbackCampaign ? 'Follow the rollback campaign to resume held changes or check restoration and restart progress.' : 'Reuse these files and this plan when you are ready to test again.'
: hasDeletedServers ? 'A participating server was deleted. Its results remain below. Roll back, then reuse this plan to test the current servers.'
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Roll back, then reuse this plan with a longer testing window.'
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Update the files to start a new revision, or roll back and reuse this plan.'
: test?.outcome === 'paused' ? 'Resume to continue collecting evidence, or start a fresh attempt. The original deadline still applies.'
: test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
: beta?.state === 'ready-promote' ? 'All required coverage passed. Review the full promotion impact before deploying to additional servers.'
@@ -64,11 +69,12 @@
if (current !== request || requestedId !== id) return;
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null;
rollbackCampaign = body.rollback ?? null;
revision = body.revision; revisions = body.revisions ?? [];
now = Date.now(); loadError = '';
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
finally { if (current === request && requestedId === id) loading = false; }
}
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; void load(); });
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
onMount(() => pollVisible(load, 5000, false));
async function reviewPromotion() {
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
@@ -129,7 +135,7 @@
{:else if !beta && !loadError}<EmptyState title="Beta not found" description="Return to Betas to find another test." />
{:else if beta}
<div class="page">
<SectionHeader title={beta.notes || `Beta #${beta.id}`} subtitle={beta.notes ? `Beta #${beta.id}` : undefined}>
<SectionHeader title={beta.notes || `Beta #${beta.id}`} subtitle={`Beta #${beta.id} · Revision ${revision.revision}`}>
{#snippet crumbs()}<Breadcrumbs items={[{label:'Betas',href:'/betas'},{label:`#${id}`}]} />{/snippet}
{#snippet actions()}<StatusBadge state={test && !closed ? test.outcome : beta?.state ?? 'running'} />{/snippet}
</SectionHeader>
@@ -139,12 +145,13 @@
<p>{nextAction}</p>
{#if test?.reason}<p class="reason">{test.reason}</p>{/if}
<div class="timing"><span>Started <time datetime={beta.opened_at}>{$timeFormat.dateTime(beta.opened_at)}</time></span>
{#if revision.revision > 1}<span>Revision {revision.revision} started {$timeFormat.dateTime(revision.started_at)}</span>{/if}
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{$timeFormat.dateTime(deadline)}</time> · {deadlineText}</strong>{/if}
{#if beta.closed_at}<span>Finished {$timeFormat.dateTime(beta.closed_at)}</span>{/if}
</div>
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline. Deploying a revision starts a fresh window.</p>{/if}
<div class="actions">
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={hasDeletedServers || beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={hasDeletedServers || beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button onclick={openUpdate} disabled={hasDeletedServers || acting !== null}>Update beta</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
{#if closed}<Button href={contextHref(`/betas/new?reuse=${id}`, $page.url)}>Reuse files and test plan</Button>{/if}
{#if promotion}<Button href={contextHref(`/campaigns/${encodeURIComponent(promotion.id)}`, $page.url)}>View campaign</Button>{/if}
{#if rollbackCampaign}<Button href={contextHref(`/campaigns/${encodeURIComponent(rollbackCampaign.id)}`, $page.url)}>View rollback campaign</Button>{/if}
@@ -163,10 +170,22 @@
<details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list">
{#each ranked as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(server.server)}`, $page.url)}/><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'submitted' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(server.provisioning_id)}`, $page.url)}>View provisioning</a>{/if}{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !server.server_deleted_at && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
</div></details>
{#if revisions.length}
<details class="ui-panel"><summary>Revision history ({revisions.length})</summary><div class="revision-list">
{#each revisions as item}<details><summary>Revision {item.revision} · {item.revision === revision.revision ? 'Current' : item.results?.test?.outcome ?? item.results?.state ?? 'Superseded'} · {$timeFormat.dateTime(item.started_at)}</summary>
{#if item.notes}<p>{item.notes}</p>{/if}
<div class="file-list">{#each item.files as file}<div><a href={`/api/betas/${id}/revisions/${item.revision}/files/${file.script_id}`} download={file.path.split('/').pop()}>{file.name} · {artifactVersionLabel(file)}</a><code>{file.path}</code><span>{file.sha256.slice(0,12)}</span></div>{/each}</div>
{#if item.results?.test}<BetaTestProgress test={item.results.test} betaId={id} closed={true} onUpdate={load} />
{:else if item.results}<ul>{#each item.results.servers as server}<li>{server.server} · {server.stage}{server.error ? ` · ${server.error}` : ''}</li>{/each}</ul>{/if}
</details>{/each}
</div></details>
{/if}
<OperationHistory {events} active={!closed} showOverview={false}/>
</div>
{/if}
<BetaRevisionDialog bind:open={updateOpen} betaId={id} revision={updateRevision} files={updateFiles} servers={servers.map(server => server.server)} timeoutHours={test?.plan.timeoutHours} onUpdate={load} />
<Dialog bind:open={reviewOpen} title="Review promotion" description="Review the exact files and server eligibility before installing the promoted versions." busy={acting === 'promote'} wide>
{#if acting === 'preview'}<p role="status">Preparing promotion impact…</p>{/if}
{#if reviewError}<p class="ui-error" role="alert">{reviewError}</p>{/if}
@@ -182,6 +201,7 @@
</Dialog>
<style>
.revision-list { display:grid; gap:20px; margin-top:16px; }
.page { display:grid; gap:24px; }
h2 { margin:0; font-size:18px; } p { line-height:1.6; overflow-wrap:anywhere; }
.summary-heading,.actions,.timing { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }