Improve operational UX, recovery flows, and action safety

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.
This commit is contained in:
2026-09-19 21:34:34 -04:00
parent 940f81e6ca
commit 8ac420fe61
83 changed files with 2574 additions and 1190 deletions
+9 -2
View File
@@ -73,8 +73,15 @@ export function testView(betaId: number): BetaTestView | null {
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL ORDER BY server').all(betaId) as TestRun[];
return { plan, outcome: stored.outcome, reason: stored.reason,
servers: runs.map(run => {
const progress = evaluateRequirements(plan, readMetrics(run), run.phase);
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress };
const metrics = readMetrics(run);
const progress = evaluateRequirements(plan, metrics, run.phase);
const latest = db().prepare("SELECT received_at, kind, payload FROM beta_test_evidence WHERE run_id = ? AND kind IN ('game_start','round_change','game_end') ORDER BY id DESC LIMIT 1").get(run.id) as { received_at: string; kind: string; payload: string } | undefined;
const event = latest ? JSON.parse(latest.payload) : null;
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress, observingAt: run.observing_at,
observation: { lastGameplayAt: latest?.received_at ?? null, lastEvent: latest?.kind ?? null,
players: metrics.match?.players ?? null, round: event?.round ?? null,
matchStarted: metrics.match?.started !== undefined, gameEnded: metrics.match?.ended ?? false,
healthy: metrics.wasHealthy ?? null, healthCheckedAt: metrics.lastSample ? new Date(metrics.lastSample).toISOString() : null } };
}),
evidence: db().prepare(`SELECT e.id, r.server, e.received_at, e.kind, e.payload FROM beta_test_evidence e
JOIN beta_test_runs r ON r.id = e.run_id WHERE r.beta_id = ? ORDER BY e.id DESC LIMIT 200`).all(betaId) as BetaTestView['evidence'] };
+83 -41
View File
@@ -1,5 +1,6 @@
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);
@@ -192,6 +193,7 @@ export class BetaError extends Error {
| 'dest-fail'
| 'invalid-state'
| 'duplicate-script'
| 'preview-changed'
| 'duplicate-target',
public readonly details?: unknown
) {
@@ -721,6 +723,54 @@ export interface PromoteResult {
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 {
@@ -728,14 +778,14 @@ function promoteDeploymentId(betaId: number): string {
return `dep-${stamp}-promote-${betaId}`;
}
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
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));
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
}
async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
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);
@@ -743,6 +793,15 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
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) => ({
@@ -755,8 +814,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
// 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));
const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline');
const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config');
// Build a per-(script, server) plan for non-collapsing artifacts.
interface Plan {
@@ -776,10 +833,7 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
);
for (const { script, version } of nonCollapsing) {
const scopeServers = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value)
: [];
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
for (const name of targets) {
const dest = destOfKind(name, script.kind);
@@ -830,23 +884,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
// Write files. Track per-plan success and per-server first-error.
const planWritten = new Map<Plan, boolean>();
const serverErr = new Map<string, string>();
for (const p of plans) {
const dest = destOfKind(p.server, p.script.kind);
const targetPath = join(dest, p.script.target_rel_path);
try {
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
// Beta servers already have the version bytes at targetPath from
// openBeta; scope-expansion servers need the write. Idempotent
// either way — we just overwrite with the promoted content.
await writeFile(targetPath, p.version.content);
planWritten.set(p, true);
} catch (err) {
planWritten.set(p, false);
const msg = `${p.script.name}: ${(err as Error).message}`;
if (!serverErr.has(p.server)) serverErr.set(p.server, msg);
console.error(`[promote] ${p.server}/${p.script.name} failed:`, err);
}
}
// Collapsing config layer: one composed cfg per server. Union of beta
// servers + scope-matching servers for every collapsing artifact.
@@ -860,29 +897,18 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
}
const cfgPlans: CfgPlan[] = [];
if (collapsing.length > 0) {
const cfgTargets = new Set<string>(betaServerSet);
for (const { script } of collapsing) {
if (script.scope_type && script.scope_value) {
for (const s of serversMatchingScope(script.scope_type, script.scope_value)) {
cfgTargets.add(s);
}
}
}
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 composedResult = composeCfgForServer(name, srv.game, srv.map, {
inBetaBaseline: collapsingBaseline?.version,
inBetaOverlays: collapsingOverlays
});
if (!composedResult) {
const composedBuf = reviewed.configContent.get(name);
if (!composedBuf) {
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
continue;
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
const composedSha = sha256Buffer(composedBuf);
const dest = destOfKind(name, 'config');
const targetRel = configFileOf(name);
@@ -913,6 +939,20 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
}
}
// 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) {
@@ -1061,7 +1101,9 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
betaId,
deploymentId: depId,
landed,
scriptIds: bundle.map((b) => b.script.id)
scriptIds: bundle.map((b) => b.script.id),
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
failedServers: [...serverErr.keys()]
};
}
+11 -1
View File
@@ -5,6 +5,8 @@ import { config, komodoConfigured, rconConfigured } from '../config.js';
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
import { getServerKey } from './server-keys.js';
import { classifyHealth } from './health.js';
import { getServer } from './servers.js';
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
function coverage(entries: BlueprintEntry[]) {
@@ -29,7 +31,15 @@ function coverage(entries: BlueprintEntry[]) {
const matches = zombieMaps(game).filter(map => map.canonical === server.map);
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
}).map(server => server.name).sort();
return { existingServers, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
const coverage = existingServers.map(name => {
const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined;
const server = getServer(name);
return { name, health: operation ? operation.state === 'queued' ? 'queued' : 'starting'
: server?.container_state && !['running', 'starting', 'restarting'].includes(server.container_state) ? 'stopped'
: classifyHealth(server?.last_seen_at ?? null, server?.last_error ?? null, server?.container_state),
href: operation ? `/deployments/${encodeURIComponent(operation.id)}` : `/servers/${encodeURIComponent(name)}` };
});
return { existingServers, coverage, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
});
}
+6 -4
View File
@@ -1,5 +1,5 @@
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification } from './deployments.js';
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification, claimRemoval } from './deployments.js';
import { randomUUID } from 'node:crypto';
import type { LaunchPreview } from '$lib/deployment-plan.js';
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
@@ -461,6 +461,7 @@ async function runDestructions(): Promise<void> {
const queued = db().prepare("SELECT id, server, stage FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string; stage: string }[];
for (const operation of queued) {
if (creating || destroying.has(operation.server)) continue;
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
destroying.add(operation.server);
try {
if (operation.stage === REMOVING_SERVER) {
@@ -470,7 +471,7 @@ async function runDestructions(): Promise<void> {
finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' });
continue;
}
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
if (!claimRemoval(operation.id)) continue;
finishDeployment(operation.id, await cleanupDestroyedServer(operation.server, operation.id));
continue;
}
@@ -484,20 +485,21 @@ async function runDestructions(): Promise<void> {
}
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
const players = await rconGetPlayerCount(operation.server);
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
if (players !== 0) {
deploymentStage(operation.id, players === null
? 'Waiting for player count: server could not be reached'
: 'Waiting for server to empty: ' + players + ' player(s) online');
continue;
}
if (!claimRemoval(operation.id)) continue;
deploymentStage(operation.id, 'Server is empty (0 players verified)');
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
const result = await destroyServerUnlocked(operation.server, operation.id);
finishDeployment(operation.id, result);
} catch (err) {
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message);
else finishDeployment(operation.id, { ok: false, message: (err as Error).message });
else if (row.state !== 'cancelled') finishDeployment(operation.id, { ok: false, message: (err as Error).message });
} finally { destroying.delete(operation.server); }
}
}
+23 -3
View File
@@ -30,12 +30,31 @@ export function getDeployment(id: string) {
const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null;
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), collection };
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), canCancelRemoval: canCancelRemoval(id), collection };
}
// This stage is written only after Komodo has confirmed deployment deletion.
export const REMOVING_SERVER = 'Removing server files and registry';
export function canCancelRemoval(id: string): boolean {
return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER);
}
/** Competes atomically with the worker's claim, before any external removal. */
export function cancelRemoval(id: string): boolean {
return db().transaction(() => {
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(new Date().toISOString(), id, REMOVING_SERVER);
if (!result.changes) return false;
recordDeploymentEvent(id, 'Queued removal cancelled. The server and its files were kept.');
return true;
})();
}
export function claimRemoval(id: string): boolean {
return db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND action = 'destroy' AND state = 'waiting'").run(id).changes > 0;
}
export function canResumeCleanup(id: string): boolean {
return !!db().prepare(`SELECT d.id FROM server_deployments d
WHERE d.id = ? AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
@@ -81,8 +100,9 @@ export function finishDeployment(id: string, result: { ok: boolean; message?: st
}
export function listDeployments() {
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all();
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists,
(d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER);
}
export function interruptDeployments(): void {
+53 -4
View File
@@ -1,4 +1,6 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
import { existsSync } from 'node:fs';
import { dirname, join, posix, relative, sep } from 'node:path';
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
@@ -244,6 +246,40 @@ export interface RunDriftInput {
servers?: string[]; // if omitted, scans the whole fleet
dryRun?: boolean;
triggeredBy?: 'ui' | 'sweep' | 'cron';
expectedFingerprint?: string;
}
export class DriftPlanChanged extends Error {
constructor(public plan: DriftPlan) { super('The files or target servers changed. Nothing was changed. Review the updated plan before continuing.'); }
}
function driftTargets(servers?: string[]): string[] {
const registered = listServers().map(server => server.name);
if (servers && (!servers.length || servers.some(name => !registered.includes(name)))) throw new Error('Select at least one registered server. Refresh the fleet if a server was removed.');
return [...new Set(servers ?? registered)].sort();
}
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
const files: DriftPlanFile[] = [];
for (const entry of entries) {
const before = await readFile(join(destOfKind(entry.server, entry.kind), entry.relPath)).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
const after = entry.driftKind === 'untracked' ? null : await resolveManagedEntry(entry.server, entry);
files.push({ server: entry.server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind,
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null });
}
files.sort((a, b) => `${a.server}:${a.kind}:${a.relPath}`.localeCompare(`${b.server}:${b.kind}:${b.relPath}`));
return { servers, files, scannedAt: new Date().toISOString(), fingerprint: sha256Buffer(Buffer.from(JSON.stringify({ servers, files }))) };
}
export async function previewDrift(servers?: string[]): Promise<DriftPlan> {
const targets = driftTargets(servers);
const entries: DriftEntry[] = [];
for (const server of targets) entries.push(...await scanServerDrift(server));
return describePlan(targets, entries);
}
export interface RunDriftResult {
@@ -259,7 +295,7 @@ export interface RunDriftResult {
function deploymentIdNow(): string {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
return `dep-${stamp}-drift`;
return `dep-${stamp}-drift-${randomUUID().slice(0, 8)}`;
}
/**
@@ -271,9 +307,7 @@ function deploymentIdNow(): string {
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
const { dryRun = false, triggeredBy = 'ui' } = input;
const scanTargets = input.servers && input.servers.length > 0
? input.servers
: listServers().map((s) => s.name);
const scanTargets = driftTargets(input.servers);
const perServer: Array<{
server: string;
@@ -287,6 +321,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
}
// Recheck identities, existing bytes, and replacement bytes before any write.
const plan = input.expectedFingerprint ? await describePlan(scanTargets, perServer.flatMap(server => server.entries)) : null;
if (plan && plan.fingerprint !== input.expectedFingerprint) throw new DriftPlanChanged(plan);
const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0);
if (totalDrift === 0 || dryRun) {
return {
@@ -335,6 +372,14 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
const dest = destOfKind(s.server, entry.kind);
const abs = join(dest, entry.relPath);
try {
const reviewed = plan?.files.find(file => file.server === s.server && file.kind === entry.kind && file.relPath === entry.relPath);
if (reviewed) {
const current = await readFile(abs).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; });
if ((current ? sha256Buffer(current) : null) !== reviewed.beforeSha256) {
s.skipped.push(`${entry.relPath}: changed after review; scan and review again`);
continue;
}
}
if (entry.driftKind === 'untracked') {
// Delete a file inventory doesn't know about.
const content = await readFile(abs);
@@ -349,6 +394,10 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
continue;
}
if (reviewed && sha256Buffer(written) !== reviewed.afterSha256) {
s.skipped.push(`${entry.relPath}: promoted content changed after review; scan and review again`);
continue;
}
const before = await readFile(abs).catch((err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') return null;
throw err;
+3 -3
View File
@@ -35,17 +35,17 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
// Only the newest lifecycle operation can still require action on a server.
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
OR (d.state IN ('done', 'warning') AND d.finished_at >= ?)
OR (d.state IN ('done', 'warning', 'cancelled') AND d.finished_at >= ?)
OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`)
.all(since) as { id: string; server: string; action: string; state: string; stage: string;
error: string | null; started_at: string }[];
for (const row of lifecycle) {
const state = row.state === 'done' || row.state === 'warning' ? 'done'
const state = ['done', 'warning', 'cancelled'].includes(row.state) ? 'done'
: ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active';
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
server: row.server, state, startedAt: row.started_at,
label: state === 'done' ? row.state === 'warning' ? 'Completed with warnings' : 'Complete'
label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete'
: state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage,
detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` });
}