Add configurable beta test plans and staged rollout

This commit is contained in:
2026-09-17 21:35:28 -04:00
parent 5b5266da3a
commit a50eae9d21
26 changed files with 1518 additions and 44 deletions
+71 -15
View File
@@ -1,3 +1,14 @@
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { storedPlan } from './beta-tests.js';
const operations = new Set<number>();
export const betaOperationInFlight = (id: number) => operations.has(id);
async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
if (operations.has(id)) throw new BetaError('A beta operation is already running', 'invalid-state');
operations.add(id);
try { return await action(); } finally { operations.delete(id); }
}
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, posix } from 'node:path';
@@ -229,7 +240,7 @@ export function findRunningBetaForScript(scriptId: number): BetaRow | undefined
.prepare(
`SELECT b.* FROM betas b
JOIN beta_scripts bs ON bs.beta_id = b.id
WHERE bs.script_id = ? AND b.state = 'running'
WHERE bs.script_id = ? AND b.state IN ('running','ready-promote')
LIMIT 1`
)
.get(scriptId) as BetaRow | undefined;
@@ -245,6 +256,7 @@ export interface OpenBetaScript {
export interface OpenBetaInput {
scripts: OpenBetaScript[];
servers: string[];
testPlan?: BetaPlan;
waitForHealth: boolean;
waitForMatch: boolean;
notes?: string;
@@ -257,6 +269,9 @@ export interface OpenBetaResult {
}
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
const plan = input.testPlan ? parseBetaPlan(input.testPlan) : null;
if (new Set(input.servers).size !== input.servers.length) throw new BetaError("Select each server only once", "invalid-state");
if (plan?.scope === "quorum" && plan.quorum > input.servers.length) throw new BetaError("Quorum exceeds selected servers", "invalid-state");
if (!input.scripts || input.scripts.length === 0) {
throw new BetaError('at least one script is required', 'no-scripts');
}
@@ -319,6 +334,11 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
serverRows.push({ name, game: row.game, map: row.map });
}
if (plan && ['map', 'game', 'map-game'].includes(plan.scope)) {
const missing = serverRows.some(s => (plan.scope !== 'map' && !s.game) || (plan.scope !== 'game' && !s.map));
if (missing) throw new BetaError('Coverage requires game/map metadata on every selected server', 'invalid-state');
}
// Per-script scope validation. Aggregate all violations into one error.
const outOfScope: Array<{ scriptId: number; scope: string; servers: string[] }> = [];
for (const { script } of resolved) {
@@ -387,11 +407,16 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
input.notes ?? null
);
betaId = Number(res.lastInsertRowid);
if (plan) db().prepare('INSERT INTO beta_test_plans (beta_id, plan) VALUES (?, ?)').run(betaId, JSON.stringify(plan));
for (const r of resolved) {
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
}
for (const name of input.servers) {
insertBetaServer.run(betaId, name, nowIso());
if (plan) {
const selected = serverRows.find(s => s.name === name)!;
db().prepare('INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (?, ?, ?, ?)').run(betaId, name, selected.game, selected.map);
}
for (const r of resolved) {
const prior = getServerScript(name, r.script.target_rel_path);
insertBetaServerScript.run(
@@ -405,6 +430,30 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
}
})();
if (plan?.waveSize) db().prepare("UPDATE beta_servers SET stage = 'queued' WHERE beta_id = ?").run(betaId);
await deployBetaServers(betaId, plan?.waveSize ? input.servers.slice(0, plan.waveSize) : input.servers);
emit({
type: 'beta-opened',
betaId,
scriptIds: resolved.map((r) => r.script.id),
servers: input.servers
});
return {
betaId,
servers: input.servers,
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
};
}
/** Apply a reserved wave only when its predecessors have passed. */
export async function deployBetaServers(betaId: number, names: string[]): Promise<void> {
return exclusive(betaId, () => deployBetaServersInternal(betaId, names));
}
async function deployBetaServersInternal(betaId: number, names: string[]): Promise<void> {
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
const serverRows = names.map(name => getServer(name)!);
// Split resolved artifacts into non-collapsing (each writes its own file)
// and collapsing (config/baseline overlay onto one shared dedicated_sp.cfg).
const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind));
@@ -434,7 +483,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
WHERE beta_id = ? AND server = ?`
);
for (const name of input.servers) {
for (const name of names) {
db().prepare("UPDATE beta_servers SET stage = 'deploying', deployed_at = ? WHERE beta_id = ? AND server = ?")
.run(nowIso(), betaId, name);
const serverRow = serverRows.find((r) => r.name === name)!;
let firstErr: string | null = null;
// 1. Non-collapsing artifacts: script/plugin — write directly to the
@@ -456,7 +507,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
sha256Buffer(priorBytes),
priorBytes.byteLength
);
db().prepare('UPDATE beta_server_scripts SET had_original = 1 WHERE beta_id = ? AND server = ? AND script_id = ?').run(betaId, name, script.id);
}
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, script.target_rel_path);
await writeFile(targetPath, version.content);
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `beta:${betaId}`);
} catch (err) {
@@ -494,6 +547,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
);
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, targetRel);
await writeFile(targetPath, composedBuf);
recordScript(
name,
@@ -519,18 +573,6 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
}
}
emit({
type: 'beta-opened',
betaId,
scriptIds: resolved.map((r) => r.script.id),
servers: input.servers
});
return {
betaId,
servers: input.servers,
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
};
}
// ---------- rollback ----------
@@ -543,6 +585,9 @@ export interface RollbackResult {
}
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
return exclusive(betaId, () => rollbackBetaInternal(betaId));
}
async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
const beta = getBeta(betaId);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
@@ -570,8 +615,10 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
);
for (const bs of servers) {
if (!bs.deployed_at && bs.stage === 'queued') continue;
for (const { script } of bundle) {
if (isCollapsingKind(script.kind)) continue; // handled once per server below
if (storedPlan(betaId) && !db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, script.target_rel_path)) continue;
const dest = destOfKind(bs.server, script.kind);
const targetPath = join(dest, script.target_rel_path);
@@ -601,7 +648,7 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
}
// Collapsing config layer: single restore for the shared cfg file.
if (hasCollapsing) {
if (hasCollapsing && (!storedPlan(betaId) || db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, configFileOf(bs.server)))) {
const dest = destOfKind(bs.server, 'config');
const targetRel = configFileOf(bs.server);
const targetPath = join(dest, targetRel);
@@ -681,6 +728,15 @@ function promoteDeploymentId(betaId: number): string {
}
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
if (storedPlan(betaId)) {
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
await plannedBetaTick();
}
return exclusive(betaId, () => promoteBetaInternal(betaId));
}
async function promoteBetaInternal(betaId: number): 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);
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
if (beta.state !== 'ready-promote') {