diff --git a/src/lib/operation-flow.ts b/src/lib/operation-flow.ts
new file mode 100644
index 0000000..b832a12
--- /dev/null
+++ b/src/lib/operation-flow.ts
@@ -0,0 +1,41 @@
+export interface ActivityEvent { id: number; created_at: string; message: string }
+export interface OperationStep { label: string; state: 'pending' | 'current' | 'done' | 'failed' }
+
+interface BetaServer {
+ stage: string; deployed_at: string | null; restarted_at: string | null;
+ healthy_at: string | null; match_seen_at: string | null;
+}
+
+export function betaFlow(beta: { state: string; wait_for_health: number; wait_for_match: number }, servers: BetaServer[]): OperationStep[] {
+ const stages = ['deploying', 'awaiting-empty', 'restarting'];
+ const labels = ['Deploy artifacts', 'Wait for empty servers', 'Restart servers'];
+ if (beta.wait_for_health) { stages.push('awaiting-health'); labels.push('Verify health'); }
+ if (beta.wait_for_match) { stages.push('awaiting-match'); labels.push('Observe a completed match'); }
+ stages.push('ready'); labels.push('Review and promote');
+ const positions = servers.map(s => {
+ if (s.stage !== 'failed') return Math.max(0, stages.indexOf(s.stage));
+ if (!s.deployed_at) return 0;
+ if (!s.restarted_at) return 2;
+ if (beta.wait_for_health && !s.healthy_at) return stages.indexOf('awaiting-health');
+ if (beta.wait_for_match && !s.match_seen_at) return stages.indexOf('awaiting-match');
+ return stages.length - 1;
+ });
+ const current = beta.state === 'promoted' ? stages.length
+ : beta.state === 'ready-promote' ? stages.length - 1
+ : positions.length ? Math.min(...positions) : 0;
+ return labels.map((label, i) => ({ label, state: i < current ? 'done' : i === current
+ ? beta.state === 'rolled-back' ? 'pending' : servers.some((s, j) => s.stage === 'failed' && positions[j] === i) ? 'failed' : 'current'
+ : 'pending' }));
+}
+
+export function campaignFlow(servers: { restart_state: string; restarted_at: string | null; files_changed: number }[], finished: boolean): OperationStep[] {
+ const positions = servers.map(s => s.restart_state === 'done' ? 4
+ : s.restart_state === 'failed' && !s.files_changed ? 0
+ : s.restarted_at ? 3 : 1);
+ const current = positions.length ? Math.min(...positions) : finished ? 4 : 0;
+ return ['Apply file changes', 'Wait for empty servers', 'Restart servers', 'Verify health'].map((label, i) => ({
+ label, state: i < current ? 'done' : i !== current ? 'pending'
+ : servers.some((s, j) => s.restart_state === 'failed' && positions[j] === i) ? 'failed'
+ : servers.some(s => ['manual', 'held'].includes(s.restart_state)) ? 'pending' : 'current'
+ }));
+}
diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql
index cca9016..e657553 100644
--- a/src/lib/server/db/schema.sql
+++ b/src/lib/server/db/schema.sql
@@ -286,3 +286,96 @@ CREATE TABLE IF NOT EXISTS server_creation_jobs (
map_slug TEXT NOT NULL,
expected_map TEXT NOT NULL
);
+
+-- Persist state transitions atomically, including changes made by background workers.
+-- Polls with unchanged status/error do not produce duplicate activity entries.
+CREATE TABLE IF NOT EXISTS beta_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ message TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_beta_events ON beta_events(beta_id, id);
+CREATE TRIGGER IF NOT EXISTS beta_activity_open AFTER INSERT ON betas
+BEGIN
+ INSERT INTO beta_events (beta_id, created_at, message)
+ VALUES (NEW.id, NEW.opened_at, 'Beta opened');
+END;
+
+CREATE TABLE IF NOT EXISTS campaign_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
+ message TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_campaign_events ON campaign_events(deployment_id, id);
+CREATE TRIGGER IF NOT EXISTS campaign_activity_file AFTER INSERT ON deployment_files
+BEGIN
+ INSERT INTO campaign_events (deployment_id, message)
+ VALUES (NEW.deployment_id, NEW.server || ': ' || NEW.verb || ' ' || NEW.rel_path);
+END;
+CREATE TRIGGER IF NOT EXISTS campaign_activity_open AFTER INSERT ON deployments
+BEGIN
+ INSERT INTO campaign_events (deployment_id, created_at, message)
+ VALUES (NEW.id, NEW.started_at, 'Campaign started: ' || NEW.action);
+END;
+
+CREATE TRIGGER IF NOT EXISTS beta_activity_state AFTER UPDATE OF state ON betas
+WHEN OLD.state IS NOT NEW.state
+BEGIN
+ INSERT INTO beta_events (beta_id, message) VALUES (NEW.id, CASE NEW.state
+ WHEN 'ready-promote' THEN 'All beta servers are ready for review and promotion'
+ WHEN 'promoted' THEN 'Beta promoted'
+ WHEN 'rolled-back' THEN 'Beta rolled back'
+ ELSE 'Beta state: ' || NEW.state END);
+END;
+CREATE TRIGGER IF NOT EXISTS campaign_activity_finish AFTER UPDATE OF finished_at ON deployments
+WHEN OLD.finished_at IS NULL AND NEW.finished_at IS NOT NULL
+BEGIN
+ INSERT INTO campaign_events (deployment_id, created_at, message)
+ VALUES (NEW.id, NEW.finished_at, 'Campaign processing finished; see server statuses for held or manual work');
+END;
+
+CREATE TRIGGER IF NOT EXISTS beta_activity_server_insert AFTER INSERT ON beta_servers
+
+BEGIN
+ INSERT INTO beta_events (beta_id, message) VALUES (NEW.beta_id, NEW.server || ': ' || CASE WHEN NEW.stage = 'deploying' THEN 'Deploying artifacts'
+ WHEN NEW.stage = 'awaiting-empty' THEN 'Waiting for an empty server'
+ WHEN NEW.stage = 'restarting' THEN 'Restarting server'
+ WHEN NEW.stage = 'awaiting-health' THEN 'Verifying health'
+ WHEN NEW.stage = 'awaiting-match' THEN 'Waiting for a completed match'
+ WHEN NEW.stage = 'ready' THEN 'Ready for promotion'
+ WHEN NEW.stage = 'failed' THEN 'Failed' ELSE NEW.stage END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END);
+END;
+
+CREATE TRIGGER IF NOT EXISTS beta_activity_server_update AFTER UPDATE ON beta_servers
+WHEN OLD.stage IS NOT NEW.stage OR OLD.error IS NOT NEW.error OR OLD.restarted_at IS NOT NEW.restarted_at
+BEGIN
+ INSERT INTO beta_events (beta_id, message) VALUES (NEW.beta_id, NEW.server || ': ' || CASE WHEN NEW.stage = 'deploying' THEN 'Deploying artifacts'
+ WHEN NEW.stage = 'awaiting-empty' THEN 'Waiting for an empty server'
+ WHEN NEW.stage = 'restarting' THEN 'Restarting server'
+ WHEN NEW.stage = 'awaiting-health' THEN 'Verifying health'
+ WHEN NEW.stage = 'awaiting-match' THEN 'Waiting for a completed match'
+ WHEN NEW.stage = 'ready' THEN 'Ready for promotion'
+ WHEN NEW.stage = 'failed' THEN 'Failed' ELSE NEW.stage END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END);
+END;
+
+CREATE TRIGGER IF NOT EXISTS campaign_activity_server_insert AFTER INSERT ON deployment_servers
+
+BEGIN
+ INSERT INTO campaign_events (deployment_id, message) VALUES (NEW.deployment_id, NEW.server || ': ' || CASE WHEN NEW.restart_state = 'pending' AND NEW.restarted_at IS NOT NULL THEN 'Restart requested; verifying health' WHEN NEW.restart_state = 'pending' THEN 'Waiting for an empty server or restart capacity'
+ WHEN NEW.restart_state = 'done' THEN 'Server complete'
+ WHEN NEW.restart_state = 'failed' THEN 'Failed'
+ WHEN NEW.restart_state = 'manual' THEN 'Manual restart required'
+ WHEN NEW.restart_state = 'held' THEN 'Changes held' ELSE NEW.restart_state END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END);
+END;
+
+CREATE TRIGGER IF NOT EXISTS campaign_activity_server_update AFTER UPDATE ON deployment_servers
+WHEN OLD.restart_state IS NOT NEW.restart_state OR OLD.error IS NOT NEW.error OR OLD.restarted_at IS NOT NEW.restarted_at
+BEGIN
+ INSERT INTO campaign_events (deployment_id, message) VALUES (NEW.deployment_id, NEW.server || ': ' || CASE WHEN NEW.restart_state = 'pending' AND NEW.restarted_at IS NOT NULL THEN 'Restart requested; verifying health' WHEN NEW.restart_state = 'pending' THEN 'Waiting for an empty server or restart capacity'
+ WHEN NEW.restart_state = 'done' THEN 'Server complete'
+ WHEN NEW.restart_state = 'failed' THEN 'Failed'
+ WHEN NEW.restart_state = 'manual' THEN 'Manual restart required'
+ WHEN NEW.restart_state = 'held' THEN 'Changes held' ELSE NEW.restart_state END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END);
+END;
diff --git a/src/lib/ui/components/OperationHistory.svelte b/src/lib/ui/components/OperationHistory.svelte
new file mode 100644
index 0000000..7e0072f
--- /dev/null
+++ b/src/lib/ui/components/OperationHistory.svelte
@@ -0,0 +1,47 @@
+
+
+
+