agent-control-plane 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1120 @@
1
+ const refreshButton = document.querySelector("#refresh-button");
2
+ const themeToggleButton = document.querySelector("#theme-toggle");
3
+ const generatedAtNode = document.querySelector("#generated-at");
4
+ const overviewNode = document.querySelector("#overview");
5
+ const profilesNode = document.querySelector("#profiles");
6
+ const seenAlertIds = new Set();
7
+ let notificationPermissionRequested = false;
8
+ const THEME_STORAGE_KEY = "acp-dashboard-theme";
9
+ const ROWS_PER_PAGE = 10;
10
+ const THEME_OPTIONS = ['light', 'dark', 'auto'];
11
+ let currentThemeIndex = 0;
12
+
13
+ // Pagination state: { [tableId]: { page: number }
14
+
15
+ function systemPrefersDark() {
16
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches;
17
+ }
18
+
19
+ function getEffectiveTheme(theme) {
20
+ if (theme === 'auto') {
21
+ return systemPrefersDark() ? 'dark' : 'light';
22
+ }
23
+ return theme;
24
+ }
25
+
26
+ function currentThemePreference() {
27
+ try {
28
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
29
+ if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored;
30
+ } catch (_error) {
31
+ // Ignore storage access issues and fall back to system preference.
32
+ }
33
+ return 'auto'; // Default to auto (follow system)
34
+ }
35
+
36
+ function updateThemeToggleLabel(theme) {
37
+ if (!themeToggleButton) return;
38
+ const nextIndex = (THEME_OPTIONS.indexOf(theme) + 1) % THEME_OPTIONS.length;
39
+ const nextTheme = THEME_OPTIONS[nextIndex];
40
+ const label = nextTheme === 'dark' ? 'Dark mode' : nextTheme === 'light' ? 'Light mode' : 'Auto (system)';
41
+ themeToggleButton.textContent = label;
42
+ themeToggleButton.setAttribute("aria-label", `Switch to ${label.toLowerCase()}`);
43
+ }
44
+
45
+ function applyTheme(theme) {
46
+ const effectiveTheme = getEffectiveTheme(theme);
47
+ document.documentElement.dataset.theme = effectiveTheme;
48
+ updateThemeToggleLabel(theme);
49
+
50
+ // Listen for system theme changes if in auto mode
51
+ if (theme === 'auto' && typeof window.matchMedia === 'function') {
52
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
53
+ mediaQuery.onchange = (e) => {
54
+ if (currentThemePreference() === 'auto') {
55
+ const newTheme = e.matches ? 'dark' : 'light';
56
+ document.documentElement.dataset.theme = newTheme;
57
+ }
58
+ };
59
+ }
60
+ }
61
+
62
+ function persistTheme(theme) {
63
+ try {
64
+ window.localStorage.setItem(THEME_STORAGE_KEY, theme);
65
+ } catch (_error) {
66
+ // Ignore storage access issues.
67
+ }
68
+ }
69
+
70
+ function initializeTheme() {
71
+ const theme = currentThemePreference();
72
+ currentThemeIndex = THEME_OPTIONS.indexOf(theme);
73
+ applyTheme(theme);
74
+ if (!themeToggleButton) return;
75
+ themeToggleButton.addEventListener("click", () => {
76
+ const nextIndex = (currentThemeIndex + 1) % THEME_OPTIONS.length;
77
+ const nextTheme = THEME_OPTIONS[nextIndex];
78
+ currentThemeIndex = nextIndex;
79
+ applyTheme(nextTheme);
80
+ persistTheme(nextTheme);
81
+ });
82
+ }
83
+
84
+ function relativeTime(input) {
85
+ if (!input) return "n/a";
86
+ const value = new Date(input);
87
+ if (Number.isNaN(value.getTime())) return input;
88
+ const seconds = Math.round((Date.now() - value.getTime()) / 1000);
89
+ const absolute = Math.abs(seconds);
90
+ const parts = [
91
+ [86400, "d"],
92
+ [3600, "h"],
93
+ [60, "m"],
94
+ ];
95
+ for (const [unitSeconds, label] of parts) {
96
+ if (absolute >= unitSeconds) {
97
+ const amount = Math.round(absolute / unitSeconds);
98
+ return seconds >= 0 ? `${amount}${label} ago` : `in ${amount}${label}`;
99
+ }
100
+ }
101
+ return seconds >= 0 ? `${absolute}s ago` : `in ${absolute}s`;
102
+ }
103
+
104
+ function formatCompactDate(input) {
105
+ if (!input) return "n/a";
106
+ const d = new Date(input);
107
+ if (Number.isNaN(d.getTime())) return input;
108
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
109
+ return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
110
+ }
111
+
112
+ function formatDuration(seconds) {
113
+ if (!seconds && seconds !== 0) return "n/a";
114
+ const absSeconds = Math.abs(seconds);
115
+ const parts = [];
116
+ const units = [
117
+ [86400, "d"],
118
+ [3600, "h"],
119
+ [60, "m"],
120
+ [1, "s"],
121
+ ];
122
+ for (const [unitSeconds, label] of units) {
123
+ if (absSeconds >= unitSeconds) {
124
+ const amount = Math.floor(absSeconds / unitSeconds);
125
+ parts.push(`${amount}${label}`);
126
+ seconds -= amount * unitSeconds;
127
+ }
128
+ }
129
+ return parts.slice(0, 2).join(" ") || "0s";
130
+ }
131
+
132
+ function timeRemaining(isoString) {
133
+ if (!isoString) return "n/a";
134
+ const next = new Date(isoString);
135
+ if (Number.isNaN(next.getTime())) return isoString;
136
+ const diffSeconds = Math.round((next.getTime() - Date.now()) / 1000);
137
+ if (diffSeconds <= 0) return "ready now";
138
+ return formatDuration(diffSeconds);
139
+ }
140
+
141
+ function statusClass(status) {
142
+ if (!status) return "";
143
+ return status.replace(/[^a-zA-Z0-9_-]/g, "-");
144
+ }
145
+
146
+ function renderLifecycle(row) {
147
+ const note = row.result_only_completion === "yes" ? `<div class="muted">Recovered</div>` : "";
148
+ return `<span class="status-pill ${statusClass(row.lifecycle_status || row.status)}">${row.lifecycle_status || row.status || "UNKNOWN"}</span>${note}`;
149
+ }
150
+
151
+ function renderResult(row) {
152
+ const primary = row.result_label || row.outcome || row.failure_reason || "n/a";
153
+ const secondary = [];
154
+ if (row.outcome && primary !== row.outcome) secondary.push(row.outcome);
155
+ if (row.action) secondary.push(row.action);
156
+ return `<span class="status-pill ${statusClass(row.result_kind || "unknown")}">${primary}</span>${
157
+ secondary.length ? `<div class="muted">${secondary.join(" · ")}</div>` : ""
158
+ }`;
159
+ }
160
+
161
+ function renderControllerState(row) {
162
+ const state = row.state || "n/a";
163
+ const stale = row.controller_stale === true || (state !== "stopped" && row.controller_live === false);
164
+ const label = stale ? `${state} (stale)` : state;
165
+ return `<span class="status-pill ${statusClass(stale ? "stale" : state)}">${label}</span>`;
166
+ }
167
+
168
+ function renderOverview(snapshot) {
169
+ const totals = snapshot.profiles.reduce(
170
+ (acc, profile) => {
171
+ acc.activeRuns += profile.counts.active_runs;
172
+ acc.runningRuns += profile.counts.running_runs;
173
+ acc.implementedRuns += profile.counts.implemented_runs;
174
+ acc.reportedRuns += profile.counts.reported_runs;
175
+ acc.blockedRuns += profile.counts.blocked_runs;
176
+ acc.controllers += profile.counts.live_resident_controllers;
177
+ acc.cooldowns += profile.counts.provider_cooldowns;
178
+ acc.queue += profile.counts.queued_issues;
179
+ acc.alerts += profile.counts.alerts || 0;
180
+ acc.pendingGithubWrites += profile.counts.pending_github_writes || 0;
181
+ return acc;
182
+ },
183
+ { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0, pendingGithubWrites: 0 },
184
+ );
185
+
186
+ overviewNode.innerHTML = [
187
+ ["Profiles", snapshot.profile_count],
188
+ ["Run sessions", totals.activeRuns],
189
+ ["Running", totals.runningRuns],
190
+ ["Implemented", totals.implementedRuns],
191
+ ["Reported", totals.reportedRuns],
192
+ ["Blocked", totals.blockedRuns],
193
+ ["Live Controllers", totals.controllers],
194
+ ["Provider Cooldowns", totals.cooldowns],
195
+ ["Pending GitHub Writes", totals.pendingGithubWrites],
196
+ ["Alerts", totals.alerts],
197
+ ["Queued Issues", totals.queue],
198
+ ["Retries", totals.retries || 0],
199
+ ["Blockers", totals.blockers || 0],
200
+ ]
201
+ .map(
202
+ ([label, value]) => `
203
+ <article class="card">
204
+ <div class="stat-label">${label}</div>
205
+ <div class="stat-value">${value}</div>
206
+ </article>
207
+ `,
208
+ )
209
+ .join("");
210
+ }
211
+
212
+ // Build windowed pagination: max 5 page buttons with ellipsis
213
+ function buildWindowedPages(current, total) {
214
+ const pages = [];
215
+ const maxVisible = 5;
216
+ if (total <= maxVisible) {
217
+ for (let i = 1; i <= total; i++) pages.push(i);
218
+ return pages;
219
+ }
220
+ // Always show first, last, and surrounding pages
221
+ const pagesSet = new Set();
222
+ pagesSet.add(1);
223
+ pagesSet.add(total);
224
+ for (let i = Math.max(1, current - 1); i <= Math.min(total, current + 1); i++) {
225
+ pagesSet.add(i);
226
+ }
227
+ const sorted = Array.from(pagesSet).sort((a, b) => a - b);
228
+ // Add ellipsis markers
229
+ const result = [];
230
+ let prev = 0;
231
+ for (const p of sorted) {
232
+ if (p - prev > 1) result.push("...");
233
+ result.push(p);
234
+ prev = p;
235
+ }
236
+ return result;
237
+ }
238
+
239
+ function renderPagination(tableId, currentPage, totalPages, totalRows) {
240
+ if (totalPages <= 1) return "";
241
+ const start = (currentPage - 1) * ROWS_PER_PAGE + 1;
242
+ const end = Math.min(currentPage * ROWS_PER_PAGE, totalRows);
243
+ const pages = buildWindowedPages(currentPage, totalPages);
244
+ const buttons = pages
245
+ .map((p) => {
246
+ if (p === "...") return `<span class="pagination-ellipsis">…</span>`;
247
+ return `<button class="${p === currentPage ? "active" : ""}" onclick="window._acpGoToPage('${tableId}',${p})">${p}</button>`;
248
+ })
249
+ .join("");
250
+ return `
251
+ <div class="pagination">
252
+ <span class="pagination-info">Showing ${start}-${end} of ${totalRows}</span>
253
+ <div class="pagination-controls">
254
+ <button ${currentPage <= 1 ? "disabled" : ""} onclick="window._acpGoToPage('${tableId}',${currentPage - 1})">‹</button>
255
+ ${buttons}
256
+ <button ${currentPage >= totalPages ? "disabled" : ""} onclick="window._acpGoToPage('${tableId}',${currentPage + 1})">›</button>
257
+ </div>
258
+ </div>`;
259
+ }
260
+
261
+ window._acpGoToPage = function(tableId, page) {
262
+ window._acpPagination[tableId] = { page };
263
+ rerenderAll();
264
+ };
265
+
266
+ function renderTableWithPagination(tableId, columns, rows, emptyMessage = "No data right now.") {
267
+ if (!rows.length) {
268
+ return `<div class="empty-state">${emptyMessage}</div>`;
269
+ }
270
+ const state = window._acpPagination[tableId] || { page: 1 };
271
+ let { page } = state;
272
+ const totalPages = Math.ceil(rows.length / ROWS_PER_PAGE);
273
+ if (page < 1) page = 1;
274
+ if (page > totalPages) page = totalPages;
275
+ window._acpPagination[tableId] = { page };
276
+ const start = (page - 1) * ROWS_PER_PAGE;
277
+ const pageRows = rows.slice(start, start + ROWS_PER_PAGE);
278
+ const headers = columns.map((column) => `<th>${column.label}</th>`).join("");
279
+ const body = pageRows
280
+ .map((row) => {
281
+ const cells = columns
282
+ .map((column) => `<td>${column.render ? column.render(row) : row[column.key] ?? ""}</td>`)
283
+ .join("");
284
+ return `<tr>${cells}</tr>`;
285
+ })
286
+ .join("");
287
+ const paginationHtml = renderPagination(tableId, page, totalPages, rows.length);
288
+ return `<div class="table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${body}</tbody></table></div>${paginationHtml}`;
289
+ }
290
+
291
+ function renderAlerts(alerts) {
292
+ if (!alerts.length) {
293
+ return `<div class="empty-state">No active alerts for this profile.</div>`;
294
+ }
295
+ return `
296
+ <div class="alert-list">
297
+ ${alerts
298
+ .map(
299
+ (alert) => `
300
+ <article class="alert-card ${statusClass(alert.severity || "warn")}">
301
+ <div class="alert-header">
302
+ <div>
303
+ <h4>${alert.title}</h4>
304
+ <div class="muted mono">${alert.session || "n/a"} · ${alert.task_kind || "task"} ${alert.task_id || ""}</div>
305
+ </div>
306
+ <span class="badge warn">${alert.kind}</span>
307
+ </div>
308
+ <p>${alert.message}</p>
309
+ <div class="alert-meta">
310
+ <span>${alert.reset_at ? `Reset: ${formatCompactDate(alert.reset_at)}` : "Reset: n/a"}</span>
311
+ <span>${alert.updated_at ? `${relativeTime(alert.updated_at)} · ${formatCompactDate(alert.updated_at)}` : "updated n/a"}</span>
312
+ </div>
313
+ </article>
314
+ `,
315
+ )
316
+ .join("")}
317
+ </div>
318
+ `;
319
+ }
320
+
321
+ function renderCodexRotation(rotation) {
322
+ if (!rotation || !rotation.active_label) {
323
+ return `<div class="empty-state">Codex rotation data is not available yet for this Codex profile.</div>`;
324
+ }
325
+ const candidates = (rotation.candidate_labels || []).length ? rotation.candidate_labels.join(", ") : "n/a";
326
+ const ready = (rotation.ready_candidates || []).length ? rotation.ready_candidates.join(", ") : "none";
327
+ const nextRetry = rotation.next_retry_at
328
+ ? `${rotation.next_retry_label || "n/a"} · ${relativeTime(rotation.next_retry_at)}<div class="muted">${formatCompactDate(rotation.next_retry_at)}</div>`
329
+ : "n/a";
330
+ const lastSwitch = rotation.last_switch_label
331
+ ? `${rotation.last_switch_label}${rotation.last_switch_reason ? ` · ${rotation.last_switch_reason}` : ""}`
332
+ : "n/a";
333
+
334
+ return renderTableWithPagination(
335
+ "codex-rotation",
336
+ [
337
+ { label: "Current", render: () => `<div class="mono">${rotation.active_label}</div>` },
338
+ { label: "Decision", render: () => `<span class="status-pill ${statusClass(rotation.switch_decision || "unknown")}">${rotation.switch_decision || "unknown"}</span>` },
339
+ { label: "Candidates", render: () => `<div class="mono">${candidates}</div>` },
340
+ { label: "Ready now", render: () => `<div class="mono">${ready}</div>` },
341
+ { label: "Next retry", render: () => nextRetry },
342
+ { label: "Last switch", render: () => `<div class="mono">${lastSwitch}</div>` },
343
+ ],
344
+ [{}],
345
+ "No Codex rotation data for this profile.",
346
+ );
347
+ }
348
+
349
+ function renderProfile(profile) {
350
+ const providerBadges = [
351
+ profile.coding_worker ? `<span class="badge good">${profile.coding_worker}</span>` : "",
352
+ profile.provider_pool.backend
353
+ ? `<span class="badge">${profile.provider_pool.backend}: ${profile.provider_pool.model || "n/a"}</span>`
354
+ : "",
355
+ profile.provider_pool.name ? `<span class="badge">${profile.provider_pool.name}</span>` : "",
356
+ profile.provider_pool.pools_exhausted
357
+ ? `<span class="badge warn">pools exhausted</span>`
358
+ : "",
359
+ profile.provider_pool.last_reason ? `<span class="badge warn">${profile.provider_pool.last_reason}</span>` : "",
360
+ ]
361
+ .filter(Boolean)
362
+ .join("");
363
+
364
+ const summaryCards = [
365
+ ["Run sessions", profile.counts.active_runs],
366
+ ["Running", profile.counts.running_runs],
367
+ ["Recent completed", profile.counts.recent_history_runs || 0],
368
+ ["Implemented", profile.counts.implemented_runs],
369
+ ["Reported", profile.counts.reported_runs],
370
+ ["Blocked", profile.counts.blocked_runs],
371
+ ["Live controllers", profile.counts.live_resident_controllers],
372
+ ["Stale controllers", profile.counts.stale_resident_controllers],
373
+ ["Provider cooldowns", profile.counts.provider_cooldowns],
374
+ ["Pending GitHub writes", profile.counts.pending_github_writes || 0],
375
+ ["Failed GitHub writes", profile.counts.failed_github_writes || 0],
376
+ ["Alerts", profile.counts.alerts || 0],
377
+ ["Issue retries", profile.counts.active_retries],
378
+ ["Queued issues", profile.counts.queued_issues],
379
+ ["Scheduled", profile.counts.scheduled_issues],
380
+ ]
381
+ .map(
382
+ ([label, value]) => `
383
+ <article class="card">
384
+ <div class="stat-label">${label}</div>
385
+ <div class="stat-value">${value}</div>
386
+ </article>
387
+ `,
388
+ )
389
+ .join("");
390
+
391
+ const runsFilterState = window._acpRunsFilter || { search: "", status: "all" };
392
+ window._acpRunsFilter = runsFilterState;
393
+
394
+ const filteredRuns = profile.runs.filter((row) => {
395
+ if (runsFilterState.status !== "all" && row.status !== runsFilterState.status) return false;
396
+ if (runsFilterState.search) {
397
+ const q = runsFilterState.search.toLowerCase();
398
+ return (
399
+ (row.session || "").toLowerCase().includes(q) ||
400
+ (row.coding_worker || "").toLowerCase().includes(q) ||
401
+ (row.task_kind || "").toLowerCase().includes(q) ||
402
+ (row.task_id || "").toLowerCase().includes(q)
403
+ );
404
+ }
405
+ return true;
406
+ });
407
+
408
+ const runsTable = renderTableWithPagination(
409
+ `runs-${profile.id}`,
410
+ [
411
+ { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
412
+ { label: "Task", render: (row) => `${row.task_kind || "n/a"} ${row.task_id || ""}`.trim() },
413
+ { label: "Lifecycle", render: renderLifecycle },
414
+ { label: "Worker", key: "coding_worker" },
415
+ { label: "Provider", render: (row) => row.provider_model || "n/a" },
416
+ { label: "Result", render: renderResult },
417
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
418
+ ],
419
+ filteredRuns,
420
+ "No active run directories for this profile.",
421
+ );
422
+
423
+ const historyFilterState = window._acpHistoryFilter || { search: "", result: "all" };
424
+ window._acpHistoryFilter = historyFilterState;
425
+
426
+ const filteredHistory = (profile.recent_history || []).filter((row) => {
427
+ if (historyFilterState.result !== "all" && row.result_kind !== historyFilterState.result) return false;
428
+ if (historyFilterState.search) {
429
+ const q = historyFilterState.search.toLowerCase();
430
+ return (
431
+ (row.session || "").toLowerCase().includes(q) ||
432
+ (row.coding_worker || "").toLowerCase().includes(q) ||
433
+ (row.task_kind || "").toLowerCase().includes(q)
434
+ );
435
+ }
436
+ return true;
437
+ });
438
+
439
+ const recentHistoryTable = renderTableWithPagination(
440
+ `history-${profile.id}`,
441
+ [
442
+ { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
443
+ { label: "Task", render: (row) => `${row.task_kind || "n/a"} ${row.task_id || ""}`.trim() },
444
+ { label: "Lifecycle", render: renderLifecycle },
445
+ { label: "Worker", key: "coding_worker" },
446
+ { label: "Result", render: renderResult },
447
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
448
+ ],
449
+ filteredHistory,
450
+ "No recently archived runs.",
451
+ );
452
+
453
+ const controllerTable = renderTableWithPagination(
454
+ `controllers-${profile.id}`,
455
+ [
456
+ { label: "Issue", key: "issue_id" },
457
+ { label: "State", render: renderControllerState },
458
+ { label: "Lane", render: (row) => `${row.lane_kind || "n/a"} / ${row.lane_value || "n/a"}` },
459
+ { label: "Reason", render: (row) => row.reason || "n/a" },
460
+ { label: "Provider", render: (row) => `${row.provider_backend || "n/a"} ${row.provider_model || ""}`.trim() },
461
+ { label: "Failover", render: (row) => `${row.provider_failover_count} failovers / ${row.provider_switch_count} switches` },
462
+ { label: "Wait", render: (row) => `${row.provider_wait_count} waits / ${row.provider_wait_total_seconds}s` },
463
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
464
+ ],
465
+ profile.resident_controllers,
466
+ "No resident controllers recorded for this profile.",
467
+ );
468
+
469
+ const retryTable = renderTableWithPagination(
470
+ `retries-${profile.id}`,
471
+ [
472
+ { label: "Issue", key: "issue_id" },
473
+ { label: "Status", render: (row) => `<span class="status-pill ${row.ready ? "" : "waiting-provider"}">${row.ready ? "ready" : "retrying"}</span>` },
474
+ { label: "Reason", render: (row) => row.last_reason || "n/a" },
475
+ { label: "Attempts", key: "attempts" },
476
+ { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${formatCompactDate(row.next_attempt_at)}</div>` : "n/a" },
477
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
478
+ ],
479
+ profile.issue_retries || [],
480
+ "No issue retries recorded.",
481
+ );
482
+
483
+ const prRetryTable = renderTableWithPagination(
484
+ `pr-retries-${profile.id}`,
485
+ [
486
+ { label: "PR", key: "pr_number" },
487
+ { label: "Status", render: (row) => `<span class="status-pill ${row.ready ? "" : "waiting-provider"}">${row.ready ? "ready" : "retrying"}</span>` },
488
+ { label: "Reason", render: (row) => row.last_reason || "n/a" },
489
+ { label: "Attempts", key: "attempts" },
490
+ { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${formatCompactDate(row.next_attempt_at)}</div>` : "n/a" },
491
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
492
+ ],
493
+ profile.pr_retries || [],
494
+ "No PR retries recorded.",
495
+ );
496
+
497
+ const workerTable = renderTableWithPagination(
498
+ `workers-${profile.id}`,
499
+ [
500
+ { label: "Key", render: (row) => `<div class="mono">${row.key}</div>` },
501
+ { label: "Scope", key: "scope" },
502
+ { label: "Worker", key: "coding_worker" },
503
+ { label: "Issue", render: (row) => row.issue_id || "n/a" },
504
+ { label: "Lane", render: (row) => `${row.resident_lane_kind || "n/a"} / ${row.resident_lane_value || "n/a"}` },
505
+ { label: "Tasks", key: "task_count" },
506
+ { label: "Last status", render: (row) => row.last_status || "n/a" },
507
+ { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${formatCompactDate(row.last_started_at)}</div>` : "n/a" },
508
+ ],
509
+ profile.resident_workers,
510
+ "No resident worker metadata yet.",
511
+ );
512
+
513
+ const cooldownTable = renderTableWithPagination(
514
+ `cooldowns-${profile.id}`,
515
+ [
516
+ { label: "Provider key", render: (row) => `<div class="mono">${row.provider_key}</div>` },
517
+ { label: "State", render: (row) => `<span class="status-pill ${row.active ? "waiting-provider" : ""}">${row.active ? "cooldown" : "expired"}</span>` },
518
+ { label: "Reason", render: (row) => row.last_reason || "n/a" },
519
+ { label: "Attempts", key: "attempts" },
520
+ { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${formatCompactDate(row.next_attempt_at)}</div>` : "n/a" },
521
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
522
+ ],
523
+ profile.provider_cooldowns,
524
+ "No provider cooldowns recorded.",
525
+ );
526
+
527
+ const scheduledTable = renderTableWithPagination(
528
+ `scheduled-${profile.id}`,
529
+ [
530
+ { label: "Issue", key: "issue_id" },
531
+ { label: "Interval", render: (row) => `${row.interval_seconds}s` },
532
+ { label: "Next due", render: (row) => row.next_due_at ? `${relativeTime(row.next_due_at)}<div class="muted">${formatCompactDate(row.next_due_at)}</div>` : "n/a" },
533
+ { label: "Time Remaining", render: (row) => row.next_due_at ? timeRemaining(row.next_due_at) : "n/a" },
534
+ { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${formatCompactDate(row.last_started_at)}</div>` : "n/a" },
535
+ ],
536
+ profile.scheduled_issues,
537
+ "No scheduled issue state recorded.",
538
+ );
539
+
540
+ const queueTable = renderTableWithPagination(
541
+ `queue-${profile.id}`,
542
+ [
543
+ { label: "Issue", key: "issue_id" },
544
+ { label: "Session", render: (row) => row.session ? `<div class="mono">${row.session}</div>` : "n/a" },
545
+ { label: "Queued by", key: "queued_by" },
546
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
547
+ ],
548
+ profile.issue_queue.pending,
549
+ "No pending leased issues.",
550
+ );
551
+
552
+ const claimsTable = renderTableWithPagination(
553
+ `claims-${profile.id}`,
554
+ [
555
+ { label: "Issue", key: "issue_id" },
556
+ { label: "Session", render: (row) => row.session ? `<div class="mono">${row.session}</div>` : "n/a" },
557
+ { label: "Claimed by", key: "claimer" },
558
+ { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${formatCompactDate(row.updated_at)}</div>` : "n/a" },
559
+ ],
560
+ profile.issue_queue.claims || [],
561
+ "No claimed issues.",
562
+ );
563
+
564
+ const githubOutbox = profile.github_outbox || { counts: {}, pending: [] };
565
+ const githubOutboxTable = renderTableWithPagination(
566
+ `github-outbox-${profile.id}`,
567
+ [
568
+ { label: "Type", render: (row) => row.type || "n/a" },
569
+ { label: "Target", render: (row) => `${row.kind || row.type || "write"} #${row.number || "?"}` },
570
+ {
571
+ label: "Payload",
572
+ render: (row) => {
573
+ if (row.type === "labels") {
574
+ return `+${row.add_count || 0} / -${row.remove_count || 0}`;
575
+ }
576
+ return row.body_preview || "n/a";
577
+ },
578
+ },
579
+ { label: "Created", render: (row) => row.created_at ? `${relativeTime(row.created_at)}<div class="muted">${formatCompactDate(row.created_at)}</div>` : "n/a" },
580
+ ],
581
+ githubOutbox.pending || [],
582
+ "No pending GitHub write intents.",
583
+ );
584
+
585
+ const codexRotationPanel =
586
+ profile.coding_worker === "codex"
587
+ ? `
588
+ <section class="panel">
589
+ <h3>Codex Rotation</h3>
590
+ <p class="panel-subtitle">Shows the active Codex label, candidate labels, and whether failover is ready or deferred.</p>
591
+ ${renderCodexRotation(profile.codex_rotation)}
592
+ </section>
593
+ `
594
+ : "";
595
+
596
+ const runsFilterBar = `
597
+ <div class="filter-bar">
598
+ <input type="text" class="filter-search" placeholder="Search runs..." value="${runsFilterState.search}"
599
+ oninput="window._acpRunsFilter.search=this.value; rerenderAll();" />
600
+ <button class="filter-btn ${runsFilterState.status === 'all' ? 'active' : ''}" onclick="window._acpRunsFilter.status='all'; rerenderAll();">All</button>
601
+ <button class="filter-btn ${runsFilterState.status === 'RUNNING' ? 'active' : ''}" onclick="window._acpRunsFilter.status='RUNNING'; rerenderAll();">Running</button>
602
+ <button class="filter-btn ${runsFilterState.status === 'SUCCEEDED' ? 'active' : ''}" onclick="window._acpRunsFilter.status='SUCCEEDED'; rerenderAll();">Completed</button>
603
+ <button class="filter-btn ${runsFilterState.status === 'FAILED' ? 'active' : ''}" onclick="window._acpRunsFilter.status='FAILED'; rerenderAll();">Failed</button>
604
+ </div>
605
+ `;
606
+
607
+ const historyFilterBar = `
608
+ <div class="filter-bar">
609
+ <input type="text" class="filter-search" placeholder="Search history..." value="${historyFilterState.search}"
610
+ oninput="window._acpHistoryFilter.search=this.value; rerenderAll();" />
611
+ <button class="filter-btn ${historyFilterState.result === 'all' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='all'; rerenderAll();">All</button>
612
+ <button class="filter-btn ${historyFilterState.result === 'implemented' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='implemented'; rerenderAll();">Implemented</button>
613
+ <button class="filter-btn ${historyFilterState.result === 'reported' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='reported'; rerenderAll();">Reported</button>
614
+ <button class="filter-btn ${historyFilterState.result === 'blocked' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='blocked'; rerenderAll();">Blocked</button>
615
+ </div>
616
+ `;
617
+
618
+ return `
619
+ <article class="profile">
620
+ <header class="profile-header">
621
+ <div>
622
+ <div class="profile-title">
623
+ <h2>${profile.id}</h2>
624
+ <span class="badge">${profile.repo_slug || "repo slug unavailable"}</span>
625
+ </div>
626
+ <div class="profile-subtitle mono">${profile.runs_root}</div>
627
+ </div>
628
+ <div class="badge-row">${providerBadges}</div>
629
+ </header>
630
+ <section class="overview">${summaryCards}</section>
631
+ <section class="profile-grid">
632
+ <section class="panel">
633
+ <h3>Host Alerts</h3>
634
+ <p class="panel-subtitle">High-signal operational blockers surfaced from active run logs and comment artifacts.</p>
635
+ ${renderAlerts(profile.alerts || [])}
636
+ </section>
637
+ <section class="panel">
638
+ <h3>Active Runs</h3>
639
+ <p class="panel-subtitle">Lifecycle shows technical session completion. Result shows what the run achieved: implemented, reported, or blocked.</p>
640
+ ${runsFilterBar}
641
+ ${runsTable}
642
+ </section>
643
+ <section class="panel">
644
+ <h3>Recent Completed Runs</h3>
645
+ <p class="panel-subtitle">Recently archived runs so they do not disappear from the dashboard immediately after completion.</p>
646
+ ${historyFilterBar}
647
+ ${recentHistoryTable}
648
+ </section>
649
+ <section class="panel">
650
+ <h3>Resident Controllers</h3>
651
+ <p class="panel-subtitle">Includes provider wait and failover telemetry. Stale controllers show a warning.</p>
652
+ ${controllerTable}
653
+ </section>
654
+ ${codexRotationPanel}
655
+ <section class="panel half">
656
+ <h3>Issue Retries</h3>
657
+ ${retryTable}
658
+ </section>
659
+ <section class="panel half">
660
+ <h3>PR Retries</h3>
661
+ ${prRetryTable}
662
+ </section>
663
+ <section class="panel">
664
+ <h3>Resident Worker Metadata</h3>
665
+ ${workerTable}
666
+ </section>
667
+ <section class="panel">
668
+ <h3>Troubleshooting</h3>
669
+ <p class="panel-subtitle">Run diagnostics or debugging tools against this live profile.</p>
670
+ <div class="action-bar">
671
+ <button class="action-btn" onclick="runDoctor('${profile.id}')">🔧 Run Doctor</button>
672
+ <button class="action-btn" onclick="exportProfile('${profile.id}')">📤 Export</button>
673
+ <button class="action-btn" onclick="document.getElementById('import-file-${profile.id}').click()">📥 Import</button>
674
+ <input type="file" id="import-file-${profile.id}" style="display:none" accept=".json" onchange="importProfile('${profile.id}', this)">
675
+ <span id="doctor-status-${profile.id}"></span>
676
+ </div>
677
+ <pre id="doctor-output-${profile.id}" class="doctor-output" style="display:none;"></pre>
678
+ </section>
679
+ <section class="panel half">
680
+ <h3>Provider Cooldowns</h3>
681
+ ${cooldownTable}
682
+ </section>
683
+ <section class="panel half">
684
+ <h3>Scheduled Issues</h3>
685
+ ${scheduledTable}
686
+ </section>
687
+ <section class="panel half">
688
+ <h3>Pending Issue Queue</h3>
689
+ ${queueTable}
690
+ </section>
691
+ <section class="panel half">
692
+ <h3>Claimed Issues</h3>
693
+ ${claimsTable}
694
+ </section>
695
+ <section class="panel">
696
+ <h3>GitHub Outbox</h3>
697
+ <p class="panel-subtitle">Local write intents queued while ACP defers or retries GitHub sync. Pending ${githubOutbox.counts?.pending || 0}, sent ${githubOutbox.counts?.sent || 0}, failed ${githubOutbox.counts?.failed || 0}.</p>
698
+ ${githubOutboxTable}
699
+ </section>
700
+ </section>
701
+ </article>
702
+ `;
703
+ }
704
+
705
+ async function maybeNotifyAlerts(snapshot) {
706
+ const alerts = (snapshot.alerts || []).filter((alert) => alert && alert.id);
707
+ if (!alerts.length || typeof window.Notification === "undefined") return;
708
+
709
+ if (window.Notification.permission === "default" && !notificationPermissionRequested) {
710
+ notificationPermissionRequested = true;
711
+ try {
712
+ await window.Notification.requestPermission();
713
+ } catch (_error) {
714
+ return;
715
+ }
716
+ }
717
+
718
+ if (window.Notification.permission !== "granted") return;
719
+
720
+ for (const alert of alerts) {
721
+ if (seenAlertIds.has(alert.id)) continue;
722
+ seenAlertIds.add(alert.id);
723
+ const bodyParts = [];
724
+ if (alert.session) bodyParts.push(alert.session);
725
+ if (alert.reset_at) bodyParts.push(`reset ${alert.reset_at}`);
726
+ if (alert.message) bodyParts.push(alert.message);
727
+ new window.Notification(alert.title || "ACP alert", {
728
+ body: bodyParts.join(" · ").slice(0, 240),
729
+ tag: alert.id,
730
+ });
731
+ }
732
+ }
733
+
734
+ async function loadSnapshot() {
735
+ refreshButton.disabled = true;
736
+ try {
737
+ const response = await fetch("./api/snapshot.json", { cache: "no-store" });
738
+ if (!response.ok) {
739
+ throw new Error(`Snapshot request failed with ${response.status}`);
740
+ }
741
+ const snapshot = await response.json();
742
+ window._acpSnapshot = snapshot;
743
+ renderFromSnapshot(snapshot);
744
+ await maybeNotifyAlerts(snapshot);
745
+ } catch (error) {
746
+ generatedAtNode.textContent = `Snapshot load failed: ${error.message}`;
747
+ profilesNode.innerHTML = `<article class="profile"><div class="empty-state">${error.message}</div></article>`;
748
+ } finally {
749
+ refreshButton.disabled = false;
750
+ }
751
+ }
752
+
753
+ function renderFromSnapshot(snapshot) {
754
+ generatedAtNode.textContent = `Snapshot: ${formatCompactDate(snapshot.generated_at)}`;
755
+ renderOverview(snapshot);
756
+ profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
757
+ }
758
+
759
+ function rerenderAll() {
760
+ const snapshot = window._acpSnapshot;
761
+ if (!snapshot) return;
762
+ renderFromSnapshot(snapshot);
763
+ }
764
+
765
+ async function runDoctor(profileId) {
766
+ const statusEl = document.getElementById(`doctor-status-${profileId}`);
767
+ const outputEl = document.getElementById(`doctor-output-${profileId}`);
768
+ if (statusEl) statusEl.textContent = "Running...";
769
+ if (outputEl) {
770
+ outputEl.style.display = "none";
771
+ outputEl.textContent = "";
772
+ }
773
+ try {
774
+ const response = await fetch(`/api/doctor?profile_id=${encodeURIComponent(profileId)}`, { cache: "no-store" });
775
+ const data = await response.json();
776
+ if (statusEl) statusEl.textContent = response.ok ? "Done" : `Error: ${data.error || response.status}`;
777
+ if (outputEl) {
778
+ outputEl.style.display = "block";
779
+ outputEl.textContent = data.output || data.error || "No output";
780
+ }
781
+ } catch (error) {
782
+ if (statusEl) statusEl.textContent = `Error: ${error.message}`;
783
+ }
784
+ }
785
+
786
+ async function exportProfile(profileId) {
787
+ try {
788
+ const response = await fetch(`/api/profile/export?profile_id=${encodeURIComponent(profileId)}`, { cache: "no-store" });
789
+ if (!response.ok) {
790
+ const data = await response.json();
791
+ alert(`Export failed: ${data.error || response.status}`);
792
+ return;
793
+ }
794
+ const data = await response.json();
795
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
796
+ const url = URL.createObjectURL(blob);
797
+ const a = document.createElement("a");
798
+ a.href = url;
799
+ a.download = `acp-profile-${profileId}.json`;
800
+ document.body.appendChild(a);
801
+ a.click();
802
+ document.body.removeChild(a);
803
+ URL.revokeObjectURL(url);
804
+ } catch (error) {
805
+ alert(`Export failed: ${error.message}`);
806
+ }
807
+ }
808
+
809
+ async function importProfile(profileId, inputEl) {
810
+ const file = inputEl.files[0];
811
+ if (!file) return;
812
+
813
+ try {
814
+ const text = await file.text();
815
+ const data = JSON.parse(text);
816
+
817
+ if (!data.profile_id || !data.config) {
818
+ alert("Invalid profile file: missing profile_id or config");
819
+ return;
820
+ }
821
+
822
+ const response = await fetch("/api/profile/import", {
823
+ method: "POST",
824
+ headers: { "Content-Type": "application/json" },
825
+ body: JSON.stringify(data),
826
+ });
827
+
828
+ const result = await response.json();
829
+ if (response.ok) {
830
+ alert(`Profile ${profileId} imported successfully!`);
831
+ setTimeout(() => window.location.reload(), 1000);
832
+ } else {
833
+ alert(`Import failed: ${result.error || response.status}`);
834
+ }
835
+ } catch (error) {
836
+ alert(`Import failed: ${error.message}`);
837
+ } finally {
838
+ inputEl.value = "";
839
+ }
840
+ }
841
+
842
+ refreshButton.addEventListener("click", () => {
843
+ void loadSnapshot();
844
+ });
845
+
846
+ initializeTheme();
847
+ void loadSnapshot();
848
+ setupSearch();
849
+
850
+ // WebSocket live updates
851
+ let wsReconnectDelay = 1000;
852
+ let wsConnectionActive = false;
853
+ let wsReconnectAttempts = 0;
854
+ const MAX_RECONNECT_ATTEMPTS = 10;
855
+
856
+ function updateConnectionStatus(connected) {
857
+ const statusEl = document.getElementById('ws-status');
858
+ if (!statusEl) return;
859
+ if (connected) {
860
+ statusEl.textContent = '● Live';
861
+ statusEl.className = 'connection-status connected';
862
+ wsReconnectAttempts = 0;
863
+ } else {
864
+ statusEl.textContent = `● Reconnecting (${wsReconnectAttempts})`;
865
+ statusEl.className = 'connection-status disconnected';
866
+ }
867
+ }
868
+
869
+ function connectWebSocket() {
870
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
871
+ const wsUrl = `${protocol}//${location.host}/ws`;
872
+ const ws = new WebSocket(wsUrl);
873
+
874
+ ws.onopen = () => {
875
+ if (!autoRefreshEnabled) {
876
+ ws.close();
877
+ return;
878
+ }
879
+ wsReconnectDelay = 1000;
880
+ wsConnectionActive = true;
881
+ wsReconnectAttempts++;
882
+ updateConnectionStatus(true);
883
+ console.log("ACP Dashboard: WebSocket connected");
884
+ // Update last updated timestamp
885
+ const lastUpdated = document.getElementById('last-updated');
886
+ if (lastUpdated) {
887
+ lastUpdated.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
888
+ }
889
+ };
890
+
891
+ ws.onmessage = async (event) => {
892
+ try {
893
+ let data = event.data;
894
+ // Handle Blob (binary) or string
895
+ if (data instanceof Blob) {
896
+ data = await data.text();
897
+ }
898
+ const snapshot = JSON.parse(data);
899
+ window._acpSnapshot = snapshot;
900
+ renderFromSnapshot(snapshot);
901
+ maybeNotifyAlerts(snapshot);
902
+ } catch (error) {
903
+ console.error("ACP Dashboard: Failed to parse WebSocket message", error);
904
+ }
905
+ };
906
+
907
+ ws.onclose = () => {
908
+ wsConnectionActive = false;
909
+ wsReconnectAttempts++;
910
+ updateConnectionStatus(false);
911
+ console.log(`ACP Dashboard: WebSocket disconnected, reconnecting in ${wsReconnectDelay}ms`);
912
+ if (wsReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
913
+ console.error(`ACP Dashboard: Max reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
914
+ return;
915
+ }
916
+ setTimeout(connectWebSocket, wsReconnectDelay);
917
+ wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000);
918
+ };
919
+
920
+ ws.onerror = (error) => {
921
+ console.error("ACP Dashboard: WebSocket error", error);
922
+ };
923
+ }
924
+
925
+ connectWebSocket();
926
+
927
+ // Scheduler Status
928
+ let schedulerStatus = null;
929
+ let autoRefreshEnabled = true;
930
+
931
+ function toggleAutoRefresh() {
932
+ autoRefreshEnabled = !autoRefreshEnabled;
933
+ const toggleBtn = document.getElementById('refresh-toggle');
934
+ if (toggleBtn) {
935
+ toggleBtn.textContent = autoRefreshEnabled ? 'Disable Auto-Refresh' : 'Enable Auto-Refresh';
936
+ toggleBtn.className = autoRefreshEnabled ? 'btn btn-primary' : 'btn btn-secondary';
937
+ }
938
+ if (!autoRefreshEnabled && window._wsConnection) {
939
+ window._wsConnection.close();
940
+ updateConnectionStatus(false);
941
+ } else if (autoRefreshEnabled) {
942
+ connectWebSocket();
943
+ }
944
+ }
945
+ let dashboardSearchTerm = '';
946
+
947
+ function filterTableData(data, searchTerm) {
948
+ if (!searchTerm) return data;
949
+ const term = searchTerm.toLowerCase();
950
+ return data.filter(row => {
951
+ return Object.values(row).some(value =>
952
+ String(value).toLowerCase().includes(term)
953
+ );
954
+ });
955
+ }
956
+
957
+ function setupSearch() {
958
+ // Create search input if it doesn't exist
959
+ let searchInput = document.getElementById('dashboard-search');
960
+ if (!searchInput) {
961
+ searchInput = document.createElement('input');
962
+ searchInput.id = 'dashboard-search';
963
+ searchInput.type = 'text';
964
+ searchInput.placeholder = 'Search tables...';
965
+ searchInput.style.cssText = 'padding: 6px 12px; margin: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;';
966
+ const header = document.querySelector('header') || document.querySelector('#dashboard-header');
967
+ if (header) {
968
+ header.appendChild(searchInput);
969
+ }
970
+ }
971
+
972
+ // Create refresh toggle button
973
+ let toggleBtn = document.getElementById('refresh-toggle');
974
+ if (!toggleBtn) {
975
+ toggleBtn = document.createElement('button');
976
+ toggleBtn.id = 'refresh-toggle';
977
+ toggleBtn.className = 'btn btn-primary';
978
+ toggleBtn.style.cssText = 'padding: 6px 12px; margin: 8px;';
979
+ toggleBtn.textContent = 'Disable Auto-Refresh';
980
+ toggleBtn.onclick = toggleAutoRefresh;
981
+ if (header) {
982
+ header.appendChild(toggleBtn);
983
+ }
984
+ }
985
+
986
+ // Create export CSV button
987
+ let csvBtn = document.getElementById('export-csv');
988
+ if (!csvBtn) {
989
+ csvBtn = document.createElement('button');
990
+ csvBtn.id = 'export-csv';
991
+ csvBtn.className = 'btn btn-secondary';
992
+ csvBtn.style.cssText = 'padding: 6px 12px; margin: 8px;';
993
+ csvBtn.textContent = 'Export CSV';
994
+ csvBtn.onclick = exportToCSV;
995
+ if (header) {
996
+ header.appendChild(csvBtn);
997
+ }
998
+ }
999
+
1000
+ searchInput.addEventListener('input', (e) => {
1001
+ dashboardSearchTerm = e.target.value;
1002
+ if (window._acpSnapshot) {
1003
+ renderFromSnapshot(window._acpSnapshot);
1004
+ }
1005
+ });
1006
+
1007
+ // Create print button
1008
+ let printBtn = document.getElementById('print-btn');
1009
+ if (!printBtn) {
1010
+ printBtn = document.createElement('button');
1011
+ printBtn.id = 'print-btn';
1012
+ printBtn.className = 'btn btn-secondary';
1013
+ printBtn.style.cssText = 'padding: 6px 12px; margin: 8px;';
1014
+ printBtn.textContent = 'Print';
1015
+ printBtn.onclick = () => window.print();
1016
+ if (header) {
1017
+ header.appendChild(printBtn);
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ function exportToCSV() {
1023
+ if (!window._acpSnapshot) {
1024
+ alert('No snapshot data available yet.');
1025
+ return;
1026
+ }
1027
+
1028
+ let csv = 'Profile,Session,Task,Lifecycle,Worker,Provider,Result,Updated\n';
1029
+ const snapshot = window._acpSnapshot;
1030
+
1031
+ if (snapshot.profiles) {
1032
+ snapshot.profiles.forEach(profile => {
1033
+ const runs = filterTableData(profile.runs || [], dashboardSearchTerm);
1034
+ if (runs.length > 0) {
1035
+ csv += `\n${profile.id} - Runs\n`;
1036
+ runs.forEach(row => {
1037
+ csv += `"${row.session || 'n/a'}","${row.task_kind || 'n/a'} ${row.task_id || ''}","${row.lifecycle || 'n/a'}","${row.coding_worker || 'n/a'}","${row.provider_model || 'n/a'}","${row.result || 'n/a'}","${row.updated_at || 'n/a'}"\n`;
1038
+ });
1039
+ }
1040
+ });
1041
+ }
1042
+
1043
+ if (!csv.includes('\n', 1)) {
1044
+ alert('No data to export.');
1045
+ return;
1046
+ }
1047
+
1048
+ const BOM = '\uFEFF';
1049
+ const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8' });
1050
+ const url = URL.createObjectURL(blob);
1051
+ const link = document.createElement('a');
1052
+ link.href = url;
1053
+ link.download = `acp-dashboard-${new Date().toISOString().slice(0, 10)}.csv`;
1054
+ link.click();
1055
+ URL.revokeObjectURL(url);
1056
+ }
1057
+
1058
+ function exportSnapshot() {
1059
+ if (!window._acpSnapshot) {
1060
+ alert('No snapshot data available yet.');
1061
+ return;
1062
+ }
1063
+ const dataStr = JSON.stringify(window._acpSnapshot, null, 2);
1064
+ const dataBlob = new Blob([dataStr], { type: 'application/json' });
1065
+ const url = URL.createObjectURL(dataBlob);
1066
+ const link = document.createElement('a');
1067
+ link.href = url;
1068
+ link.download = `acp-snapshot-${new Date().toISOString().slice(0, 10)}.json`;
1069
+ link.click();
1070
+ URL.revokeObjectURL(url);
1071
+ }
1072
+
1073
+ async function fetchSchedulerStatus() {
1074
+ try {
1075
+ const response = await fetch("/api/scheduler-status", { cache: "no-store" });
1076
+ if (response.ok) {
1077
+ schedulerStatus = await response.json();
1078
+ renderSchedulerStatus();
1079
+ }
1080
+ } catch (error) {
1081
+ console.error("ACP Dashboard: Failed to fetch scheduler status", error);
1082
+ }
1083
+ }
1084
+
1085
+ function renderSchedulerStatus() {
1086
+ const container = document.getElementById("scheduler-status");
1087
+ if (!container) return;
1088
+ if (!schedulerStatus) {
1089
+ container.innerHTML = `<article class="profile"><h3>Scheduler Status</h3><p class="panel-subtitle">Loading scheduler status...</p></article>`;
1090
+ return;
1091
+ }
1092
+ const { is_running, pid, last_log_lines, message } = schedulerStatus;
1093
+ const statusPill = is_running
1094
+ ? `<span class="status-pill RUNNING">Running (PID: ${pid})</span>`
1095
+ : `<span class="status-pill STOPPED">Stopped</span>`;
1096
+ const logHtml = last_log_lines && last_log_lines.length
1097
+ ? `<pre class="mono" style="background:var(--panel-strong); padding:8px; border-radius:4px; font-size:11px; max-height:120px; overflow-y:auto;">${last_log_lines.join("\n")}</pre>`
1098
+ : `<p class="muted">No log data available.</p>`;
1099
+ container.innerHTML = `
1100
+ <article class="profile">
1101
+ <header class="profile-header">
1102
+ <div>
1103
+ <div class="profile-title">
1104
+ <h2>Scheduler Status</h2>
1105
+ ${statusPill}
1106
+ </div>
1107
+ <p class="panel-subtitle">${message || "Scheduler status from real state"}</p>
1108
+ </div>
1109
+ </header>
1110
+ <section class="panel">
1111
+ <h3>Last Log Lines</h3>
1112
+ ${logHtml}
1113
+ </section>
1114
+ </article>
1115
+ `;
1116
+ }
1117
+
1118
+ // Initial load
1119
+ fetchSchedulerStatus();
1120
+ setInterval(fetchSchedulerStatus, 30000); // Refresh every 30s