Improve operational UX, recovery flows, and action safety

Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
2026-09-19 21:34:34 -04:00
parent 940f81e6ca
commit 8ac420fe61
83 changed files with 2574 additions and 1190 deletions
+83 -41
View File
@@ -1,5 +1,6 @@
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { storedPlan } from './beta-tests.js';
import type { BetaPromotionPreview } from '../../beta-promotion.js';
const operations = new Set<number>();
export const betaOperationInFlight = (id: number) => operations.has(id);
@@ -192,6 +193,7 @@ export class BetaError extends Error {
| 'dest-fail'
| 'invalid-state'
| 'duplicate-script'
| 'preview-changed'
| 'duplicate-target',
public readonly details?: unknown
) {
@@ -721,6 +723,54 @@ export interface PromoteResult {
deploymentId: string;
landed: string[]; // unique servers that received at least one file
scriptIds: number[];
awaitingActivation: number;
failedServers: string[];
}
/** Snapshot exactly the scope expansion and composed configuration reviewed by the operator. */
function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configContent: Map<string, Buffer> } {
const beta = getBeta(betaId);
if (!beta) throw new BetaError('This beta no longer exists.', 'no-such-beta');
const bundle = listBetaScripts(betaId).map(row => {
const script = getScript(row.script_id), version = getVersion(row.script_version_id);
if (!script || !version) throw new BetaError('A selected file version is no longer available.', 'no-such-version');
return { script, version };
});
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
const tested = new Set(listBetaServers(betaId).map(row => row.server));
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
path: script.target_rel_path, versionId: version.id, version: version.version,
release_version: version.release_version, sha256: version.sha256,
scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) }));
const names = [...new Set(files.flatMap(file => file.servers))].sort();
const blockers: string[] = [];
const servers = names.map(name => {
const server = getServer(name);
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
});
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
const configContent = new Map<string, Buffer>();
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
const row = getServer(server);
const composed = row && composeCfgForServer(server, row.game, row.map, {
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
});
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
if (error) blockers.push(error);
const content = composed ? Buffer.from(composed.text, 'utf8') : null;
if (content) configContent.set(server, content);
return { server, path: configFileOf(server), sha256: content ? sha256Buffer(content) : null, error };
});
const impact = { betaId, files, servers, configWrites, blockers, restartPolicy: 'additional-when-empty' as const };
return { preview: { ...impact, token: sha256Buffer(Buffer.from(JSON.stringify(impact))), createdAt: nowIso() }, configContent };
}
export function previewBetaPromotion(betaId: number): BetaPromotionPreview {
return promotionPlan(betaId).preview;
}
function promoteDeploymentId(betaId: number): string {
@@ -728,14 +778,14 @@ function promoteDeploymentId(betaId: number): string {
return `dep-${stamp}-promote-${betaId}`;
}
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
export async function promoteBeta(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
if (storedPlan(betaId)) {
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
await plannedBetaTick();
}
return exclusive(betaId, () => promoteBetaInternal(betaId));
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
}
async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
const test = storedPlan(betaId);
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
const beta = getBeta(betaId);
@@ -743,6 +793,15 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
if (beta.state !== 'ready-promote') {
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
}
const reviewed = promotionPlan(betaId);
const checkReview = () => {
const current = previewBetaPromotion(betaId);
if (current.token !== (reviewedToken ?? reviewed.preview.token)) {
throw new BetaError('Promotion targets or versions changed. Review the updated plan before promoting.', 'preview-changed', current);
}
if (current.blockers.length) throw new BetaError(current.blockers.join(' '), 'invalid-state');
};
checkReview();
const bundleRows = listBetaScripts(betaId);
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
const bundle = bundleRows.map((br) => ({
@@ -755,8 +814,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind));
const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline');
const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config');
// Build a per-(script, server) plan for non-collapsing artifacts.
interface Plan {
@@ -776,10 +833,7 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
);
for (const { script, version } of nonCollapsing) {
const scopeServers = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value)
: [];
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
for (const name of targets) {
const dest = destOfKind(name, script.kind);
@@ -830,23 +884,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
// Write files. Track per-plan success and per-server first-error.
const planWritten = new Map<Plan, boolean>();
const serverErr = new Map<string, string>();
for (const p of plans) {
const dest = destOfKind(p.server, p.script.kind);
const targetPath = join(dest, p.script.target_rel_path);
try {
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
// Beta servers already have the version bytes at targetPath from
// openBeta; scope-expansion servers need the write. Idempotent
// either way — we just overwrite with the promoted content.
await writeFile(targetPath, p.version.content);
planWritten.set(p, true);
} catch (err) {
planWritten.set(p, false);
const msg = `${p.script.name}: ${(err as Error).message}`;
if (!serverErr.has(p.server)) serverErr.set(p.server, msg);
console.error(`[promote] ${p.server}/${p.script.name} failed:`, err);
}
}
// Collapsing config layer: one composed cfg per server. Union of beta
// servers + scope-matching servers for every collapsing artifact.
@@ -860,29 +897,18 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
}
const cfgPlans: CfgPlan[] = [];
if (collapsing.length > 0) {
const cfgTargets = new Set<string>(betaServerSet);
for (const { script } of collapsing) {
if (script.scope_type && script.scope_value) {
for (const s of serversMatchingScope(script.scope_type, script.scope_value)) {
cfgTargets.add(s);
}
}
}
const cfgTargets = reviewed.preview.configWrites.map(write => write.server);
for (const name of cfgTargets) {
const srv = getServer(name);
if (!srv) {
serverErr.set(name, 'unknown server during config promote');
continue;
}
const composedResult = composeCfgForServer(name, srv.game, srv.map, {
inBetaBaseline: collapsingBaseline?.version,
inBetaOverlays: collapsingOverlays
});
if (!composedResult) {
const composedBuf = reviewed.configContent.get(name);
if (!composedBuf) {
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
continue;
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
const composedSha = sha256Buffer(composedBuf);
const dest = destOfKind(name, 'config');
const targetRel = configFileOf(name);
@@ -913,6 +939,20 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
}
}
// Recheck after asynchronous snapshot reads, before any mutation. Execution uses
// the captured target lists/content so later membership changes never expand it.
checkReview();
for (const p of plans) {
const dest = destOfKind(p.server, p.script.kind);
try {
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
planWritten.set(p, true);
} catch (err) {
planWritten.set(p, false);
if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`);
}
}
// Write composed cfg to each target.
const cfgWritten = new Map<CfgPlan, boolean>();
for (const cp of cfgPlans) {
@@ -1061,7 +1101,9 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
betaId,
deploymentId: depId,
landed,
scriptIds: bundle.map((b) => b.script.id)
scriptIds: bundle.map((b) => b.script.id),
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
failedServers: [...serverErr.keys()]
};
}