From c6352d86b412a5c6bba6b3218676980e0094c51a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 09:41:17 -0400 Subject: [PATCH] Support multi-game betas with automatic server selection --- docs/guides/beta-test-plans.md | 49 ++++- src/lib/beta-draft.ts | 34 +++- src/lib/server/db/index.ts | 9 +- src/lib/server/fleet/beta-selection.ts | 103 ++++++++++ src/lib/server/fleet/beta.ts | 204 +++++++++---------- src/lib/server/fleet/rollback.ts | 41 ++-- src/lib/server/fleet/servers.ts | 4 +- src/lib/server/fleet/snapshot.ts | 3 +- src/lib/server/pipeline/beta-plan-runner.ts | 10 +- src/lib/ui/components/BetaPlanBuilder.svelte | 9 +- src/routes/api/betas/+server.ts | 25 ++- src/routes/betas/new/+page.svelte | 96 ++++++--- tests/unit/beta-memberships.test.ts | 81 ++++++++ tests/unit/beta-multigame.test.ts | 159 +++++++++++++++ tests/unit/beta-plans.test.ts | 37 ++++ tests/unit/beta-selection.test.ts | 148 ++++++++++++++ tests/unit/beta-setup.test.ts | 42 +++- 17 files changed, 881 insertions(+), 173 deletions(-) create mode 100644 src/lib/server/fleet/beta-selection.ts create mode 100644 tests/unit/beta-memberships.test.ts create mode 100644 tests/unit/beta-multigame.test.ts create mode 100644 tests/unit/beta-selection.test.ts diff --git a/docs/guides/beta-test-plans.md b/docs/guides/beta-test-plans.md index e41e9b5..ae18f98 100644 --- a/docs/guides/beta-test-plans.md +++ b/docs/guides/beta-test-plans.md @@ -1,9 +1,42 @@ # Beta test plans -Create a plan in Betas, choose its servers, and deploy. The server's game script +Create a plan in Betas, choose how many instances per game must pass, and deploy. The server's game script reports gameplay to Electric Cherry. The same reports update Fleet and any active beta on that server. +## Files and automatic server selection + +A beta can bundle files for different games, including T5 and T6 together. Each +file installs only on participants matching its game or map scope; an unscoped +file applies to all participants. Every file needs at least one matching test +participant. For example, a T5 script and a T6 script can share one beta without +either script being installed on the other game's servers. + +Setup defaults to automatic selection with one instance per applicable game. +Choose the number that must pass for each game. Every automatically selected +instance must pass every requirement in the plan. Set a game's count to zero to +omit it only when every selected file still has a matching participant. + +When you choose **Start beta**, Electric Cherry chooses compatible servers with +the fewest current players, while ensuring that every file is tested. Counts +are exact: requesting two T5 and three T6 instances selects five participants, +and all five must pass. Server choices are made at launch, so review shows the +requested counts rather than a provisional list of server names. + +Automatic selection requires fresh player observations and available running +servers. Servers held by another active beta or with pending deployment work +are excluded, as are instances on unavailable, draining, or disabled hosts. +Unknown or stale player counts are not treated as empty. If the +requested counts cannot be met, including coverage of every selected map-scoped +file, the beta does not start. Deployment and restarts still wait for confirmed +empty lobbies and restart capacity. + +Open **Advanced options → Choose specific servers** for manual selection. The +picker includes servers matching any file in the bundle; each file must have a +matching selected server. Manual selection retains the configurable coverage +rules below. Drafts preserve the selection mode and counts, and older drafts +with explicit server choices resume in manual mode. + ## Game reporter protocol Set `EC_TELEMETRY_TOKEN` on the Electric Cherry controller to a dedicated random @@ -209,9 +242,11 @@ Health intervals spanning more than 60 seconds earn no healthy time. ## Coverage, waves and outcomes -Coverage can require all selected servers, a quorum, one per selected map, +Automatic selection requires all requested instances to pass. With manual server +selection, coverage can require all selected servers, a quorum, one per selected map, one per selected game, or one per selected game/map combination. Coverage -metadata is pinned when the beta opens. +metadata is pinned when the beta opens. Every bundled file also needs at least +one passing server that received it; a quorum cannot skip testing a game's file. Set wave size to 1 for a canary, a larger number for batches, or 0 for all selected servers. Later waves receive files after the previous waves pass. @@ -288,7 +323,13 @@ changing the policy still requires a new beta. ## API and storage -`POST /api/betas` accepts `testPlan`. +`POST /api/betas` accepts `scripts`, `testPlan`, and either +`instancesPerGame` (for example, `{ "t5": 2, "t6": 3 }`) for automatic selection +or `servers` with explicit registered server names for manual selection. Submit +only positive integer counts in `instancesPerGame`; omit games set to zero in +the UI. Automatic selection sets the plan's coverage `scope` to `all`. +Do not send both selection forms. File scopes determine which bundle files each +participant receives in both modes. `GET /api/betas/:id` includes the plan, outcome, per-server progress, and recent evidence under `test`. diff --git a/src/lib/beta-draft.ts b/src/lib/beta-draft.ts index e1ee635..63e7183 100644 --- a/src/lib/beta-draft.ts +++ b/src/lib/beta-draft.ts @@ -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; 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(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): Record { + return Object.fromEntries(games.map(game => [game, saved[game] === undefined ? 1 : saved[game]])); +} + export function compatibleBetaServers(selected: Iterable, candidates: readonly string[]): { kept: Set; 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)) }; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 2a25a83..a4a0b7c 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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( diff --git a/src/lib/server/fleet/beta-selection.ts b/src/lib/server/fleet/beta-selection.ts new file mode 100644 index 0000000..ff87a1b --- /dev/null +++ b/src/lib/server/fleet/beta-selection.ts @@ -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>; + +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): boolean { + const options = new Map>(); + 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; +} diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index 592eb96..f0aacfb 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -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; testPlan?: BetaPlan; waitForHealth: boolean; waitForMatch: boolean; @@ -313,26 +317,18 @@ export interface OpenBetaResult { } export async function openBeta(input: OpenBetaInput): Promise { - 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(); - // 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(); - 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 { '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 { 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(); + 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 { // 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 { 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 { })(); 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[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[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 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[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(); 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(); + 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(); 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 diff --git a/src/lib/server/fleet/rollback.ts b/src/lib/server/fleet/rollback.ts index cf7cb0f..b53bf46 100644 --- a/src/lib/server/fleet/rollback.ts +++ b/src/lib/server/fleet/rollback.ts @@ -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 { 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(); + // 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(); const kindByScriptId = new Map(); if (scripts.length > 0) { const placeholders = scripts.map(() => '?').join(','); @@ -125,11 +126,25 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise { - 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(); - 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 { `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` diff --git a/src/lib/server/fleet/snapshot.ts b/src/lib/server/fleet/snapshot.ts index c21ec64..b3145a1 100644 --- a/src/lib/server/fleet/snapshot.ts +++ b/src/lib/server/fleet/snapshot.ts @@ -17,7 +17,8 @@ export function fleetSnapshot(serverName?: string) { .all(...bindings) as NativeInstance[]).map(instance => [instance.server, instance])); const holds = new Map(); 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 }[]) { diff --git a/src/lib/server/pipeline/beta-plan-runner.ts b/src/lib/server/pipeline/beta-plan-runner.ts index 33bbcf4..311537c 100644 --- a/src/lib/server/pipeline/beta-plan-runner.ts +++ b/src/lib/server/pipeline/beta-plan-runner.ts @@ -158,8 +158,16 @@ async function advancePlan(stored: StoredPlan): Promise { } 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); } diff --git a/src/lib/ui/components/BetaPlanBuilder.svelte b/src/lib/ui/components/BetaPlanBuilder.svelte index e146698..86b0597 100644 --- a/src/lib/ui/components/BetaPlanBuilder.svelte +++ b/src/lib/ui/components/BetaPlanBuilder.svelte @@ -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(), serverCount }: { plan: BetaPlan; serverCount?: number } = $props(); + let { plan = $bindable(), 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 @@ Coverage & waves{coverageSummary(plan)} · {waveSummary}Configure
+ {#if allServersRequired} +

Every requested instance must pass. Change the instance counts in the Servers step.

+ {:else} {#if plan.scope === 'quorum'}{/if} + {/if}
-

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.

+

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.

diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts index cab0760..75414a0 100644 --- a/src/routes/api/betas/+server.ts +++ b/src/routes/api/betas/+server.ts @@ -10,6 +10,7 @@ import type { StoredPlan } from '$lib/server/fleet/beta-tests.js'; import { kick } from '$lib/server/pipeline/worker.js'; import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js'; import { betaListPage } from '$lib/server/fleet/beta-pages.js'; +import { parseBetaInstanceCounts } from '$lib/server/fleet/beta-selection.js'; export async function GET({ url }: { url: URL }) { const state = url.searchParams.get('state'); @@ -47,6 +48,7 @@ export async function POST({ request }: { request: Request }) { artifacts?: Array<{ id?: number; versionId?: number }>; scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>; servers?: string[]; + instancesPerGame?: Record; waitForHealth?: boolean; waitForMatch?: boolean; notes?: string; @@ -57,29 +59,35 @@ export async function POST({ request }: { request: Request }) { // Normalize into the openBeta shape. Both fields feed the same list. const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = []; if (Array.isArray(body.artifacts)) { - for (const a of body.artifacts) rawEntries.push({ scriptId: a.id, scriptVersionId: a.versionId }); + for (const a of body.artifacts) rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId }); } if (Array.isArray(body.scripts)) { - for (const s of body.scripts) rawEntries.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId }); + for (const s of body.scripts) rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId }); } - if (rawEntries.length === 0 || !Array.isArray(body.servers)) { + if (rawEntries.length === 0) { return json( - { ok: false, error: 'artifacts[]/scripts[] (non-empty) and servers[] are required' }, + { ok: false, error: 'artifacts[]/scripts[] must contain at least one file' }, { status: 400 } ); } - if (body.servers.length === 0) { - return json({ ok: false, error: 'at least one server is required' }, { status: 400 }); + if ((body.servers !== undefined) === (body.instancesPerGame !== undefined)) { + return json({ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' }, { status: 400 }); + } + if (body.instancesPerGame !== undefined) { + try { body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame); } + catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); } + } else if (!Array.isArray(body.servers) || !body.servers.length || !body.servers.every(name => typeof name === 'string' && name.trim())) { + return json({ ok: false, error: 'servers must contain at least one server name' }, { status: 400 }); } const normalized: Array<{ scriptId: number; scriptVersionId: number }> = []; for (const s of rawEntries) { - if (typeof s.scriptId !== 'number' || typeof s.scriptVersionId !== 'number') { + if (!Number.isSafeInteger(s.scriptId) || !Number.isSafeInteger(s.scriptVersionId) || s.scriptId! < 1 || s.scriptVersionId! < 1) { return json( { ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' }, { status: 400 } ); } - normalized.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId }); + normalized.push({ scriptId: s.scriptId!, scriptVersionId: s.scriptVersionId! }); } try { @@ -90,6 +98,7 @@ export async function POST({ request }: { request: Request }) { testPlan, scripts: normalized, servers: body.servers, + instancesPerGame: body.instancesPerGame, waitForHealth: body.waitForHealth ?? true, waitForMatch: body.waitForMatch ?? false, notes: body.notes diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte index aa29d74..34f7c82 100644 --- a/src/routes/betas/new/+page.svelte +++ b/src/routes/betas/new/+page.svelte @@ -7,7 +7,7 @@ import { onMount, tick } from 'svelte'; import { beforeNavigate, goto } from '$app/navigation'; import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js'; - import { betaDraftKey, readBetaDraft, compatibleBetaServers, type BetaDraft } from '$lib/beta-draft.js'; + import { betaDraftKey, readBetaDraft, compatibleBetaServers, betaScopeMatches, betaCandidateServers, betaApplicableGames, betaInstanceCounts, type BetaDraft } from '$lib/beta-draft.js'; import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js'; import { artifactVersionLabel } from '$lib/artifact-version.js'; import { parseBetaSelection, mapLabel } from '$lib/script-library.js'; @@ -28,6 +28,8 @@ let scripts = $state([]), detailById = $state>({}), nextKey=0; let picks = $state([{key:nextKey++,scriptId:null,versionId:null}]); let pickedServers = $state>(new Set()), testPlan = $state(defaultPlan()); + let selectionMode = $state('automatic'); + let instancesPerGame = $state({}); let step = $state(0), stepHeading = $state(), planBuilder = $state(); let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state(''); let availableDraft = $state(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false; @@ -41,10 +43,29 @@ const fleetServers = $derived($sharedFleet); const eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null)); const filesReady = $derived(picks.length>0 && picks.every(p=>p.scriptId!==null && p.versionId!==null && versionsFor(p).some(v=>v.id===p.versionId))); - const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan())); - const candidateServers = $derived.by(()=>{ - const scopes=picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file); - return scopes.length ? fleetServers.filter(server=>scopes.every(file=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value)).map(server=>server.name) : []; + const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || selectionMode==='manual' || Object.values(instancesPerGame).some(count=>count!==1) || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan())); + const selectedFiles = $derived(picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file)); + const candidates = $derived(betaCandidateServers(selectedFiles,fleetServers)); + const candidateServers = $derived(candidates.map(server=>server.name)); + const applicableGames = $derived(betaApplicableGames(selectedFiles,fleetServers)); + const requestedCounts = $derived(betaInstanceCounts(applicableGames,instancesPerGame)); + const automaticCounts = $derived(Object.fromEntries(Object.entries(requestedCounts).filter((entry):entry is [string,number]=>typeof entry[1]==='number' && entry[1]>0))); + const serverCount = $derived(selectionMode==='manual' ? pickedServers.size : Object.values(automaticCounts).reduce((total,count)=>total+count,0)); + const serverSelectionIssue = $derived.by(()=>{ + if(selectionMode==='automatic') { + if(!applicableGames.length)return 'The selected files have no registered servers in a supported game.'; + if(Object.values(requestedCounts).some(count=>count===null || !Number.isInteger(count) || count<0 || count>1000))return 'Enter a whole number from 0 to 1,000 for each game.'; + if(!serverCount)return 'Request at least one test instance.'; + const missing=selectedFiles.find(file=>!candidates.some(server=>betaScopeMatches(file,server) && !!server.game && (automaticCounts[server.game]??0)>0)); + if(missing)return `${missing.name} needs at least one instance in a matching game and scope.`; + const short=applicableGames.find(game=>(automaticCounts[game]??0)>candidates.filter(server=>server.game===game).length); + if(short)return `${gameLabel(short)} has fewer compatible registered servers than requested. Lower its instance count or add servers.`; + return ''; + } + if(!pickedServers.size)return 'Select at least one compatible test server.'; + if([...pickedServers].some(name=>!candidateServers.includes(name)))return 'Recheck the selected files and servers before starting.'; + const missing=selectedFiles.find(file=>!candidates.some(server=>pickedServers.has(server.name)&&betaScopeMatches(file,server))); + return missing ? `Select at least one server matching ${missing.name}. Every file needs a test participant.` : ''; }); const visibleServers = $derived(fleetServers.filter(server=>candidateServers.includes(server.name)).filter(server=>{ const identity=fleetIdentity(server), fresh=freshServer(server,now); @@ -52,10 +73,11 @@ && `${server.name} ${identity.title} ${identity.map} ${identity.game}`.toLowerCase().includes(search.toLowerCase()); })); const groups = $derived([...new Set(visibleServers.map(server=>fleetIdentity(server).group))].map(group=>({key:group,servers:visibleServers.filter(server=>fleetIdentity(server).group===group)}))); - const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? !pickedServers.size ? 'Select at least one compatible test server.' : '' : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady || !pickedServers.size || [...pickedServers].some(name=>!candidateServers.includes(name)) ? 'Recheck the selected files and servers before starting.' : ''); + const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? serverSelectionIssue : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady ? 'Recheck the selected files before starting.' : serverSelectionIssue); function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); } function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); } + function gameLabel(game:string) { return game==='t5'?'Black Ops I (T5)':game==='t6'?'Black Ops II (T6)':game; } function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; } function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; } function reconcileSelection() { @@ -63,7 +85,7 @@ // authoritative enough to remove selections from the saved draft. if($fleetError || picks.every(p=>p.scriptId===null) || picks.some(p=>p.scriptId!==null && !detailById[p.scriptId]))return; const result=compatibleBetaServers(pickedServers,candidateServers); - if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match every file: ${result.removed.join(', ')}.`; } + if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match any file: ${result.removed.join(', ')}.`; } } async function loadSelections(selections:BetaDraft['picks']) { const warnings:string[]=[]; @@ -110,6 +132,7 @@ if(source.test?.plan)testPlan=parseBetaPlan(source.test.plan); betaLabel=source.beta.notes ?? ''; pickedServers=new Set(source.servers.map((s:{server:string})=>s.server)); + selectionMode='manual'; await loadSelections(source.scripts.map((file:{id:number;version:{id:number}|null})=>({scriptId:file.id,versionId:file.version?.id??null}))); reconcileSelection(); notice='Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.'; draftReady=true; } else if(selections.length) { await loadSelections(selections); draftReady=true; } @@ -120,22 +143,23 @@ async function resumeDraft() { if(!availableDraft)return; const draft=availableDraft; loading=true; flash=''; notice=''; - try { testPlan=draft.testPlan; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; } + try { testPlan=draft.testPlan; selectionMode=draft.selectionMode; instancesPerGame=draft.instancesPerGame; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; } finally { availableDraft=null; loading=false; draftReady=true; } } function discardDraft() { const useLinked=!!availableDraft && hasLinkedSetup; - availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.'; + availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); selectionMode='automatic'; instancesPerGame={}; testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.'; try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;} draftReady=true; if(useLinked)void initialize(true); } $effect(()=>{ if(!draftReady || loading || openingBeta)return; - const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],testPlan:JSON.parse(JSON.stringify(testPlan))}; + const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],selectionMode,instancesPerGame:{...instancesPerGame,...requestedCounts},testPlan:JSON.parse(JSON.stringify(testPlan))}; if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} } }); $effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } }); + $effect(()=>{ if(selectionMode==='automatic' && testPlan.scope!=='all')testPlan={...testPlan,scope:'all'}; }); beforeNavigate(navigation=>{ if(!dirty || !storageUnavailable || allowLeave || openingBeta)return; if(navigation.willUnload){navigation.cancel();return;} @@ -165,10 +189,10 @@ } async function openBeta(event:Event) { event.preventDefault();if(step!==3||forwardReason||openingBeta)return; - if(betaPlanIssues(testPlan,pickedServers.size).length){await move(2);await planBuilder?.validate();return;} + if(betaPlanIssues(testPlan,serverCount).length){await move(2);await planBuilder?.validate();return;} openingBeta=true;flash=''; try { - const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json(); + const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),...(selectionMode==='automatic'?{instancesPerGame:automaticCounts}:{servers:[...pickedServers]}),testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json(); if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.'); draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true; await goto(contextHref(`/betas/${body.result.betaId}`, safeReturnHref($page.url.searchParams.get('returnTo'), '/betas'))); @@ -180,12 +204,12 @@
- + {#if flash}{/if} {#if $fleetError}{/if} {#if notice}

{notice}

{/if} {#if loading} - {:else if availableDraft}

Resume unfinished beta?

Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.

+ {:else if availableDraft}

Resume unfinished beta?

Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.selectionMode==='automatic'?'Automatic server selection':`${availableDraft.servers.length} selected servers`}. Versions and eligibility will be rechecked.

{:else if !eligibleFiles.length && !flash} {:else if scripts.length}
    {#each steps as label,index}
  1. {index+1}{label}
  2. {/each}
@@ -201,30 +225,56 @@ {#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}

{scopeText(file)} · {scopeCount(file)} eligible servers · Inspect file and versions{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}
No unpromoted versions. Upload a new version from the file page.{/if}

{/if}{/if} {/each}
-

Test servers must match every file's scope. Changing a file retains compatible server selections.

+

Combine files for different games in one beta. Each file installs only on servers matching its scope, and every file needs at least one test participant.

{:else if step===1} -

{pickedServers.size} selected · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.

+ {#if selectionMode==='automatic'} +
+

Instances that must pass per game

+

At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.

+
{#each applicableGames as game} + + {/each}
+

Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies. Set a game to 0 only if every file still has a matching participant.

+
+ {:else}

{pickedServers.size} selected · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.

{/if}
Selected file scopes
    {#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}
  • {file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers
  • {/if}{/if}{/each}
- {#if !candidateServers.length}
The selected file scopes have no servers in common. Review the scopes above, then go Back to replace or remove a conflicting file.
- {:else} + {#if !candidateServers.length}
No registered servers match these files. Review their scopes above or register a matching server.
{/if} +
Advanced options · choose specific servers +
+
Server selection + + +
+ {#if selectionMode==='manual' && candidateServers.length}
{visibleServers.length} visible
{#each groups as group}
{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}
{#each group.servers as server}{/each}
{/each} {#if !visibleServers.length}

No servers match these filters.

{/if} {/if} - {:else if step===2} - {:else}

Files and exact versions

    {#each picks as pick}
  • {scripts.find(file=>file.id===pick.scriptId)?.name} · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}
  • {/each}

Test servers ({pickedServers.size})

    {#each [...pickedServers] as name}
  • {/each}

Pass criteria

{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.

    {#each testPlan.requirements as requirement}
  • {requirementSummary(requirement)}
  • {/each}

Waves and failure handling

{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}

Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.

{/if} -

{forwardReason}

{#if step<3}{:else}{/if}
+
+ {:else if step===2} + {:else}
+

Files and exact versions

+
    {#each picks as pick}
  • {scripts.find(file=>file.id===pick.scriptId)?.name} · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}{#if pick.scriptId!==null && detailById[pick.scriptId]}
    {scopeText(detailById[pick.scriptId].script)}{/if}
  • {/each}
+

Test instances ({serverCount})

+ {#if selectionMode==='automatic'} +
    {#each Object.entries(automaticCounts) as [game,count]}
  • {gameLabel(game)}: {count} instance{count===1?'':'s'} must pass
  • {/each}
+

The least-used available servers are chosen when you start the beta. Selection uses current player counts and ensures every file has a matching participant. If there are not enough available servers, the beta will not start.

+ {:else}
    {#each [...pickedServers] as name}
  • {/each}
{/if} +

Pass criteria

{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.

    {#each testPlan.requirements as requirement}
  • {requirementSummary(requirement)}
  • {/each}

Waves and failure handling

{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}

Starting installs each selected version only on matching test servers and restarts them when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.

+
{/if} +

{forwardReason}

{#if step<3}{:else}{/if}
{/if}