Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
1153 lines
50 KiB
TypeScript
1153 lines
50 KiB
TypeScript
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
|
import { storedPlan } from './beta-tests.js';
|
|
import type { BetaPromotionPreview } from '../../beta-promotion.js';
|
|
|
|
const operations = new Set<number>();
|
|
export const betaOperationInFlight = (id: number) => operations.has(id);
|
|
async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
|
|
if (operations.has(id)) throw new BetaError('A beta operation is already running', 'invalid-state');
|
|
operations.add(id);
|
|
try { return await action(); } finally { operations.delete(id); }
|
|
}
|
|
|
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { dirname, join, posix } from 'node:path';
|
|
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
|
import { db } from '../db/index.js';
|
|
import { sha256Buffer } from './files.js';
|
|
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
|
import {
|
|
getBaselineForScope,
|
|
getScript,
|
|
getVersion,
|
|
listPromotedOverlaysForScope,
|
|
listVersions,
|
|
serversMatchingScope,
|
|
setScope,
|
|
type ScopeType,
|
|
type ScriptRow,
|
|
type ScriptVersionRow
|
|
} from './scripts.js';
|
|
import { composeServerCfg } from './config-compose.js';
|
|
import { recordScript, removeScript, getServerScript } from './inventory.js';
|
|
import { emit } from '../pipeline/bus.js';
|
|
import type { BetaStage } from '../pipeline/bus.js';
|
|
|
|
// Whether an artifact writes a dedicated on-disk file (script/plugin) or
|
|
// contributes to a composed shared file (config/baseline).
|
|
function isCollapsingKind(kind: ArtifactKind): boolean {
|
|
return kind === 'config' || kind === 'baseline';
|
|
}
|
|
|
|
// The path we should write to for the composed cfg on this server.
|
|
function composedCfgPath(server: string): string {
|
|
return join(destOfKind(server, 'config'), configFileOf(server));
|
|
}
|
|
|
|
// Compose the effective dedicated_sp.cfg for a given beta+server, taking
|
|
// into account:
|
|
// • baseline: from the beta if present, else the currently-promoted
|
|
// baseline for the server's scope
|
|
// • promoted overlays for the server's scope (excluding scripts being
|
|
// replaced by this beta)
|
|
// • this beta's overlays whose scope matches the server
|
|
//
|
|
// Returns null if no baseline is available (nothing to compose against).
|
|
interface ComposeInputs {
|
|
inBetaBaseline?: ScriptVersionRow;
|
|
inBetaOverlays: Array<{ script: ScriptRow; version: ScriptVersionRow }>;
|
|
}
|
|
|
|
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;
|
|
const field = scope.scope_type === 'game' ? server.game : server.map;
|
|
return field === scope.scope_value;
|
|
}
|
|
|
|
function composeCfgForServer(
|
|
serverName: string,
|
|
serverGame: string | null,
|
|
serverMap: string | null,
|
|
inputs: ComposeInputs
|
|
): { text: string; baselineScriptId: number | null } | null {
|
|
// Baseline: prefer the beta's baseline if its scope matches this server.
|
|
let baselineText: string | null = null;
|
|
let baselineScriptId: number | null = null;
|
|
if (inputs.inBetaBaseline) {
|
|
const s = getScript(inputs.inBetaBaseline.script_id);
|
|
if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) {
|
|
baselineText = inputs.inBetaBaseline.content.toString('utf8');
|
|
baselineScriptId = s.id;
|
|
}
|
|
}
|
|
// Fall back to the currently-promoted baseline for the server's game.
|
|
if (baselineText === null && serverGame) {
|
|
const promoted = getBaselineForScope('game', serverGame);
|
|
if (promoted?.current_version_id) {
|
|
const ver = getVersion(promoted.current_version_id);
|
|
if (ver) {
|
|
baselineText = ver.content.toString('utf8');
|
|
baselineScriptId = promoted.id;
|
|
}
|
|
}
|
|
}
|
|
if (baselineText === null) return null;
|
|
|
|
// In-beta script ids that are replacing promoted overlays — those get
|
|
// skipped from the promoted overlay list to avoid double-applying an
|
|
// older version of the same script.
|
|
const inBetaScriptIds = new Set(inputs.inBetaOverlays.map((o) => o.script.id));
|
|
|
|
// Collect promoted overlays scoped to this server's game AND map.
|
|
const promotedOverlays: Array<{ id: number; text: string }> = [];
|
|
const promotedIdsSeen = new Set<number>();
|
|
const pushIfScoped = (rows: ScriptRow[]) => {
|
|
for (const s of rows) {
|
|
if (promotedIdsSeen.has(s.id)) continue;
|
|
if (inBetaScriptIds.has(s.id)) continue;
|
|
if (!scopeAppliesToServer(s, { game: serverGame, map: serverMap })) continue;
|
|
if (!s.current_version_id) continue;
|
|
const ver = getVersion(s.current_version_id);
|
|
if (!ver) continue;
|
|
promotedOverlays.push({ id: s.id, text: ver.content.toString('utf8') });
|
|
promotedIdsSeen.add(s.id);
|
|
}
|
|
};
|
|
if (serverGame) pushIfScoped(listPromotedOverlaysForScope('game', serverGame));
|
|
if (serverMap) pushIfScoped(listPromotedOverlaysForScope('map', serverMap));
|
|
|
|
// In-beta overlays scoped to this server.
|
|
const inBetaScopedOverlays = inputs.inBetaOverlays
|
|
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
|
|
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') }));
|
|
|
|
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
|
|
return { text: composed, baselineScriptId };
|
|
}
|
|
|
|
export interface BetaRow {
|
|
id: number;
|
|
// Deprecated: the columns are still populated (NOT NULL on legacy schemas)
|
|
// from the first bundled script's identity so existing data continues to
|
|
// roundtrip, but new code reads beta_scripts for the authoritative list.
|
|
script_id: number;
|
|
script_version_id: number;
|
|
trigger_kind: string;
|
|
wait_for_health: number;
|
|
wait_for_match: number;
|
|
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
|
opened_at: string;
|
|
closed_at: string | null;
|
|
notes: string | null;
|
|
}
|
|
|
|
export interface BetaServerRow {
|
|
beta_id: number;
|
|
server: string;
|
|
had_original: number; // deprecated; per-script data lives in beta_server_scripts
|
|
prior_version_id: number | null; // deprecated
|
|
stage: BetaStage;
|
|
error: string | null;
|
|
deployed_at: string | null;
|
|
restarted_at: string | null;
|
|
healthy_at: string | null;
|
|
match_seen_at: string | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface BetaScriptRow {
|
|
beta_id: number;
|
|
script_id: number;
|
|
script_version_id: number;
|
|
kind: ArtifactKind;
|
|
}
|
|
|
|
export interface BetaServerScriptRow {
|
|
beta_id: number;
|
|
server: string;
|
|
script_id: number;
|
|
had_original: number;
|
|
prior_version_id: number | null;
|
|
}
|
|
|
|
export class BetaError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly kind:
|
|
| 'no-such-script'
|
|
| 'no-such-version'
|
|
| 'version-script-mismatch'
|
|
| 'no-scripts'
|
|
| 'no-servers'
|
|
| 'unknown-server'
|
|
| 'out-of-scope'
|
|
| 'conflict'
|
|
| 'blocked-backup'
|
|
| 'no-such-beta'
|
|
| 'not-ready-promote'
|
|
| 'not-running'
|
|
| 'dest-fail'
|
|
| 'invalid-state'
|
|
| 'duplicate-script'
|
|
| 'preview-changed'
|
|
| 'duplicate-target',
|
|
public readonly details?: unknown
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
function nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
// ---------- Reads ----------
|
|
|
|
export function getBeta(id: number): BetaRow | undefined {
|
|
return db().prepare('SELECT * FROM betas WHERE id = ?').get(id) as BetaRow | undefined;
|
|
}
|
|
|
|
export function listBetas(state?: BetaRow['state']): BetaRow[] {
|
|
if (state) {
|
|
return db().prepare('SELECT * FROM betas WHERE state = ? ORDER BY opened_at DESC').all(state) as BetaRow[];
|
|
}
|
|
return db().prepare('SELECT * FROM betas ORDER BY opened_at DESC').all() as BetaRow[];
|
|
}
|
|
|
|
export function listBetaServers(betaId: number): BetaServerRow[] {
|
|
return db()
|
|
.prepare('SELECT * FROM beta_servers WHERE beta_id = ? ORDER BY server')
|
|
.all(betaId) as BetaServerRow[];
|
|
}
|
|
|
|
export function listBetaScripts(betaId: number): BetaScriptRow[] {
|
|
return db()
|
|
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
|
|
.all(betaId) as BetaScriptRow[];
|
|
}
|
|
|
|
export function getBetaServerScript(betaId: number, server: string, scriptId: number): BetaServerScriptRow | undefined {
|
|
return db()
|
|
.prepare('SELECT * FROM beta_server_scripts WHERE beta_id = ? AND server = ? AND script_id = ?')
|
|
.get(betaId, server, scriptId) as BetaServerScriptRow | undefined;
|
|
}
|
|
|
|
// A script is "in a running beta" if beta_scripts joins to a running beta row.
|
|
// Existing callers only care about the running-beta id, so return that shape.
|
|
export function findRunningBetaForScript(scriptId: number): BetaRow | undefined {
|
|
return db()
|
|
.prepare(
|
|
`SELECT b.* FROM betas b
|
|
JOIN beta_scripts bs ON bs.beta_id = b.id
|
|
WHERE bs.script_id = ? AND b.state IN ('running','ready-promote')
|
|
LIMIT 1`
|
|
)
|
|
.get(scriptId) as BetaRow | undefined;
|
|
}
|
|
|
|
// ---------- openBeta ----------
|
|
|
|
export interface OpenBetaScript {
|
|
scriptId: number;
|
|
scriptVersionId: number;
|
|
}
|
|
|
|
export interface OpenBetaInput {
|
|
scripts: OpenBetaScript[];
|
|
servers: string[];
|
|
testPlan?: BetaPlan;
|
|
waitForHealth: boolean;
|
|
waitForMatch: boolean;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface OpenBetaResult {
|
|
betaId: number;
|
|
servers: string[];
|
|
scripts: OpenBetaScript[];
|
|
}
|
|
|
|
export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|
const plan = input.testPlan ? parseBetaPlan(input.testPlan) : null;
|
|
if (new Set(input.servers).size !== input.servers.length) throw new BetaError("Select each server only once", "invalid-state");
|
|
if (plan?.scope === "quorum" && plan.quorum > input.servers.length) throw new BetaError("Quorum exceeds selected servers", "invalid-state");
|
|
if (!input.scripts || input.scripts.length === 0) {
|
|
throw new BetaError('at least one script is required', 'no-scripts');
|
|
}
|
|
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');
|
|
}
|
|
seenScriptIds.add(s.scriptId);
|
|
const script = getScript(s.scriptId);
|
|
if (!script) throw new BetaError(`no such script (id=${s.scriptId})`, 'no-such-script');
|
|
const version = getVersion(s.scriptVersionId);
|
|
if (!version) throw new BetaError(`no such version (id=${s.scriptVersionId})`, 'no-such-version');
|
|
if (version.script_id !== script.id) {
|
|
throw new BetaError(
|
|
`version ${s.scriptVersionId} does not belong to script ${s.scriptId}`,
|
|
'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 });
|
|
}
|
|
|
|
// Look up server rows once.
|
|
const serverRows: Array<{ name: string; game: string | null; map: string | null }> = [];
|
|
for (const name of input.servers) {
|
|
const row = getServer(name);
|
|
if (!row) throw new BetaError(`unknown server: ${name}`, 'unknown-server');
|
|
serverRows.push({ name, game: row.game, map: row.map });
|
|
}
|
|
|
|
if (plan && ['map', 'game', 'map-game'].includes(plan.scope)) {
|
|
const missing = serverRows.some(s => (plan.scope !== 'map' && !s.game) || (plan.scope !== 'game' && !s.map));
|
|
if (missing) throw new BetaError('Coverage requires game/map metadata on every selected server', 'invalid-state');
|
|
}
|
|
|
|
// Per-script scope validation. Aggregate all violations into one error.
|
|
const outOfScope: Array<{ scriptId: number; scope: string; servers: string[] }> = [];
|
|
for (const { script } of resolved) {
|
|
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)
|
|
});
|
|
}
|
|
}
|
|
if (outOfScope.length > 0) {
|
|
throw new BetaError('one or more servers are out of scope', 'out-of-scope', outOfScope);
|
|
}
|
|
|
|
// Per-(server, script) conflict check. heldByBeta covers "another running
|
|
// 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) {
|
|
const held = heldByBeta(name);
|
|
for (const { script } of resolved) {
|
|
const owner = held.get(script.target_rel_path);
|
|
if (owner) conflicts.push({ server: name, scriptId: script.id, heldByBetaId: owner });
|
|
}
|
|
}
|
|
if (conflicts.length > 0) throw new BetaError('target already held by another beta', 'conflict', conflicts);
|
|
|
|
// Ensure destinations exist. The `ensureDest` helper covers only the
|
|
// script destination today; config/plugin destinations are created
|
|
// on-demand via mkdir during the write loop.
|
|
for (const name of input.servers) {
|
|
const ok = await ensureDest(name, true);
|
|
if (!ok.ok) throw new BetaError(`dest not usable for ${name}: ${ok.reason}`, 'dest-fail');
|
|
}
|
|
|
|
// DB writes in one transaction.
|
|
const primary = resolved[0]; // populates deprecated betas.* NOT NULL cols
|
|
const insertBeta = db().prepare(
|
|
`INSERT INTO betas (script_id, script_version_id, trigger_kind, wait_for_health, wait_for_match, state, notes)
|
|
VALUES (?, ?, 'bundle', ?, ?, 'running', ?)`
|
|
);
|
|
const insertBetaScript = db().prepare(
|
|
`INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)`
|
|
);
|
|
const insertBetaServer = db().prepare(
|
|
`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage, updated_at)
|
|
VALUES (?, ?, 0, NULL, 'deploying', ?)`
|
|
);
|
|
const insertBetaServerScript = db().prepare(
|
|
`INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original, prior_version_id)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
);
|
|
|
|
let betaId!: number;
|
|
db().transaction(() => {
|
|
const res = insertBeta.run(
|
|
primary.script.id,
|
|
primary.version.id,
|
|
input.waitForHealth ? 1 : 0,
|
|
input.waitForMatch ? 1 : 0,
|
|
input.notes ?? null
|
|
);
|
|
betaId = Number(res.lastInsertRowid);
|
|
if (plan) db().prepare('INSERT INTO beta_test_plans (beta_id, plan) VALUES (?, ?)').run(betaId, JSON.stringify(plan));
|
|
for (const r of resolved) {
|
|
insertBetaScript.run(betaId, r.script.id, r.version.id, r.script.kind);
|
|
}
|
|
for (const name of input.servers) {
|
|
insertBetaServer.run(betaId, name, nowIso());
|
|
if (plan) {
|
|
const selected = serverRows.find(s => s.name === name)!;
|
|
db().prepare('INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (?, ?, ?, ?)').run(betaId, name, selected.game, selected.map);
|
|
}
|
|
for (const r of resolved) {
|
|
const prior = getServerScript(name, r.script.target_rel_path);
|
|
insertBetaServerScript.run(
|
|
betaId,
|
|
name,
|
|
r.script.id,
|
|
prior ? 1 : 0,
|
|
prior?.script_version_id ?? null
|
|
);
|
|
}
|
|
}
|
|
})();
|
|
|
|
if (plan?.waveSize) db().prepare("UPDATE beta_servers SET stage = 'queued' WHERE beta_id = ?").run(betaId);
|
|
await deployBetaServers(betaId, plan?.waveSize ? input.servers.slice(0, plan.waveSize) : input.servers);
|
|
|
|
emit({
|
|
type: 'beta-opened',
|
|
betaId,
|
|
scriptIds: resolved.map((r) => r.script.id),
|
|
servers: input.servers
|
|
});
|
|
|
|
return {
|
|
betaId,
|
|
servers: input.servers,
|
|
scripts: resolved.map((r) => ({ scriptId: r.script.id, scriptVersionId: r.version.id }))
|
|
};
|
|
}
|
|
|
|
/** Apply a reserved wave only when its predecessors have passed. */
|
|
export async function deployBetaServers(betaId: number, names: string[]): Promise<void> {
|
|
return exclusive(betaId, () => deployBetaServersInternal(betaId, names));
|
|
}
|
|
async function deployBetaServersInternal(betaId: number, names: string[]): Promise<void> {
|
|
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
|
|
const serverRows = names.map(name => getServer(name)!);
|
|
// Split resolved artifacts into non-collapsing (each writes its own file)
|
|
// and collapsing (config/baseline overlay onto one shared dedicated_sp.cfg).
|
|
const nonCollapsing = resolved.filter((r) => !isCollapsingKind(r.script.kind));
|
|
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.
|
|
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), r.version.content);
|
|
}
|
|
|
|
const insertSnapshot = db().prepare(
|
|
`INSERT INTO beta_file_snapshots (beta_id, server, rel_path, prior_content, prior_sha256, prior_size)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
);
|
|
|
|
// On-disk deploy per server. If any write fails on a server, mark the
|
|
// whole server 'failed' — we can't restart a partially deployed server
|
|
// safely. Remaining artifacts on that server are skipped.
|
|
const updateStage = db().prepare(
|
|
`UPDATE beta_servers
|
|
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
|
|
WHERE beta_id = ? AND server = ?`
|
|
);
|
|
|
|
for (const name of names) {
|
|
db().prepare("UPDATE beta_servers SET stage = 'deploying', deployed_at = ? WHERE beta_id = ? AND server = ?")
|
|
.run(nowIso(), betaId, name);
|
|
const serverRow = serverRows.find((r) => r.name === name)!;
|
|
let firstErr: string | null = null;
|
|
// 1. Non-collapsing artifacts: script/plugin — write directly to the
|
|
// target filename. Pre-beta bytes go into beta_file_snapshots so
|
|
// rollback can restore them.
|
|
for (const { script, version } of nonCollapsing) {
|
|
if (firstErr) break;
|
|
const dest = destOfKind(name, script.kind);
|
|
const targetPath = join(dest, script.target_rel_path);
|
|
try {
|
|
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
|
if (existsSync(targetPath)) {
|
|
const priorBytes = await readFile(targetPath);
|
|
insertSnapshot.run(
|
|
betaId,
|
|
name,
|
|
script.target_rel_path,
|
|
priorBytes,
|
|
sha256Buffer(priorBytes),
|
|
priorBytes.byteLength
|
|
);
|
|
db().prepare('UPDATE beta_server_scripts SET had_original = 1 WHERE beta_id = ? AND server = ? AND script_id = ?').run(betaId, name, script.id);
|
|
}
|
|
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, script.target_rel_path);
|
|
await writeFile(targetPath, version.content);
|
|
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `beta:${betaId}`);
|
|
} catch (err) {
|
|
firstErr = `${script.name}: ${(err as Error).message}`;
|
|
}
|
|
}
|
|
// 2. Collapsing layer: compose the effective dedicated_sp.cfg from
|
|
// baseline + all applicable overlays, write in place. Pre-beta cfg
|
|
// bytes go into beta_file_snapshots keyed on the target rel_path.
|
|
const hasCollapsing = collapsingBaseline || collapsingOverlays.length > 0;
|
|
if (!firstErr && hasCollapsing) {
|
|
try {
|
|
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 dest = destOfKind(name, 'config');
|
|
await mkdir(dest, { recursive: true });
|
|
const targetRel = configFileOf(name);
|
|
const targetPath = join(dest, targetRel);
|
|
if (existsSync(targetPath)) {
|
|
const priorBytes = await readFile(targetPath);
|
|
insertSnapshot.run(
|
|
betaId,
|
|
name,
|
|
targetRel,
|
|
priorBytes,
|
|
sha256Buffer(priorBytes),
|
|
priorBytes.byteLength
|
|
);
|
|
}
|
|
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
|
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, targetRel);
|
|
await writeFile(targetPath, composedBuf);
|
|
recordScript(
|
|
name,
|
|
targetRel,
|
|
null,
|
|
sha256Buffer(composedBuf),
|
|
composedBuf.byteLength,
|
|
`beta:${betaId}`
|
|
);
|
|
// Archive the composed preview for operator visibility.
|
|
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
|
|
} catch (err) {
|
|
firstErr = `configs: ${(err as Error).message}`;
|
|
}
|
|
}
|
|
const t = nowIso();
|
|
if (firstErr) {
|
|
updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name);
|
|
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${firstErr}` });
|
|
} else {
|
|
updateStage.run('awaiting-empty', null, t, t, betaId, name);
|
|
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'awaiting-empty', error: null });
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// ---------- rollback ----------
|
|
|
|
export interface RollbackResult {
|
|
betaId: number;
|
|
restored: string[];
|
|
dropped: string[];
|
|
warnings: string[];
|
|
}
|
|
|
|
export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
|
return exclusive(betaId, () => rollbackBetaInternal(betaId));
|
|
}
|
|
async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
|
|
const beta = getBeta(betaId);
|
|
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
|
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
|
|
throw new BetaError(`beta is ${beta.state}, cannot rollback`, 'invalid-state');
|
|
}
|
|
const bundleRows = listBetaScripts(betaId);
|
|
if (bundleRows.length === 0) {
|
|
throw new BetaError('beta has no scripts', 'no-scripts');
|
|
}
|
|
const bundle = bundleRows.map((br) => ({
|
|
script: getScript(br.script_id)!,
|
|
versionId: br.script_version_id
|
|
}));
|
|
|
|
const servers = listBetaServers(betaId);
|
|
const restored = new Set<string>();
|
|
const dropped = new Set<string>();
|
|
const warnings: string[] = [];
|
|
|
|
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
|
|
|
|
const getSnapshot = db().prepare(
|
|
`SELECT prior_content, prior_sha256, prior_size FROM beta_file_snapshots
|
|
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
|
);
|
|
|
|
for (const bs of servers) {
|
|
if (!bs.deployed_at && bs.stage === 'queued') continue;
|
|
for (const { script } of bundle) {
|
|
if (isCollapsingKind(script.kind)) continue; // handled once per server below
|
|
if (storedPlan(betaId) && !db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, script.target_rel_path)) continue;
|
|
const dest = destOfKind(bs.server, script.kind);
|
|
const targetPath = join(dest, script.target_rel_path);
|
|
|
|
const bss = getBetaServerScript(betaId, bs.server, script.id);
|
|
if (bss?.had_original) {
|
|
const snap = getSnapshot.get(betaId, bs.server, script.target_rel_path) as
|
|
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
|
| undefined;
|
|
if (snap) {
|
|
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
|
|
await writeFile(targetPath, snap.prior_content);
|
|
if (bss.prior_version_id) {
|
|
recordScript(bs.server, script.target_rel_path, bss.prior_version_id, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
|
} else {
|
|
recordScript(bs.server, script.target_rel_path, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
|
}
|
|
restored.add(bs.server);
|
|
} else {
|
|
warnings.push(`${bs.server}/${script.name}: snapshot missing — cannot restore`);
|
|
}
|
|
} else {
|
|
// No prior file — the beta wrote a fresh file. Drop it.
|
|
if (existsSync(targetPath)) await rm(targetPath, { force: true });
|
|
removeScript(bs.server, script.target_rel_path);
|
|
dropped.add(bs.server);
|
|
}
|
|
}
|
|
|
|
// Collapsing config layer: single restore for the shared cfg file.
|
|
if (hasCollapsing && (!storedPlan(betaId) || db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, configFileOf(bs.server)))) {
|
|
const dest = destOfKind(bs.server, 'config');
|
|
const targetRel = configFileOf(bs.server);
|
|
const targetPath = join(dest, targetRel);
|
|
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
|
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
|
| undefined;
|
|
if (snap) {
|
|
await mkdir(dest, { recursive: true });
|
|
await writeFile(targetPath, snap.prior_content);
|
|
recordScript(bs.server, targetRel, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
|
|
restored.add(bs.server);
|
|
} else if (existsSync(targetPath)) {
|
|
// No snapshot ⇒ the beta wrote the cfg into a server that had
|
|
// no pre-existing file. Remove the beta-written cfg to leave
|
|
// the server clean.
|
|
await rm(targetPath, { force: true });
|
|
removeScript(bs.server, targetRel);
|
|
dropped.add(bs.server);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Snapshots are single-use — safe to drop now that the disk is restored.
|
|
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
|
|
|
// Wipe archive + close out beta.
|
|
const archive = betaArchiveOf(betaId);
|
|
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
|
|
|
db()
|
|
.prepare(`UPDATE betas SET state = 'rolled-back', closed_at = ? WHERE id = ?`)
|
|
.run(nowIso(), betaId);
|
|
|
|
// Per-version test-failed transition. Skip a version if (a) another beta
|
|
// still gates it at ready-promote/promoted, or (b) it's the currently-
|
|
// promoted version of its script (already-live code isn't "failed" by
|
|
// rejecting a redundant beta).
|
|
for (const { script, versionId } of bundle) {
|
|
const stillOk = db()
|
|
.prepare(
|
|
`SELECT 1 FROM beta_scripts bs
|
|
JOIN betas b ON b.id = bs.beta_id
|
|
WHERE bs.script_version_id = ?
|
|
AND b.id <> ?
|
|
AND b.state IN ('ready-promote','promoted')
|
|
LIMIT 1`
|
|
)
|
|
.get(versionId, betaId);
|
|
const isCurrent = script.current_version_id === versionId;
|
|
if (!stillOk && !isCurrent) {
|
|
db().prepare(`UPDATE script_versions SET state = 'test-failed' WHERE id = ?`).run(versionId);
|
|
}
|
|
}
|
|
|
|
emit({ type: 'beta-state', betaId, state: 'rolled-back' });
|
|
|
|
return {
|
|
betaId,
|
|
restored: [...restored].sort(),
|
|
dropped: [...dropped].sort(),
|
|
warnings
|
|
};
|
|
}
|
|
|
|
// ---------- promote ----------
|
|
|
|
export interface PromoteResult {
|
|
betaId: number;
|
|
deploymentId: string;
|
|
landed: string[]; // unique servers that received at least one file
|
|
scriptIds: number[];
|
|
awaitingActivation: number;
|
|
failedServers: string[];
|
|
}
|
|
|
|
/** Snapshot exactly the scope expansion and composed configuration reviewed by the operator. */
|
|
function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configContent: Map<string, Buffer> } {
|
|
const beta = getBeta(betaId);
|
|
if (!beta) throw new BetaError('This beta no longer exists.', 'no-such-beta');
|
|
const bundle = listBetaScripts(betaId).map(row => {
|
|
const script = getScript(row.script_id), version = getVersion(row.script_version_id);
|
|
if (!script || !version) throw new BetaError('A selected file version is no longer available.', 'no-such-version');
|
|
return { script, version };
|
|
});
|
|
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
|
|
const tested = new Set(listBetaServers(betaId).map(row => row.server));
|
|
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
|
|
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
|
|
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
|
|
path: script.target_rel_path, versionId: version.id, version: version.version,
|
|
release_version: version.release_version, sha256: version.sha256,
|
|
scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) }));
|
|
const names = [...new Set(files.flatMap(file => file.servers))].sort();
|
|
const blockers: string[] = [];
|
|
const servers = names.map(name => {
|
|
const server = getServer(name);
|
|
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
|
|
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
|
|
});
|
|
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
|
|
const configContent = new Map<string, Buffer>();
|
|
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
|
|
const row = getServer(server);
|
|
const composed = row && composeCfgForServer(server, row.game, row.map, {
|
|
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
|
|
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
|
|
});
|
|
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
|
|
if (error) blockers.push(error);
|
|
const content = composed ? Buffer.from(composed.text, 'utf8') : null;
|
|
if (content) configContent.set(server, content);
|
|
return { server, path: configFileOf(server), sha256: content ? sha256Buffer(content) : null, error };
|
|
});
|
|
const impact = { betaId, files, servers, configWrites, blockers, restartPolicy: 'additional-when-empty' as const };
|
|
return { preview: { ...impact, token: sha256Buffer(Buffer.from(JSON.stringify(impact))), createdAt: nowIso() }, configContent };
|
|
}
|
|
|
|
export function previewBetaPromotion(betaId: number): BetaPromotionPreview {
|
|
return promotionPlan(betaId).preview;
|
|
}
|
|
|
|
function promoteDeploymentId(betaId: number): string {
|
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
return `dep-${stamp}-promote-${betaId}`;
|
|
}
|
|
|
|
export async function promoteBeta(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
|
if (storedPlan(betaId)) {
|
|
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
|
|
await plannedBetaTick();
|
|
}
|
|
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
|
|
}
|
|
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
|
const test = storedPlan(betaId);
|
|
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
|
const beta = getBeta(betaId);
|
|
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
|
if (beta.state !== 'ready-promote') {
|
|
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
|
|
}
|
|
const reviewed = promotionPlan(betaId);
|
|
const checkReview = () => {
|
|
const current = previewBetaPromotion(betaId);
|
|
if (current.token !== (reviewedToken ?? reviewed.preview.token)) {
|
|
throw new BetaError('Promotion targets or versions changed. Review the updated plan before promoting.', 'preview-changed', current);
|
|
}
|
|
if (current.blockers.length) throw new BetaError(current.blockers.join(' '), 'invalid-state');
|
|
};
|
|
checkReview();
|
|
const bundleRows = listBetaScripts(betaId);
|
|
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
|
|
const bundle = bundleRows.map((br) => ({
|
|
script: getScript(br.script_id)!,
|
|
version: getVersion(br.script_version_id)!
|
|
}));
|
|
|
|
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
|
|
|
|
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
|
|
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
|
|
const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind));
|
|
|
|
// Build a per-(script, server) plan for non-collapsing artifacts.
|
|
interface Plan {
|
|
script: ScriptRow;
|
|
version: ScriptVersionRow;
|
|
server: string;
|
|
priorVersionId: number | null;
|
|
priorSha: string | null;
|
|
priorContent: Buffer | null;
|
|
hadFile: boolean;
|
|
}
|
|
const plans: Plan[] = [];
|
|
|
|
const getSnapshotForPromote = db().prepare(
|
|
`SELECT prior_content, prior_sha256 FROM beta_file_snapshots
|
|
WHERE beta_id = ? AND server = ? AND rel_path = ?`
|
|
);
|
|
|
|
for (const { script, version } of nonCollapsing) {
|
|
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
|
|
|
|
for (const name of targets) {
|
|
const dest = destOfKind(name, script.kind);
|
|
let plan: Plan;
|
|
if (betaServerSet.has(name)) {
|
|
const bss = getBetaServerScript(betaId, name, script.id);
|
|
if (bss?.had_original) {
|
|
const snap = getSnapshotForPromote.get(betaId, name, script.target_rel_path) as
|
|
| { prior_content: Buffer; prior_sha256: string }
|
|
| undefined;
|
|
let priorContent: Buffer | null = null;
|
|
let priorSha: string | null = null;
|
|
if (snap) {
|
|
priorContent = snap.prior_content;
|
|
priorSha = snap.prior_sha256;
|
|
} else if (bss.prior_version_id) {
|
|
const pv = getVersion(bss.prior_version_id);
|
|
if (pv) { priorContent = pv.content; priorSha = pv.sha256; }
|
|
}
|
|
plan = {
|
|
script, version, server: name,
|
|
priorVersionId: bss.prior_version_id, priorSha, priorContent, hadFile: true
|
|
};
|
|
} else {
|
|
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
|
|
}
|
|
} else {
|
|
// Non-beta matching-scope server: current on-disk file (if any)
|
|
// is the "before" state for a promote-then-rollback round trip.
|
|
const targetPath = join(dest, script.target_rel_path);
|
|
if (existsSync(targetPath)) {
|
|
const priorContent = await readFile(targetPath);
|
|
const inv = getServerScript(name, script.target_rel_path);
|
|
plan = {
|
|
script, version, server: name,
|
|
priorVersionId: inv?.script_version_id ?? null,
|
|
priorSha: sha256Buffer(priorContent),
|
|
priorContent, hadFile: true
|
|
};
|
|
} else {
|
|
plan = { script, version, server: name, priorVersionId: null, priorSha: null, priorContent: null, hadFile: false };
|
|
}
|
|
}
|
|
plans.push(plan);
|
|
}
|
|
}
|
|
|
|
// Write files. Track per-plan success and per-server first-error.
|
|
const planWritten = new Map<Plan, boolean>();
|
|
const serverErr = new Map<string, string>();
|
|
|
|
// Collapsing config layer: one composed cfg per server. Union of beta
|
|
// servers + scope-matching servers for every collapsing artifact.
|
|
interface CfgPlan {
|
|
server: string;
|
|
priorContent: Buffer | null;
|
|
priorSha: string | null;
|
|
composedContent: Buffer;
|
|
composedSha: string;
|
|
hadFile: boolean;
|
|
}
|
|
const cfgPlans: CfgPlan[] = [];
|
|
if (collapsing.length > 0) {
|
|
const cfgTargets = reviewed.preview.configWrites.map(write => write.server);
|
|
for (const name of cfgTargets) {
|
|
const srv = getServer(name);
|
|
if (!srv) {
|
|
serverErr.set(name, 'unknown server during config promote');
|
|
continue;
|
|
}
|
|
const composedBuf = reviewed.configContent.get(name);
|
|
if (!composedBuf) {
|
|
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
|
|
continue;
|
|
}
|
|
const composedSha = sha256Buffer(composedBuf);
|
|
const dest = destOfKind(name, 'config');
|
|
const targetRel = configFileOf(name);
|
|
const targetPath = join(dest, targetRel);
|
|
|
|
let priorContent: Buffer | null = null;
|
|
let priorSha: string | null = null;
|
|
let hadFile = false;
|
|
// Beta server: prior cfg bytes were snapshotted at openBeta.
|
|
if (betaServerSet.has(name)) {
|
|
const snap = getSnapshotForPromote.get(betaId, name, targetRel) as
|
|
| { prior_content: Buffer; prior_sha256: string }
|
|
| undefined;
|
|
if (snap) {
|
|
priorContent = snap.prior_content;
|
|
priorSha = snap.prior_sha256;
|
|
hadFile = true;
|
|
}
|
|
}
|
|
if (!hadFile && existsSync(targetPath)) {
|
|
// Scope-expansion server (or beta server with no snapshot):
|
|
// current on-disk cfg is the "before".
|
|
priorContent = await readFile(targetPath);
|
|
priorSha = sha256Buffer(priorContent);
|
|
hadFile = true;
|
|
}
|
|
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile });
|
|
}
|
|
}
|
|
|
|
// Recheck after asynchronous snapshot reads, before any mutation. Execution uses
|
|
// the captured target lists/content so later membership changes never expand it.
|
|
checkReview();
|
|
for (const p of plans) {
|
|
const dest = destOfKind(p.server, p.script.kind);
|
|
try {
|
|
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
|
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
|
|
planWritten.set(p, true);
|
|
} catch (err) {
|
|
planWritten.set(p, false);
|
|
if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
// Write composed cfg to each target.
|
|
const cfgWritten = new Map<CfgPlan, boolean>();
|
|
for (const cp of cfgPlans) {
|
|
try {
|
|
const dest = destOfKind(cp.server, 'config');
|
|
await mkdir(dest, { recursive: true });
|
|
const targetRel = configFileOf(cp.server);
|
|
const targetPath = join(dest, targetRel);
|
|
// Beta server already has composed content at targetPath; overwrite
|
|
// idempotently. Scope-expansion server: write in place.
|
|
await writeFile(targetPath, cp.composedContent);
|
|
cfgWritten.set(cp, true);
|
|
} catch (err) {
|
|
cfgWritten.set(cp, false);
|
|
const msg = `configs: ${(err as Error).message}`;
|
|
if (!serverErr.has(cp.server)) serverErr.set(cp.server, msg);
|
|
console.error(`[promote] ${cp.server}/configs failed:`, err);
|
|
}
|
|
}
|
|
|
|
// Build the aggregate deployment.
|
|
const depId = promoteDeploymentId(betaId);
|
|
const startedAt = nowIso();
|
|
|
|
const insertDep = db().prepare(
|
|
`INSERT INTO deployments
|
|
(id, action, started_at, triggered_by, script_id, script_version_id, prior_version_id, beta_id)
|
|
VALUES (?, 'promote', ?, 'ui', NULL, NULL, NULL, ?)`
|
|
);
|
|
const insertDepScript = db().prepare(
|
|
`INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
);
|
|
const insertDepServer = db().prepare(
|
|
`INSERT INTO deployment_servers
|
|
(deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error)
|
|
VALUES (?, ?, ?, ?, NULL, NULL, ?, 0, ?)`
|
|
);
|
|
const insertDepFile = db().prepare(
|
|
`INSERT INTO deployment_files
|
|
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
);
|
|
|
|
const uniqueServers = Array.from(new Set([
|
|
...plans.map((p) => p.server),
|
|
...cfgPlans.map((c) => c.server)
|
|
]));
|
|
|
|
db().transaction(() => {
|
|
insertDep.run(depId, startedAt, betaId);
|
|
for (const { script, version } of bundle) {
|
|
insertDepScript.run(depId, script.id, version.id, script.current_version_id, script.kind);
|
|
}
|
|
// deployment_files — one row per successful (script, server) write for
|
|
// non-collapsing artifacts, plus one row per (server, dedicated_sp.cfg)
|
|
// for the composed config layer.
|
|
for (const p of plans) {
|
|
if (!planWritten.get(p)) continue;
|
|
const verb = p.hadFile ? 'updated' : 'added';
|
|
insertDepFile.run(
|
|
depId,
|
|
p.server,
|
|
verb,
|
|
p.script.target_rel_path,
|
|
p.priorSha,
|
|
p.version.sha256,
|
|
p.priorContent,
|
|
p.version.content
|
|
);
|
|
recordScript(
|
|
p.server,
|
|
p.script.target_rel_path,
|
|
p.version.id,
|
|
p.version.sha256,
|
|
p.version.size,
|
|
`promote:${depId}`
|
|
);
|
|
}
|
|
for (const cp of cfgPlans) {
|
|
if (!cfgWritten.get(cp)) continue;
|
|
const verb = cp.hadFile ? 'updated' : 'added';
|
|
insertDepFile.run(
|
|
depId,
|
|
cp.server,
|
|
verb,
|
|
configFileOf(cp.server),
|
|
cp.priorSha,
|
|
cp.composedSha,
|
|
cp.priorContent,
|
|
cp.composedContent
|
|
);
|
|
recordScript(
|
|
cp.server,
|
|
configFileOf(cp.server),
|
|
null,
|
|
cp.composedSha,
|
|
cp.composedContent.byteLength,
|
|
`promote:${depId}`
|
|
);
|
|
}
|
|
// deployment_servers — one row per unique server. Beta servers are
|
|
// already restarted; scope-only servers still need the sweep to drain
|
|
// and restart them.
|
|
for (const name of uniqueServers) {
|
|
const err = serverErr.get(name);
|
|
if (err) {
|
|
insertDepServer.run(depId, name, 0, 'failed', null, err);
|
|
continue;
|
|
}
|
|
const filesChanged =
|
|
plans.filter((p) => p.server === name && planWritten.get(p)).length +
|
|
cfgPlans.filter((c) => c.server === name && cfgWritten.get(c)).length;
|
|
if (betaServerSet.has(name)) {
|
|
const bsRestartedAt = db()
|
|
.prepare(`SELECT restarted_at FROM beta_servers WHERE beta_id = ? AND server = ?`)
|
|
.get(betaId, name) as { restarted_at: string | null } | undefined;
|
|
insertDepServer.run(depId, name, filesChanged, 'done', bsRestartedAt?.restarted_at ?? startedAt, null);
|
|
} else {
|
|
insertDepServer.run(depId, name, filesChanged, 'pending', null, null);
|
|
}
|
|
}
|
|
// Update each script's current_version_id in the same transaction.
|
|
for (const { script, version } of bundle) {
|
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
|
}
|
|
})();
|
|
|
|
db().prepare(`UPDATE betas SET state = 'promoted', closed_at = ? WHERE id = ?`).run(nowIso(), betaId);
|
|
// Snapshots are single-use — the promoted content is live on disk, so the
|
|
// pre-beta bytes are no longer needed.
|
|
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
|
|
|
const archive = betaArchiveOf(betaId);
|
|
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
|
|
|
const landed = Array.from(new Set([
|
|
...plans.filter((p) => planWritten.get(p)).map((p) => p.server),
|
|
...cfgPlans.filter((c) => cfgWritten.get(c)).map((c) => c.server)
|
|
])).sort();
|
|
|
|
emit({ type: 'beta-state', betaId, state: 'promoted' });
|
|
emit({ type: 'deployment-opened', deploymentId: depId, action: 'promote', servers: landed });
|
|
|
|
return {
|
|
betaId,
|
|
deploymentId: depId,
|
|
landed,
|
|
scriptIds: bundle.map((b) => b.script.id),
|
|
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
|
|
failedServers: [...serverErr.keys()]
|
|
};
|
|
}
|
|
|
|
// ---------- helpers used by API layer ----------
|
|
|
|
// Latest of the script's versions — useful for the "open beta with this
|
|
// version" default in the UI.
|
|
export function latestVersionForScript(script: ScriptRow): ScriptVersionRow | null {
|
|
const rows = listVersions(script.id);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Un-set a script's scope. Files are left on disk so drift picks up the
|
|
* change on the next /deployments rescan and the operator reconciles from
|
|
* there like any other drift — the same visible flow as manual drift.
|
|
*
|
|
* For scripts/plugins, we detach the inventory rows for this artifact's
|
|
* versions so drift Pass 1 flags the on-disk copies as untracked. For
|
|
* configs/baselines, no detach is needed: drift Pass 2 recomposes the
|
|
* expected dedicated_sp.cfg against the current promoted overlays, and
|
|
* scope=null naturally drops the overlay from the compose, surfacing the
|
|
* disparity. Returns the servers whose inventory was detached (empty for
|
|
* configs/baselines).
|
|
*/
|
|
export function unsetScopeAndDetach(scriptId: number): string[] {
|
|
const script = getScript(scriptId);
|
|
if (!script) throw new BetaError(`no such script (id=${scriptId})`, 'no-such-script');
|
|
setScope({ scriptId, scopeType: null, scopeValue: null });
|
|
|
|
const detached: string[] = [];
|
|
if (script.kind === 'script' || script.kind === 'plugin') {
|
|
const rows = db()
|
|
.prepare(
|
|
`SELECT server FROM server_scripts
|
|
WHERE rel_path = ?
|
|
AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?)`
|
|
)
|
|
.all(script.target_rel_path, scriptId) as Array<{ server: string }>;
|
|
for (const { server } of rows) {
|
|
removeScript(server, script.target_rel_path);
|
|
detached.push(server);
|
|
}
|
|
}
|
|
return detached;
|
|
}
|