Support multi-game betas with automatic server selection

This commit is contained in:
2026-10-07 09:41:17 -04:00
parent a6dee8c5e2
commit c6352d86b4
17 changed files with 881 additions and 173 deletions
+33 -1
View File
@@ -5,6 +5,8 @@ export interface BetaDraft {
version: 1; savedAt: string; step: number;
picks: Array<{ scriptId: number | null; versionId: number | null }>;
servers: string[]; testPlan: BetaPlan;
selectionMode: 'automatic' | 'manual';
instancesPerGame: Record<string, number | null>;
label?: string;
}
/** Validate draft structure, preserving incomplete numeric fields for correction. */
@@ -17,10 +19,40 @@ export function readBetaDraft(raw: string | null): BetaDraft | null {
|| !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string')
|| d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30
|| !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null;
return { ...d, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
if (d.selectionMode !== undefined && !['automatic', 'manual'].includes(d.selectionMode)) return null;
if (d.instancesPerGame !== undefined && (!d.instancesPerGame || typeof d.instancesPerGame !== 'object'
|| Array.isArray(d.instancesPerGame) || !Object.entries(d.instancesPerGame).every(([game, count]) =>
['t5', 't6'].includes(game) && (count === null || typeof count === 'number' && Number.isFinite(count))))) return null;
// Existing drafts represent explicit selections and must not silently switch modes.
return { ...d, selectionMode: d.selectionMode ?? (d.servers.length ? 'manual' : 'automatic'),
instancesPerGame: d.instancesPerGame ?? {}, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
} catch { return null; }
}
interface BetaFileScope { scope_type: 'game' | 'map' | null; scope_value: string | null }
interface BetaServerScope { name: string; game: string | null; map: string | null }
export function betaScopeMatches(file: BetaFileScope, server: BetaServerScope): boolean {
return !file.scope_type || (file.scope_type === 'game' ? server.game : server.map) === file.scope_value;
}
/** A bundle installs each file only on its own matching participants. */
export function betaCandidateServers<T extends BetaServerScope>(files: readonly BetaFileScope[], servers: readonly T[]): T[] {
return servers.filter(server => files.some(file => betaScopeMatches(file, server)));
}
export function betaApplicableGames(files: readonly BetaFileScope[], servers: readonly BetaServerScope[]): string[] {
return [...new Set([
...files.filter(file => file.scope_type === 'game').map(file => file.scope_value),
...betaCandidateServers(files, servers).map(server => server.game)
].filter((game): game is string => game === 't5' || game === 't6'))].sort();
}
/** Keep incomplete counts recoverable and default newly applicable games to one. */
export function betaInstanceCounts(games: readonly string[], saved: Record<string, number | null>): Record<string, number | null> {
return Object.fromEntries(games.map(game => [game, saved[game] === undefined ? 1 : saved[game]]));
}
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
const allowed = new Set(candidates), names = [...selected];
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };
+5 -4
View File
@@ -125,9 +125,8 @@ function migrateTable(handle: Database.Database, table: string, cols: Array<{ na
}
// Backfill the multi-script join tables from the legacy single-script columns.
// Idempotent — every INSERT is guarded by a NOT EXISTS clause on the target
// row's primary key. Safe to run on every boot even after the deprecated
// columns are eventually dropped.
// Existing bundle membership is authoritative. Only single-script betas can
// inherit server membership from the deprecated primary-script columns.
function backfillMultiScriptJoins(handle: Database.Database): void {
handle.exec(
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id)
@@ -144,9 +143,11 @@ function backfillMultiScriptJoins(handle: Database.Database): void {
WHERE b.script_id IS NOT NULL
AND bs.server_deleted_at IS NULL
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
AND (SELECT COUNT(*) FROM beta_scripts x WHERE x.beta_id = b.id) = 1
AND EXISTS (SELECT 1 FROM beta_scripts x WHERE x.beta_id = b.id AND x.script_id = b.script_id)
AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts x
WHERE x.beta_id = bs.beta_id AND x.server = bs.server AND x.script_id = b.script_id
WHERE x.beta_id = bs.beta_id AND x.server = bs.server
)`
);
handle.exec(
+103
View File
@@ -0,0 +1,103 @@
import { db } from '../db/index.js';
import { listHosts } from '../hosts/index.js';
import { protectedWork } from '../autoscaling/snapshot.js';
import { freshStatusTimestamp } from '../../server-freshness.js';
import { listServers, type ServerRow } from './servers.js';
import { observationReader } from './status.js';
import type { ScriptRow } from './scripts.js';
const games = ['t5', 't6'] as const;
type Game = typeof games[number];
type Counts = Partial<Record<Game, number>>;
export function parseBetaInstanceCounts(value: unknown): Counts {
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length) {
throw new Error('instancesPerGame must contain at least one game and instance count');
}
const counts: Counts = {};
for (const [game, count] of Object.entries(value)) {
if (!games.includes(game as Game) || !Number.isSafeInteger(count) || count < 1 || count > 1000) {
throw new Error('Instance counts must be integers from 1 to 1000 for t5 or t6');
}
counts[game as Game] = count;
}
return counts;
}
function matches(script: ScriptRow, server: ServerRow): boolean {
return !script.scope_type || !script.scope_value
|| (script.scope_type === 'game' ? server.game : server.map) === script.scope_value;
}
/** Choose at submission time using the same fresh observations shown in Fleet. */
export function selectBetaServers(scripts: ScriptRow[], instancesPerGame: unknown): string[] {
const counts = parseBetaInstanceCounts(instancesPerGame), observe = observationReader(), now = Date.now();
const hosts = new Map(listHosts().map(host => [host.id, host]));
const busy = new Set((db().prepare(`SELECT server FROM server_deployments WHERE server_deleted_at IS NULL
AND (state IN ('queued','waiting','running','awaiting-health','interrupted')
OR (action IN ('destroy','rebuild') AND state = 'failed'
AND stage IN ('Stopping and removing container','Removing server files and registry')))`)
.all() as { server: string }[]).map(row => row.server));
const candidates = listServers().flatMap(server => {
if (!counts[server.game as Game] || server.desired_state === 'stopped' || busy.has(server.name)
|| !scripts.some(script => matches(script, server)) || protectedWork(server.name)) return [];
const host = server.host_id ? hosts.get(server.host_id) : null;
if (server.host_id && (!host?.connected || host.mode !== 'active' || host.decommission)) return [];
const observation = observe(server);
if (observation.health !== 'online' || observation.last_players === null
|| !freshStatusTimestamp(observation.last_seen_at, now)) return [];
// An occupied RCON observation must not rank as empty just because a
// competing telemetry report claims zero players.
const rconPlayers = freshStatusTimestamp(server.last_seen_at, now)
&& (!observation.telemetry_after || Date.parse(server.last_seen_at!) > Date.parse(observation.telemetry_after))
? server.last_players ?? 0 : 0;
return [{ server, players: Math.max(observation.last_players, rconPlayers) }];
}).sort((a, b) => a.players - b.players || a.server.name.localeCompare(b.server.name));
for (const game of games) {
const available = candidates.filter(candidate => candidate.server.game === game).length;
if ((counts[game] ?? 0) > available) {
throw new Error(`${game.toUpperCase()} needs ${counts[game]} available instances, but only ${available} have fresh status and no pending work. Reduce the count or retry when more servers are available.`);
}
}
for (const script of scripts) {
if (!candidates.some(candidate => matches(script, candidate.server))) {
throw new Error(`${script.name}: no available server in the requested games matches this file's scope.`);
}
}
// Map scopes need a representative even when the least populated servers
// all run the same map. Reserve enough remaining slots for every map.
const requiredMaps = new Set(scripts.filter(script => script.scope_type === 'map').map(script => script.scope_value!));
function canComplete(remaining: typeof candidates, slots: Counts, maps: Set<string>): boolean {
const options = new Map<string, Set<Game>>();
for (const game of games) {
if (remaining.filter(candidate => candidate.server.game === game).length < (slots[game] ?? 0)) return false;
}
for (const map of maps) {
const choices = new Set(remaining.filter(candidate => candidate.server.map === map && (slots[candidate.server.game as Game] ?? 0) > 0)
.map(candidate => candidate.server.game as Game));
if (!choices.size) return false;
options.set(map, choices);
}
// There are two supported games: single-game maps reserve that game's
// slots, and maps available in either game use any remaining slot.
return games.every(game => [...options.values()].filter(choices => choices.size === 1 && choices.has(game)).length <= (slots[game] ?? 0))
&& maps.size <= games.reduce((total, game) => total + (slots[game] ?? 0), 0);
}
if (!canComplete(candidates, counts, requiredMaps)) {
throw new Error('Increase the instance counts to cover every selected file\'s map scope, or choose servers in advanced options.');
}
const selected: string[] = [], slots = { ...counts };
let maps = requiredMaps;
for (let index = 0; index < candidates.length; index++) {
const { server } = candidates[index], game = server.game as Game;
if (!slots[game]) continue;
const nextSlots = { ...slots, [game]: slots[game]! - 1 }, nextMaps = new Set(maps);
if (server.map) nextMaps.delete(server.map);
if (!canComplete(candidates.slice(index + 1), nextSlots, nextMaps)) continue;
selected.push(server.name); slots[game] = nextSlots[game]; maps = nextMaps;
if (games.every(game => !slots[game])) break;
}
return selected;
}
+91 -113
View File
@@ -1,6 +1,7 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { defaultPlan, parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { selectBetaServers } from './beta-selection.js';
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
import type { BetaPromotionPreview } from '../../beta-promotion.js';
@@ -74,7 +75,7 @@ function scopeAppliesToServer(
scope: { scope_type: string | null; scope_value: string | null },
server: { game: string | null; map: string | null }
): boolean {
if (!scope.scope_type || !scope.scope_value) return false;
if (!scope.scope_type || !scope.scope_value) return true;
const field = scope.scope_type === 'game' ? server.game : server.map;
return field === scope.scope_value;
}
@@ -265,10 +266,12 @@ export function isActiveBetaServer(betaId: number, server: string): boolean {
.get(betaId, server);
}
export function listBetaScripts(betaId: number): BetaScriptRow[] {
export function listBetaScripts(betaId: number, server?: string): BetaScriptRow[] {
return db()
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
.all(betaId) as BetaScriptRow[];
.prepare(`SELECT bs.* FROM beta_scripts bs WHERE bs.beta_id = ?
${server === undefined ? '' : 'AND EXISTS (SELECT 1 FROM beta_server_scripts bss WHERE bss.beta_id = bs.beta_id AND bss.script_id = bs.script_id AND bss.server = ?)'}
ORDER BY bs.script_id`)
.all(betaId, ...(server === undefined ? [] : [server])) as BetaScriptRow[];
}
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
@@ -299,7 +302,8 @@ export interface OpenBetaScript {
export interface OpenBetaInput {
scripts: OpenBetaScript[];
servers: string[];
servers?: string[];
instancesPerGame?: Record<string, number>;
testPlan?: BetaPlan;
waitForHealth: boolean;
waitForMatch: boolean;
@@ -313,26 +317,18 @@ 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");
const automatic = input.instancesPerGame !== undefined;
if (automatic && input.servers !== undefined) throw new BetaError('Choose automatic instance counts or explicit servers, not both', 'invalid-state');
const plan = automatic ? parseBetaPlan({ ...(input.testPlan ?? defaultPlan()), scope: 'all' })
: input.testPlan ? parseBetaPlan(input.testPlan) : null;
if (!input.scripts || input.scripts.length === 0) {
throw new BetaError('at least one script is required', 'no-scripts');
}
if (!input.servers || input.servers.length === 0) {
throw new BetaError('at least one server is required', 'no-servers');
}
// Resolve every (script, version) pair and validate belonging.
interface Resolved { script: ScriptRow; version: ScriptVersionRow }
const resolved: Resolved[] = [];
const seenScriptIds = new Set<number>();
// For non-collapsing kinds (script/plugin), two artifacts targeting the
// same rel_path within one beta is an internal race — reject up front.
// Collapsing kinds (config/baseline) intentionally share a target file
// (dedicated_sp.cfg) and compose, so they skip this check.
const seenTargets = new Map<string, number>();
let baselineInBeta: Resolved | null = null;
for (const s of input.scripts) {
if (seenScriptIds.has(s.scriptId)) {
throw new BetaError(`script ${s.scriptId} listed twice in one beta`, 'duplicate-script');
@@ -348,33 +344,23 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
'version-script-mismatch'
);
}
if (!isCollapsingKind(script.kind)) {
const targetKey = `${script.kind}:${script.target_rel_path}`;
if (seenTargets.has(targetKey)) {
throw new BetaError(
`${script.kind}s ${seenTargets.get(targetKey)} and ${script.id} share target ${script.target_rel_path}`,
'duplicate-target'
);
}
seenTargets.set(targetKey, script.id);
}
if (script.kind === 'baseline') {
if (baselineInBeta) {
throw new BetaError(
`two baselines in one beta (${baselineInBeta.script.id}, ${script.id}) — betas support at most one baseline`,
'duplicate-target'
);
}
baselineInBeta = { script, version };
}
resolved.push({ script, version });
}
let selectedServers = input.servers;
if (automatic) {
try { selectedServers = selectBetaServers(resolved.map(row => row.script), input.instancesPerGame); }
catch (error) { throw new BetaError((error as Error).message, 'invalid-state'); }
}
if (!selectedServers?.length) throw new BetaError('at least one server is required', 'no-servers');
if (new Set(selectedServers).size !== selectedServers.length) throw new BetaError('Select each server only once', 'invalid-state');
if (plan?.scope === 'quorum' && plan.quorum > selectedServers.length) throw new BetaError('Quorum exceeds selected servers', 'invalid-state');
// Look up server rows once.
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
for (const name of input.servers) {
for (const name of selectedServers) {
const row = getServer(name);
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
if (!row || row.destroyed_at) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
// Once teardown is queued or in flight, opening a beta cannot safely
// claim the instance. A failed remote request may still be running.
const removal = db().prepare(`SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL
@@ -391,29 +377,30 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
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) {
if (!script.scope_type || !script.scope_value) continue;
const bad = serverRows.filter(
(r) => (script.scope_type === 'game' ? r.game : r.map) !== script.scope_value
);
if (bad.length > 0) {
outOfScope.push({
scriptId: script.id,
scope: `${script.scope_type}=${script.scope_value}`,
servers: bad.map((r) => r.name)
});
// Each server receives only matching bundle members. Unscoped files apply
// to every selected server; every file must be exercised by at least one.
const perServer = new Map(serverRows.map(server => [server.name, resolved.filter(({ script }) => scopeAppliesToServer(script, server))]));
for (const [server, members] of perServer) {
if (!members.length) throw new BetaError(`${server}: no bundled files apply to this server`, 'out-of-scope');
const seenTargets = new Map<string, number>();
for (const { script } of members) {
if (script.kind === 'config') continue;
const target = script.kind === 'baseline' ? 'baseline' : `${script.kind}:${script.target_rel_path}`;
const prior = seenTargets.get(target);
if (prior !== undefined) throw new BetaError(`${server}: files ${prior} and ${script.id} share target ${script.target_rel_path}`, 'duplicate-target');
seenTargets.set(target, script.id);
}
}
if (outOfScope.length > 0) {
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
for (const { script } of resolved) {
if (!serverRows.some(server => scopeAppliesToServer(script, server))) {
throw new BetaError(`${script.name}: select at least one server matching ${script.scope_type}=${script.scope_value}`, 'out-of-scope');
}
}
// A beta outranks campaigns during activation. Do not let it overtake older
// staged writes which would otherwise overwrite the tested version on closure.
// Legacy campaigns have no staged payload and retain their restart-only priority.
const queuedCampaigns = input.servers.flatMap(unresolvedCampaignWrites);
const queuedCampaigns = selectedServers.flatMap(unresolvedCampaignWrites);
if (queuedCampaigns.length) {
const interrupted = queuedCampaigns.some(row => row.state === 'applying');
throw new BetaError(interrupted
@@ -425,9 +412,9 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
// beta already claimed this target"; the previous stale-.OLD check is no
// longer relevant now that snapshots live in the DB.
const conflicts: Array<{ server: string; scriptId: number; heldByBetaId: number }> = [];
for (const name of input.servers) {
for (const name of selectedServers) {
const held = heldByBeta(name);
for (const { script } of resolved) {
for (const { script } of perServer.get(name)!) {
const owner = held.get(script.target_rel_path);
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
}
@@ -466,13 +453,13 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
for (const r of resolved) {
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
}
for (const name of input.servers) {
for (const name of selectedServers) {
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) {
for (const r of perServer.get(name)!) {
insertBetaServerScript.run(
betaId,
name,
@@ -485,18 +472,18 @@ 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);
await deployBetaServers(betaId, plan?.waveSize ? selectedServers.slice(0, plan.waveSize) : selectedServers);
emit({
type: 'beta-opened',
betaId,
scriptIds: resolved.map((r) => r.script.id),
servers: input.servers
servers: selectedServers
});
return {
betaId,
servers: input.servers,
servers: selectedServers,
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
};
}
@@ -602,7 +589,7 @@ export async function reviseBeta(betaId: number, expectedRevision: number, uploa
}
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 resolved = listBetaScripts(betaId, name).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,
@@ -612,7 +599,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
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');
if (!composed) throw new BetaError(`no baseline available for ${name} — upload one before running config overlays`, 'invalid-state');
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: 'config', relPath: configFileOf(name),
content: Buffer.from(composed.text), inventorySource: `beta:${betaId}` });
}
@@ -628,47 +615,24 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
if (!isActiveBetaServer(betaId, name)) throw new BetaError(`${name}: original beta server was deleted; start a new beta`, 'unknown-server');
}
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));
const collapsingOverlays = resolved.filter((r) => r.script.kind === 'config');
const collapsingBaseline = resolved.find((r) => r.script.kind === 'baseline');
// Archive: one copy per non-collapsing artifact under its target basename,
// plus one composed preview per beta server for the config layer.
// Script IDs distinguish same-named files for different games in archives.
const archive = betaArchiveOf(betaId);
await mkdir(archive, { recursive: true });
for (const r of nonCollapsing) {
const base = posix.basename(r.script.target_rel_path);
await writeFile(join(archive, base), getVersionContent(r.version.id));
await writeFile(join(archive, `${r.script.id}.${base}`), getVersionContent(r.version.id));
}
// Persist the requested bytes without touching any live destination. Provisioning
// captures the original file and inventory immediately before guarded activation.
for (const name of names) {
if (!isActiveBetaServer(betaId, name)) continue;
const serverRow = serverRows.find((r) => r.name === name)!;
try {
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
sourceKind: 'beta' as const, sourceId: String(betaId), server: name,
kind: script.kind, relPath: script.target_rel_path, content: getVersionContent(version.id),
scriptVersionId: version.id, inventorySource: `beta:${betaId}`
}));
if (collapsingBaseline || collapsingOverlays.length > 0) {
const composedResult = composeCfgForServer(name, serverRow.game, serverRow.map, {
inBetaBaseline: collapsingBaseline?.version,
inBetaOverlays: collapsingOverlays
});
if (!composedResult) {
throw new Error(`no baseline available for ${name} (game=${serverRow.game ?? '?'}) — upload one before running config overlays`);
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
changes.push({ sourceKind: 'beta', sourceId: String(betaId), server: name,
kind: 'config', relPath: configFileOf(name), content: composedBuf,
scriptVersionId: null, inventorySource: `beta:${betaId}` });
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
}
const changes = revisionFileChanges(betaId, name);
const configChange = changes.find(change => change.kind === 'config');
if (configChange?.content) await writeFile(join(archive, `${name}.${configFileOf(name)}`), configChange.content);
if (!isActiveBetaServer(betaId, name)) continue;
db().transaction(() => {
for (const change of changes) queueFileChange(change);
@@ -732,8 +696,6 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
const dropped = new Set<string>();
const warnings: string[] = [];
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare(
`SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?`
@@ -754,7 +716,8 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
continue;
}
for (const { script } of bundle) {
const members = bundle.filter(({ script }) => getBetaServerScript(betaId, bs.server, script.id));
for (const { script } of members) {
if (isCollapsingKind(script.kind) || !applied(bs, script.kind, script.target_rel_path)) continue;
const bss = getBetaServerScript(betaId, bs.server, script.id);
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
@@ -769,7 +732,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
(snap ? restored : dropped).add(bs.server);
}
const targetRel = configFileOf(bs.server);
if (hasCollapsing && applied(bs, 'config', targetRel)) {
if (members.some(({ script }) => isCollapsingKind(script.kind)) && applied(bs, 'config', targetRel)) {
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
| undefined;
@@ -878,8 +841,12 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
const participants = listBetaServers(betaId);
const tested = new Set(participants.filter(row => isActiveBetaServer(betaId, row.server)).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 targetsByScript = new Map(bundle.map(({ script }) => {
const participants = [...tested].filter(server => getBetaServerScript(betaId, server, script.id));
const scopeServers = script.scope_type && script.scope_value ? serversMatchingScope(script.scope_type, script.scope_value) : [];
return [script.id, [...new Set([...participants, ...scopeServers])].sort()];
}));
const targets = (script: ScriptRow) => targetsByScript.get(script.id)!;
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,
@@ -890,15 +857,23 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
const servers = names.map(name => {
const server = getServer(name);
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
const destinations = new Map<string, number>();
for (const file of files.filter(file => file.servers.includes(name) && file.kind !== 'config')) {
const target = file.kind === 'baseline' ? 'baseline' : `${file.kind}:${file.path}`;
const prior = destinations.get(target);
if (prior !== undefined) blockers.push(`${name}: files ${prior} and ${file.id} share target ${file.path}.`);
destinations.set(target, file.id);
}
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 matching = collapsing.filter(({ script }) => targets(script).includes(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')
inBetaBaseline: matching.find(({ script }) => script.kind === 'baseline')?.version,
inBetaOverlays: matching.filter(({ script }) => script.kind === 'config')
});
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
if (error) blockers.push(error);
@@ -966,6 +941,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
priorSha: string | null;
priorContent: Buffer | null;
hadFile: boolean;
tested: boolean;
}
const plans: Plan[] = [];
@@ -979,8 +955,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
for (const name of targets) {
let plan: Plan;
if (betaServerSet.has(name)) {
const bss = getBetaServerScript(betaId, name, script.id);
const bss = getBetaServerScript(betaId, name, script.id);
if (bss && betaServerSet.has(name)) {
if (bss?.had_original) {
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
| { prior_content: Buffer; prior_sha256: string }
@@ -996,15 +972,15 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
}
plan = {
script, version, server: name,
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true, tested: true
};
} else {
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: true };
}
} else {
// Campaign activation captures the actual before image just
// before replacing this file. Do not wait on remote reads here.
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false, tested: false };
}
plans.push(plan);
}
@@ -1023,6 +999,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
composedSha: string;
hadFile: boolean;
requiresActivation: boolean;
tested: boolean;
}
const cfgPlans: CfgPlan[] = [];
if (collapsing.length > 0) {
@@ -1046,8 +1023,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
let priorContent: Buffer | null = null;
let priorSha: string | null = null;
let hadFile = false;
const tested = betaServerSet.has(name) && collapsing.some(({ script }) => getBetaServerScript(betaId, name, script.id));
// Beta server: preserve its original bytes captured at activation.
if (betaServerSet.has(name)) {
if (tested) {
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
| { prior_content: Buffer; prior_sha256: string }
| undefined;
@@ -1057,9 +1035,9 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
hadFile = true;
}
}
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
const requiresActivation = !tested || !await exists(targetPath)
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation, tested });
}
}
@@ -1070,7 +1048,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
// beta was active. Reassert the tested bytes after that older campaign, even
// when they already match the live file, so it cannot overwrite this release.
const queuedByServer = new Map(reviewed.preview.servers.map(server => [server.name, unresolvedCampaignWrites(server.name)]));
const plansToActivate = new Set(plans.filter(p => !betaServerSet.has(p.server)
const plansToActivate = new Set(plans.filter(p => !p.tested
|| queuedByServer.get(p.server)?.some(row => row.kind === p.script.kind && row.rel_path === p.script.target_rel_path)));
for (const cp of cfgPlans) {
if (queuedByServer.get(cp.server)?.some(row => row.kind === 'config' && row.rel_path === configFileOf(cp.server))) cp.requiresActivation = true;
@@ -1124,13 +1102,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
p.version.sha256,
putFileBlob(p.priorContent),
putFileBlob(getVersionContent(p.version.id)),
betaServerSet.has(p.server) ? 0 : 1
p.tested ? 0 : 1
);
if (plansToActivate.has(p)) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: p.tested });
}
}
for (const cp of cfgPlans) {
@@ -1144,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
cp.composedSha,
putFileBlob(cp.priorContent),
putFileBlob(cp.composedContent),
betaServerSet.has(cp.server) ? 0 : 1
cp.tested ? 0 : 1
);
if (cp.requiresActivation) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: cp.server,
kind: 'config', relPath: configFileOf(cp.server), content: cp.composedContent,
scriptVersionId: null, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(cp.server) });
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: cp.tested });
}
}
// deployment_servers — one row per unique server. Beta servers are
+28 -13
View File
@@ -24,6 +24,7 @@ interface DeploymentRow {
script_version_id: number | null;
prior_version_id: number | null;
rolled_back_by: string | null;
beta_id: number | null;
}
interface DepScriptRow {
@@ -70,7 +71,7 @@ export interface RollbackDeploymentResult {
export async function rollbackPromoteDeployment(promoteId: string): Promise<RollbackDeploymentResult> {
const dep = db()
.prepare(`SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by
.prepare(`SELECT id, action, script_id, script_version_id, prior_version_id, rolled_back_by, beta_id
FROM deployments WHERE id = ?`)
.get(promoteId) as DeploymentRow | undefined;
if (!dep) throw new RollbackError(`no such deployment ${promoteId}`, 'no-such-deployment');
@@ -109,9 +110,9 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
)
.all(promoteId) as FileRow[];
// Map rel_path → scriptId (and prior_version_id) so we can restore the
// inventory row's script_version_id correctly during file restore.
const scriptByTarget = new Map<string, DepScriptRow>();
// Different games may promote distinct scripts with the same target path.
// Retain all candidates and resolve ownership from the per-server history.
const scriptsByTarget = new Map<string, DepScriptRow[]>();
const kindByScriptId = new Map<number, ArtifactKind>();
if (scripts.length > 0) {
const placeholders = scripts.map(() => '?').join(',');
@@ -125,11 +126,25 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
}
for (const s of scripts) {
const t = targetById.get(s.script_id);
if (t) scriptByTarget.set(t, s);
if (t) scriptsByTarget.set(t, [...(scriptsByTarget.get(t) ?? []), s]);
}
}
const kindForRelPath = (relPath: string): ArtifactKind => {
const owner = scriptByTarget.get(relPath);
const staged = stagedFileChanges('campaign', promoteId);
const scriptForFile = (file: FileRow): DepScriptRow | undefined => {
const candidates = scriptsByTarget.get(file.rel_path) ?? [];
const versionId = staged.find(row => row.deployment_file_id === file.id)?.script_version_id;
if (versionId) return candidates.find(script => script.script_version_id === versionId);
if (dep.beta_id) {
const participant = candidates.find(script => db().prepare(`SELECT 1 FROM beta_server_scripts
WHERE beta_id = ? AND server = ? AND script_id = ?`).get(dep.beta_id, file.server, script.script_id));
if (participant) return participant;
}
return candidates.find(script => getVersion(script.script_version_id)?.sha256 === file.sha256_after) ?? candidates[0];
};
const kindForFile = (file: FileRow): ArtifactKind => {
const stagedKind = staged.find(row => row.deployment_file_id === file.id)?.kind;
if (stagedKind) return stagedKind;
const owner = scriptForFile(file);
if (owner) return owner.kind ?? kindByScriptId.get(owner.script_id) ?? 'script';
// Config/baseline aggregate rows have no per-artifact target_rel_path
// mapping — the deployment_files row is keyed on the shared cfg name.
@@ -144,10 +159,6 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
// Group files by server so each server gets exactly one deployment_servers row.
const perServer = new Map<string, FileRow[]>();
const staged = stagedFileChanges('campaign', promoteId);
// Composed baseline rows are physically staged as config files. Prefer the
// explicit link instead of inferring their destination from the library.
const kindForFile = (file: FileRow) => staged.find(row => row.deployment_file_id === file.id)?.kind ?? kindForRelPath(file.rel_path);
for (const f of fileRows) {
// A promotion can still be waiting for empty servers. Withdrawing it
// must not restore or remove files that it never installed. Rows from
@@ -230,8 +241,12 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
putFileBlob(f.content_after),
putFileBlob(f.content_before)
);
const owningScript = scriptByTarget.get(f.rel_path);
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null;
const owningScript = scriptForFile(f);
const betaPrior = dep.beta_id && owningScript ? db().prepare(`SELECT prior_version_id FROM beta_server_scripts
WHERE beta_id = ? AND server = ? AND script_id = ?`).get(dep.beta_id, server, owningScript.script_id) as
{ prior_version_id: number | null } | undefined : undefined;
const priorVersionId = betaPrior ? betaPrior.prior_version_id : owningScript?.prior_version_id;
const priorVersion = priorVersionId ? getVersion(priorVersionId) : null;
queueFileChange({ sourceKind: 'campaign', sourceId: newDepId, server,
kind: kindForFile(f), relPath: f.rel_path,
content: f.verb === 'added' ? null : f.content_before,
+2 -2
View File
@@ -228,7 +228,7 @@ export function heldByBeta(server: string): Map<string, number> {
`SELECT s.target_rel_path AS target, b.id AS beta_id
FROM beta_servers bsv
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN beta_server_scripts bs ON bs.beta_id = b.id AND bs.server = bsv.server
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`
)
@@ -247,7 +247,7 @@ export function ownerOf(server: string, target: string): number | null {
`SELECT b.id AS beta_id
FROM beta_servers bsv
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN beta_server_scripts bs ON bs.beta_id = b.id AND bs.server = bsv.server
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
LIMIT 1`
+2 -1
View File
@@ -17,7 +17,8 @@ export function fleetSnapshot(serverName?: string) {
.all(...bindings) as NativeInstance[]).map(instance => [instance.server, instance]));
const holds = new Map<string, { target: string; betaId: number }[]>();
for (const row of db().prepare(`SELECT bsv.server, s.target_rel_path AS target, b.id AS betaId
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id JOIN beta_scripts bs ON bs.beta_id=b.id
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id
JOIN beta_server_scripts bs ON bs.beta_id=b.id AND bs.server=bsv.server
JOIN scripts s ON s.id=bs.script_id WHERE bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')
${serverName === undefined ? '' : 'AND bsv.server = ?'}`).all(...bindings) as
{ server: string; target: string; betaId: number }[]) {
+9 -1
View File
@@ -158,8 +158,16 @@ async function advancePlan(stored: StoredPlan): Promise<void> {
}
const registry = db().prepare('SELECT server AS name, game, map FROM beta_test_coverage WHERE beta_id = ?').all(id) as Array<{ name: string; game: string | null; map: string | null }>;
// All waves must be activated, even when the final coverage rule is a quorum.
// A passing server proves only its assigned files. A quorum or coverage
// group must not pass an unrelated game/map's untested bundle member.
const ready = !queued.length && updated.every(s => !!currentRun(id, s.server)?.observing_at)
&& coveragePassed(plan, updated.map(s => ({ ...registry.find(r => r.name === s.server)!, passed: s.stage === 'ready' })));
&& coveragePassed(plan, updated.map(s => ({ ...registry.find(r => r.name === s.server)!, passed: s.stage === 'ready' })))
&& !db().prepare(`SELECT 1 FROM beta_scripts files WHERE files.beta_id = ? AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts members JOIN beta_servers servers
ON servers.beta_id = members.beta_id AND servers.server = members.server
WHERE members.beta_id = files.beta_id AND members.script_id = files.script_id
AND servers.server_deleted_at IS NULL AND servers.stage = 'ready'
) LIMIT 1`).get(id);
betaState(id, ready);
setOutcome(id, ready ? 'passed' : 'active', null);
}
+7 -2
View File
@@ -2,7 +2,7 @@
import { onMount, tick } from 'svelte';
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
let { plan = $bindable<BetaPlan>(), serverCount }: { plan: BetaPlan; serverCount?: number } = $props();
let { plan = $bindable<BetaPlan>(), serverCount, allServersRequired = false }: { plan: BetaPlan; serverCount?: number; allServersRequired?: boolean } = $props();
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
@@ -47,6 +47,7 @@
try {
// Validation returns an independent plain object, even when the source is a Svelte proxy.
plan = parseBetaPlan(preset.plan);
if (allServersRequired) plan.scope = 'all';
appliedPresetName = name;
choosingPreset = false;
editingId = ''; error = '';
@@ -178,15 +179,19 @@
<summary><span>Coverage &amp; waves<small>{coverageSummary(plan)} · {waveSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body">
<div class="fields">
{#if allServersRequired}
<p>Every requested instance must pass. Change the instance counts in the Servers step.</p>
{:else}
<label>Qualifying servers<select bind:value={plan.scope}>
<option value="all">Every selected server</option><option value="quorum">At least N selected servers</option>
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
<option value="map-game">At least one per selected game/map combination</option>
</select></label>
{#if plan.scope === 'quorum'}<label>Required servers<input id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
{/if}
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
</div>
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</p>
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing, and every file must pass on at least one matching instance. Restarts always wait for empty servers.</p>
</div>
</details>
<details class="policy" bind:open={deadlineOpen}>