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
+125 -3
View File
@@ -1,5 +1,6 @@
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { storedPlan } from './beta-tests.js';
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
import type { BetaPromotionPreview } from '../../beta-promotion.js';
const operations = new Set<number>();
@@ -28,17 +29,18 @@ import {
listVersions,
serversMatchingScope,
setScope,
uploadNewVersion,
type ScopeType,
type ScriptRow,
type ScriptVersionRow
} from './scripts.js';
import { composeServerCfg } from './config-compose.js';
import { getServerScript, removeScript } from './inventory.js';
import { cancelStagedFiles, isFileApplied, queueFileChange } from './staged-files.js';
import { cancelStagedFiles, isFileApplied, queueFileChange, stagedFileChanges } from './staged-files.js';
import { emit } from '../pipeline/bus.js';
import { cancelProvisioningJob } from '../provisioning/cancellation.js';
import { reconcileNativeProvisioningReceipt } from '../provisioning/native.js';
import { requestRestart } from '../provisioning/requests.js';
import { requestRestart, releaseRestartRequest } from '../provisioning/requests.js';
import type { BetaStage } from '../pipeline/bus.js';
// Whether an artifact writes a dedicated on-disk file (script/plugin) or
@@ -497,6 +499,124 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
};
}
export interface BetaRevisionUpload { scriptId: number; content: Buffer; releaseVersion?: string }
/** Replace existing bundle members; commit files, deadline and evidence reset together. */
export async function reviseBeta(betaId: number, expectedRevision: number, uploads: BetaRevisionUpload[], notes = ''): Promise<{ revision: number }> {
// Drain a runner already observing the previous revision before resetting it.
const { betaTick } = await import('../pipeline/beta-runner.js');
await betaTick();
return exclusive(betaId, async () => {
const beta = getBeta(betaId);
if (!beta) throw new BetaError('Beta not found', 'no-such-beta');
if (!['running', 'ready-promote'].includes(beta.state)) throw new BetaError('Beta is closed', 'invalid-state');
const current = currentBetaRevision(betaId);
if (current.revision !== expectedRevision) throw new BetaError('The beta changed. Reload and review the current revision.', 'preview-changed');
if (!uploads.length || new Set(uploads.map(file => file.scriptId)).size !== uploads.length) {
throw new BetaError('Choose at least one replacement, once per file.', 'invalid-state');
}
if (notes.length > 1000) throw new BetaError('Change notes must be at most 1000 characters.', 'invalid-state');
const bundle = listBetaScripts(betaId);
for (const file of uploads) {
const member = bundle.find(row => row.script_id === file.scriptId);
if (!member) throw new BetaError('Updates can only replace files already in this beta.', 'invalid-state');
if (!file.content.length) throw new BetaError('Choose non-empty replacement files.', 'invalid-state');
if (getVersion(member.script_version_id)!.sha256 === sha256Buffer(file.content)) {
throw new BetaError(`${getScript(file.scriptId)!.name}: the replacement is identical to the current version.`, 'invalid-state');
}
}
const servers = listBetaServers(betaId);
if (!servers.length || servers.some(server => !isActiveBetaServer(betaId, server.server))) {
throw new BetaError('An original beta server is missing. Roll back and open a new beta.', 'invalid-state');
}
ensureHostSchema(db());
assertNativeActivationSettled(betaId);
if (stagedFileChanges('beta', String(betaId)).some(row => row.state === 'applying')) {
throw new BetaError('A file write was interrupted. Resolve it before updating the beta.', 'conflict');
}
const jobs = db().prepare(`SELECT DISTINCT d.id, d.state, d.submitted_at, n.operation_id FROM server_deployments d
JOIN provisioning_sources s ON s.operation_id = d.id LEFT JOIN native_provisioning n ON n.deployment_id = d.id
WHERE s.kind = 'beta' AND s.source_id = ?`).all(String(betaId)) as
Array<{ id: string; state: string; submitted_at: string | null; operation_id: string | null }>;
if (jobs.some(job => ['running', 'awaiting-health'].includes(job.state)
|| (job.state === 'interrupted' && servers.some(server => server.provisioning_id === job.id))
|| (['waiting', 'queued'].includes(job.state) && (job.submitted_at || job.operation_id)))) {
throw new BetaError('Wait for the current activation and recovery to finish before updating this beta.', 'conflict');
}
const stored = storedPlan(betaId);
const plan = stored ? parseBetaPlan(JSON.parse(stored.plan)) : null;
const revision = current.revision + 1;
const at = nowIso();
db().transaction(() => {
archiveBetaRevision(betaId, current.revision, current.started_at, null);
db().prepare('UPDATE beta_revisions SET results_json = ? WHERE beta_id = ? AND revision = ?')
.run(JSON.stringify({ state: beta.state, test: testView(betaId), servers }), betaId, current.revision);
for (const upload of uploads) {
const version = uploadNewVersion(upload.scriptId, upload.content, notes.trim() || undefined, upload.releaseVersion);
db().prepare('UPDATE beta_scripts SET script_version_id = ? WHERE beta_id = ? AND script_id = ?')
.run(version.id, betaId, upload.scriptId);
db().prepare('UPDATE betas SET script_version_id = ? WHERE id = ? AND script_id = ?')
.run(version.id, betaId, upload.scriptId);
}
// Preflight all targets before cancelling work. Errors roll back the
// uploads and history as well as the beta changes.
const changes = new Map(servers.map(server => [server.server, revisionFileChanges(betaId, server.server)]));
// Betas created before staged activation may only have deployed_at
// and snapshots. Record their first writes before replacing staging.
for (const server of servers.filter(server => server.deployed_at)) {
for (const change of changes.get(server.server)!) {
if (isFileApplied('beta', String(betaId), server.server, change.kind, change.relPath) === null) {
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)')
.run(betaId, server.server, change.relPath);
}
}
}
for (const job of jobs.filter(job => ['waiting', 'queued'].includes(job.state))) {
cancelProvisioningJob(job.id, `Superseded by beta #${betaId} revision ${revision}`);
}
for (const server of servers) releaseRestartRequest(server.server, { kind: 'beta', id: String(betaId) });
// New IDs prevent historical host receipts from resolving to a newer
// revision's desired bytes. Original before images stay intact.
db().prepare("DELETE FROM staged_file_changes WHERE source_kind = 'beta' AND source_id = ?").run(String(betaId));
db().prepare('UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL').run(at, betaId);
db().prepare(`UPDATE beta_servers SET stage = 'queued', error = NULL, deployed_at = NULL,
restarted_at = NULL, healthy_at = NULL, match_seen_at = NULL, updated_at = ? WHERE beta_id = ?`).run(at, betaId);
db().prepare("UPDATE betas SET state = 'running' WHERE id = ?").run(betaId);
setOutcome(betaId, 'active', null);
archiveBetaRevision(betaId, revision, at, notes.trim() || null);
// The original first wave follows selection order; later waves are
// ordered by the runner. Preserve that canary selection on revision.
const firstWave = db().prepare('SELECT server FROM beta_servers WHERE beta_id = ? ORDER BY rowid LIMIT ?')
.all(betaId, plan?.waveSize || servers.length) as { server: string }[];
for (const server of firstWave) {
for (const change of changes.get(server.server)!) queueFileChange(change);
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty' WHERE beta_id = ? AND server = ?").run(betaId, server.server);
}
activity(betaId, `Revision ${revision} submitted; fresh tests and testing window${notes.trim() ? `: ${notes.trim()}` : ''}`);
})();
emit({ type: 'beta-state', betaId, state: 'running' });
return { revision };
});
}
function revisionFileChanges(betaId: number, name: string): Parameters<typeof queueFileChange>[0][] {
const resolved = listBetaScripts(betaId).map(row => ({ script: getScript(row.script_id)!, version: getVersion(row.script_version_id)! }));
const server = getServer(name)!;
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
relPath: script.target_rel_path, content: version.content, scriptVersionId: version.id, inventorySource: `beta:${betaId}` }));
if (resolved.some(row => isCollapsingKind(row.script.kind))) {
const composed = composeCfgForServer(name, server.game, server.map, {
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
inBetaOverlays: resolved.filter(row => row.script.kind === 'config')
});
if (!composed) throw new BetaError(`No baseline available for ${name}.`, 'invalid-state');
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: 'config', relPath: configFileOf(name),
content: Buffer.from(composed.text), inventorySource: `beta:${betaId}` });
}
return changes;
}
/** 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));
@@ -620,6 +740,8 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
const restorations: Array<{ server: string; kind: ArtifactKind; relPath: string; content: Buffer | null; versionId: number | null }> = [];
const changed = db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?');
const applied = (bs: BetaServerRow, kind: ArtifactKind, path: string) => {
// A queued revision may supersede an applied one before its own activation.
if (changed.get(betaId, bs.server, path)) return true;
const staged = isFileApplied('beta', String(betaId), bs.server, kind, path);
if (staged !== null) return staged;
// Existing betas predating staging wrote their files before deployed_at.