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:
@@ -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()]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user