agent-control-plane 0.4.9 → 0.6.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.
Files changed (80) hide show
  1. package/README.md +72 -9
  2. package/npm/bin/agent-control-plane.js +1 -1
  3. package/package.json +39 -33
  4. package/tools/bin/debug-session.sh +106 -0
  5. package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
  6. package/tools/bin/flow-runtime-doctor.sh +5 -1
  7. package/tools/bin/install-project-systemd.sh +255 -0
  8. package/tools/bin/project-runtimectl.sh +45 -0
  9. package/tools/bin/project-systemd-bootstrap.sh +74 -0
  10. package/tools/bin/uninstall-project-systemd.sh +87 -0
  11. package/tools/dashboard/app.js +198 -5
  12. package/tools/dashboard/issue_queue_state.py +101 -0
  13. package/tools/dashboard/server.py +123 -1
  14. package/tools/dashboard/styles.css +526 -455
  15. package/tools/bin/agent-cleanup-worktree +0 -247
  16. package/tools/bin/agent-github-update-labels +0 -105
  17. package/tools/bin/agent-init-worktree +0 -216
  18. package/tools/bin/agent-project-archive-run +0 -52
  19. package/tools/bin/agent-project-capture-worker +0 -46
  20. package/tools/bin/agent-project-catch-up-issue-pr-links +0 -118
  21. package/tools/bin/agent-project-catch-up-merged-prs +0 -195
  22. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +0 -123
  23. package/tools/bin/agent-project-cleanup-session +0 -513
  24. package/tools/bin/agent-project-detached-launch +0 -127
  25. package/tools/bin/agent-project-heartbeat-loop +0 -1029
  26. package/tools/bin/agent-project-open-issue-worktree +0 -89
  27. package/tools/bin/agent-project-open-pr-worktree +0 -80
  28. package/tools/bin/agent-project-publish-issue-pr +0 -468
  29. package/tools/bin/agent-project-reconcile-issue-session +0 -1409
  30. package/tools/bin/agent-project-reconcile-pr-session +0 -1288
  31. package/tools/bin/agent-project-retry-state +0 -158
  32. package/tools/bin/agent-project-run-claude-session +0 -805
  33. package/tools/bin/agent-project-run-codex-resilient +0 -963
  34. package/tools/bin/agent-project-run-codex-session +0 -435
  35. package/tools/bin/agent-project-run-kilo-session +0 -369
  36. package/tools/bin/agent-project-run-ollama-session +0 -658
  37. package/tools/bin/agent-project-run-openclaw-session +0 -1309
  38. package/tools/bin/agent-project-run-opencode-session +0 -377
  39. package/tools/bin/agent-project-run-pi-session +0 -479
  40. package/tools/bin/agent-project-sync-anchor-repo +0 -139
  41. package/tools/bin/agent-project-sync-source-repo-main +0 -163
  42. package/tools/bin/agent-project-worker-status +0 -188
  43. package/tools/bin/branch-verification-guard.sh +0 -364
  44. package/tools/bin/capture-worker.sh +0 -18
  45. package/tools/bin/cleanup-worktree.sh +0 -52
  46. package/tools/bin/codex-quota +0 -31
  47. package/tools/bin/create-follow-up-issue.sh +0 -114
  48. package/tools/bin/dashboard-launchd-bootstrap.sh +0 -50
  49. package/tools/bin/issue-publish-localization-guard.sh +0 -142
  50. package/tools/bin/issue-publish-scope-guard.sh +0 -242
  51. package/tools/bin/issue-requires-local-workspace-install.sh +0 -31
  52. package/tools/bin/issue-resource-class.sh +0 -12
  53. package/tools/bin/kick-scheduler.sh +0 -75
  54. package/tools/bin/label-follow-up-issues.sh +0 -14
  55. package/tools/bin/new-pr-worktree.sh +0 -50
  56. package/tools/bin/new-worktree.sh +0 -49
  57. package/tools/bin/pr-risk.sh +0 -12
  58. package/tools/bin/prepare-worktree.sh +0 -142
  59. package/tools/bin/provider-cooldown-state.sh +0 -204
  60. package/tools/bin/publish-issue-worker.sh +0 -31
  61. package/tools/bin/reconcile-bootstrap-lib.sh +0 -113
  62. package/tools/bin/reconcile-issue-worker.sh +0 -34
  63. package/tools/bin/reconcile-pr-worker.sh +0 -34
  64. package/tools/bin/record-verification.sh +0 -71
  65. package/tools/bin/render-flow-config.sh +0 -98
  66. package/tools/bin/resident-issue-controller-lib.sh +0 -448
  67. package/tools/bin/retry-state.sh +0 -31
  68. package/tools/bin/reuse-issue-worktree.sh +0 -121
  69. package/tools/bin/run-codex-bypass.sh +0 -3
  70. package/tools/bin/run-codex-safe.sh +0 -3
  71. package/tools/bin/run-codex-task.sh +0 -280
  72. package/tools/bin/serve-dashboard.sh +0 -5
  73. package/tools/bin/start-issue-worker.sh +0 -943
  74. package/tools/bin/start-pr-fix-worker.sh +0 -528
  75. package/tools/bin/start-pr-merge-repair-worker.sh +0 -8
  76. package/tools/bin/start-pr-review-worker.sh +0 -261
  77. package/tools/bin/start-resident-issue-loop.sh +0 -499
  78. package/tools/bin/update-github-labels.sh +0 -14
  79. package/tools/bin/worker-status.sh +0 -19
  80. package/tools/bin/workflow-catalog.sh +0 -77
@@ -72,6 +72,35 @@ function relativeTime(input) {
72
72
  return seconds >= 0 ? `${absolute}s ago` : `in ${absolute}s`;
73
73
  }
74
74
 
75
+ function formatDuration(seconds) {
76
+ if (!seconds && seconds !== 0) return "n/a";
77
+ const absSeconds = Math.abs(seconds);
78
+ const parts = [];
79
+ const units = [
80
+ [86400, "d"],
81
+ [3600, "h"],
82
+ [60, "m"],
83
+ [1, "s"],
84
+ ];
85
+ for (const [unitSeconds, label] of units) {
86
+ if (absSeconds >= unitSeconds) {
87
+ const amount = Math.floor(absSeconds / unitSeconds);
88
+ parts.push(`${amount}${label}`);
89
+ seconds -= amount * unitSeconds;
90
+ }
91
+ }
92
+ return parts.slice(0, 2).join(" ") || "0s";
93
+ }
94
+
95
+ function timeRemaining(isoString) {
96
+ if (!isoString) return "n/a";
97
+ const next = new Date(isoString);
98
+ if (Number.isNaN(next.getTime())) return isoString;
99
+ const diffSeconds = Math.round((next.getTime() - Date.now()) / 1000);
100
+ if (diffSeconds <= 0) return "ready now";
101
+ return formatDuration(diffSeconds);
102
+ }
103
+
75
104
  function statusClass(status) {
76
105
  if (!status) return "";
77
106
  return status.replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -129,6 +158,8 @@ function renderOverview(snapshot) {
129
158
  ["Pending GitHub Writes", totals.pendingGithubWrites],
130
159
  ["Alerts", totals.alerts],
131
160
  ["Queued Issues", totals.queue],
161
+ ["Retries", totals.retries || 0],
162
+ ["Blockers", totals.blockers || 0],
132
163
  ]
133
164
  .map(
134
165
  ([label, value]) => `
@@ -256,6 +287,23 @@ function renderProfile(profile) {
256
287
  )
257
288
  .join("");
258
289
 
290
+ const runsFilterState = window._acpRunsFilter || { search: "", status: "all" };
291
+ window._acpRunsFilter = runsFilterState;
292
+
293
+ const filteredRuns = profile.runs.filter((row) => {
294
+ if (runsFilterState.status !== "all" && row.status !== runsFilterState.status) return false;
295
+ if (runsFilterState.search) {
296
+ const q = runsFilterState.search.toLowerCase();
297
+ return (
298
+ (row.session || "").toLowerCase().includes(q) ||
299
+ (row.coding_worker || "").toLowerCase().includes(q) ||
300
+ (row.task_kind || "").toLowerCase().includes(q) ||
301
+ (row.task_id || "").toLowerCase().includes(q)
302
+ );
303
+ }
304
+ return true;
305
+ });
306
+
259
307
  const runsTable = renderTable(
260
308
  [
261
309
  { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
@@ -266,10 +314,26 @@ function renderProfile(profile) {
266
314
  { label: "Result", render: renderResult },
267
315
  { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
268
316
  ],
269
- profile.runs,
317
+ filteredRuns,
270
318
  "No active run directories for this profile.",
271
319
  );
272
320
 
321
+ const historyFilterState = window._acpHistoryFilter || { search: "", result: "all" };
322
+ window._acpHistoryFilter = historyFilterState;
323
+
324
+ const filteredHistory = (profile.recent_history || []).filter((row) => {
325
+ if (historyFilterState.result !== "all" && row.result_kind !== historyFilterState.result) return false;
326
+ if (historyFilterState.search) {
327
+ const q = historyFilterState.search.toLowerCase();
328
+ return (
329
+ (row.session || "").toLowerCase().includes(q) ||
330
+ (row.coding_worker || "").toLowerCase().includes(q) ||
331
+ (row.task_kind || "").toLowerCase().includes(q)
332
+ );
333
+ }
334
+ return true;
335
+ });
336
+
273
337
  const recentHistoryTable = renderTable(
274
338
  [
275
339
  { label: "Session", render: (row) => `<div class="mono">${row.session}</div>` },
@@ -279,7 +343,7 @@ function renderProfile(profile) {
279
343
  { label: "Result", render: renderResult },
280
344
  { label: "Updated", render: (row) => row.updated_at ? `${relativeTime(row.updated_at)}<div class="muted">${row.updated_at}</div>` : "n/a" },
281
345
  ],
282
- profile.recent_history || [],
346
+ filteredHistory,
283
347
  "No recently archived runs.",
284
348
  );
285
349
 
@@ -305,6 +369,7 @@ function renderProfile(profile) {
305
369
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
306
370
  { label: "Attempts", key: "attempts" },
307
371
  { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
372
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
308
373
  ],
309
374
  profile.issue_retries || [],
310
375
  "No issue retries recorded.",
@@ -317,6 +382,7 @@ function renderProfile(profile) {
317
382
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
318
383
  { label: "Attempts", key: "attempts" },
319
384
  { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
385
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
320
386
  ],
321
387
  profile.pr_retries || [],
322
388
  "No PR retries recorded.",
@@ -344,6 +410,7 @@ function renderProfile(profile) {
344
410
  { label: "Reason", render: (row) => row.last_reason || "n/a" },
345
411
  { label: "Attempts", key: "attempts" },
346
412
  { label: "Next attempt", render: (row) => row.next_attempt_at ? `${relativeTime(row.next_attempt_at)}<div class="muted">${row.next_attempt_at}</div>` : "n/a" },
413
+ { label: "Time Remaining", render: (row) => row.next_attempt_at ? timeRemaining(row.next_attempt_at) : "n/a" },
347
414
  ],
348
415
  profile.provider_cooldowns,
349
416
  "No provider cooldowns recorded.",
@@ -354,6 +421,7 @@ function renderProfile(profile) {
354
421
  { label: "Issue", key: "issue_id" },
355
422
  { label: "Interval", render: (row) => `${row.interval_seconds}s` },
356
423
  { label: "Next due", render: (row) => row.next_due_at ? `${relativeTime(row.next_due_at)}<div class="muted">${row.next_due_at}</div>` : "n/a" },
424
+ { label: "Time Remaining", render: (row) => row.next_due_at ? timeRemaining(row.next_due_at) : "n/a" },
357
425
  { label: "Last started", render: (row) => row.last_started_at ? `${relativeTime(row.last_started_at)}<div class="muted">${row.last_started_at}</div>` : "n/a" },
358
426
  ],
359
427
  profile.scheduled_issues,
@@ -413,6 +481,28 @@ function renderProfile(profile) {
413
481
  `
414
482
  : "";
415
483
 
484
+ const runsFilterBar = `
485
+ <div class="filter-bar">
486
+ <input type="text" class="filter-search" placeholder="Search runs..." value="${runsFilterState.search}"
487
+ oninput="window._acpRunsFilter.search=this.value; rerenderAll();" />
488
+ <button class="filter-btn ${runsFilterState.status === 'all' ? 'active' : ''}" onclick="window._acpRunsFilter.status='all'; rerenderAll();">All</button>
489
+ <button class="filter-btn ${runsFilterState.status === 'RUNNING' ? 'active' : ''}" onclick="window._acpRunsFilter.status='RUNNING'; rerenderAll();">Running</button>
490
+ <button class="filter-btn ${runsFilterState.status === 'SUCCEEDED' ? 'active' : ''}" onclick="window._acpRunsFilter.status='SUCCEEDED'; rerenderAll();">Completed</button>
491
+ <button class="filter-btn ${runsFilterState.status === 'FAILED' ? 'active' : ''}" onclick="window._acpRunsFilter.status='FAILED'; rerenderAll();">Failed</button>
492
+ </div>
493
+ `;
494
+
495
+ const historyFilterBar = `
496
+ <div class="filter-bar">
497
+ <input type="text" class="filter-search" placeholder="Search history..." value="${historyFilterState.search}"
498
+ oninput="window._acpHistoryFilter.search=this.value; rerenderAll();" />
499
+ <button class="filter-btn ${historyFilterState.result === 'all' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='all'; rerenderAll();">All</button>
500
+ <button class="filter-btn ${historyFilterState.result === 'implemented' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='implemented'; rerenderAll();">Implemented</button>
501
+ <button class="filter-btn ${historyFilterState.result === 'reported' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='reported'; rerenderAll();">Reported</button>
502
+ <button class="filter-btn ${historyFilterState.result === 'blocked' ? 'active' : ''}" onclick="window._acpHistoryFilter.result='blocked'; rerenderAll();">Blocked</button>
503
+ </div>
504
+ `;
505
+
416
506
  return `
417
507
  <article class="profile">
418
508
  <header class="profile-header">
@@ -435,11 +525,13 @@ function renderProfile(profile) {
435
525
  <section class="panel">
436
526
  <h3>Active Runs</h3>
437
527
  <p class="panel-subtitle">Lifecycle shows technical session completion. Result shows what the run achieved: implemented, reported, or blocked.</p>
528
+ ${runsFilterBar}
438
529
  ${runsTable}
439
530
  </section>
440
531
  <section class="panel">
441
532
  <h3>Recent Completed Runs</h3>
442
533
  <p class="panel-subtitle">Recently archived runs so they do not disappear from the dashboard immediately after completion.</p>
534
+ ${historyFilterBar}
443
535
  ${recentHistoryTable}
444
536
  </section>
445
537
  <section class="panel">
@@ -460,6 +552,18 @@ function renderProfile(profile) {
460
552
  <h3>Resident Worker Metadata</h3>
461
553
  ${workerTable}
462
554
  </section>
555
+ <section class="panel">
556
+ <h3>Troubleshooting</h3>
557
+ <p class="panel-subtitle">Run diagnostics or debugging tools against this live profile.</p>
558
+ <div class="action-bar">
559
+ <button class="action-btn" onclick="runDoctor('${profile.id}')">Run Doctor</button>
560
+ <button class="action-btn" onclick="exportProfile('${profile.id}')">Export Profile</button>
561
+ <button class="action-btn" onclick="document.getElementById('import-file-${profile.id}').click()">Import Profile</button>
562
+ <input type="file" id="import-file-${profile.id}" style="display:none" accept=".json" onchange="importProfile('${profile.id}', this)">
563
+ <span id="doctor-status-${profile.id}"></span>
564
+ </div>
565
+ <pre id="doctor-output-${profile.id}" class="doctor-output" style="display:none;"></pre>
566
+ </section>
463
567
  <section class="panel half">
464
568
  <h3>Provider Cooldowns</h3>
465
569
  ${cooldownTable}
@@ -523,9 +627,8 @@ async function loadSnapshot() {
523
627
  throw new Error(`Snapshot request failed with ${response.status}`);
524
628
  }
525
629
  const snapshot = await response.json();
526
- generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
527
- renderOverview(snapshot);
528
- profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
630
+ window._acpSnapshot = snapshot;
631
+ renderFromSnapshot(snapshot);
529
632
  await maybeNotifyAlerts(snapshot);
530
633
  } catch (error) {
531
634
  generatedAtNode.textContent = `Snapshot load failed: ${error.message}`;
@@ -535,6 +638,96 @@ async function loadSnapshot() {
535
638
  }
536
639
  }
537
640
 
641
+ function renderFromSnapshot(snapshot) {
642
+ generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
643
+ renderOverview(snapshot);
644
+ profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
645
+ }
646
+
647
+ function rerenderAll() {
648
+ const snapshot = window._acpSnapshot;
649
+ if (!snapshot) return;
650
+ renderFromSnapshot(snapshot);
651
+ }
652
+
653
+ async function runDoctor(profileId) {
654
+ const statusEl = document.getElementById(`doctor-status-${profileId}`);
655
+ const outputEl = document.getElementById(`doctor-output-${profileId}`);
656
+ if (statusEl) statusEl.textContent = "Running...";
657
+ if (outputEl) {
658
+ outputEl.style.display = "none";
659
+ outputEl.textContent = "";
660
+ }
661
+ try {
662
+ const response = await fetch(`/api/doctor?profile_id=${encodeURIComponent(profileId)}`, { cache: "no-store" });
663
+ const data = await response.json();
664
+ if (statusEl) statusEl.textContent = response.ok ? "Done" : `Error: ${data.error || response.status}`;
665
+ if (outputEl) {
666
+ outputEl.style.display = "block";
667
+ outputEl.textContent = data.output || data.error || "No output";
668
+ }
669
+ } catch (error) {
670
+ if (statusEl) statusEl.textContent = `Error: ${error.message}`;
671
+ }
672
+ }
673
+
674
+ async function exportProfile(profileId) {
675
+ try {
676
+ const response = await fetch(`/api/profile/export?profile_id=${encodeURIComponent(profileId)}`, { cache: "no-store" });
677
+ if (!response.ok) {
678
+ const data = await response.json();
679
+ alert(`Export failed: ${data.error || response.status}`);
680
+ return;
681
+ }
682
+ const data = await response.json();
683
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
684
+ const url = URL.createObjectURL(blob);
685
+ const a = document.createElement("a");
686
+ a.href = url;
687
+ a.download = `acp-profile-${profileId}.json`;
688
+ document.body.appendChild(a);
689
+ a.click();
690
+ document.body.removeChild(a);
691
+ URL.revokeObjectURL(url);
692
+ } catch (error) {
693
+ alert(`Export failed: ${error.message}`);
694
+ }
695
+ }
696
+
697
+ async function importProfile(profileId, inputEl) {
698
+ const file = inputEl.files[0];
699
+ if (!file) return;
700
+
701
+ try {
702
+ const text = await file.text();
703
+ const data = JSON.parse(text);
704
+
705
+ if (!data.profile_id || !data.config) {
706
+ alert("Invalid profile file: missing profile_id or config");
707
+ return;
708
+ }
709
+
710
+ const response = await fetch("/api/profile/import", {
711
+ method: "POST",
712
+ headers: { "Content-Type": "application/json" },
713
+ body: JSON.stringify(data),
714
+ });
715
+
716
+ const result = await response.json();
717
+ if (response.ok) {
718
+ alert(`Profile ${profileId} imported successfully!`);
719
+ // Refresh the page to show imported profile
720
+ setTimeout(() => window.location.reload(), 1000);
721
+ } else {
722
+ alert(`Import failed: ${result.error || response.status}`);
723
+ }
724
+ } catch (error) {
725
+ alert(`Import failed: ${error.message}`);
726
+ } finally {
727
+ inputEl.value = "";
728
+ }
729
+ }
730
+
538
731
  refreshButton.addEventListener("click", () => {
539
732
  void loadSnapshot();
540
733
  });
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def split_key_value_text(text: str) -> list[str]:
11
+ return [line.strip() for line in re.split(r"(?:\r?\n|\\n)+", text) if line.strip()]
12
+
13
+
14
+ def normalize_value(raw: str) -> str:
15
+ value = raw.strip()
16
+ if value in {"''", '""'}:
17
+ return ""
18
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
19
+ return value[1:-1]
20
+ return value
21
+
22
+
23
+ def parse_key_value_text(text: str) -> dict[str, str]:
24
+ data: dict[str, str] = {}
25
+ for line in split_key_value_text(text):
26
+ if "=" not in line:
27
+ continue
28
+ key, value = line.split("=", 1)
29
+ data[key.strip()] = normalize_value(value)
30
+ return data
31
+
32
+
33
+ def read_env_file(path: Path) -> dict[str, str]:
34
+ if not path.is_file():
35
+ return {}
36
+ return parse_key_value_text(path.read_text(encoding="utf-8", errors="replace"))
37
+
38
+
39
+ def file_mtime_iso(path: Path) -> str:
40
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
41
+
42
+
43
+ def parse_issue_queue_filename(path: Path) -> tuple[str, str]:
44
+ name = path.name
45
+ if name.endswith(".env"):
46
+ name = name[:-4]
47
+ if not name.startswith("issue-"):
48
+ return "", ""
49
+
50
+ payload = name[len("issue-") :]
51
+ if "." not in payload:
52
+ return payload, ""
53
+
54
+ issue_id, remainder = payload.split(".", 1)
55
+ remainder_parts = remainder.split(".")
56
+ if len(remainder_parts) >= 2:
57
+ return issue_id, ".".join(remainder_parts[:-1])
58
+ return issue_id, remainder
59
+
60
+
61
+ def is_pending_queue_file(path: Path) -> bool:
62
+ return path.is_file() and path.name.startswith("issue-") and path.name.endswith(".env") and ".tmp." not in path.name
63
+
64
+
65
+ def is_claim_queue_file(path: Path) -> bool:
66
+ return path.is_file() and path.name.startswith("issue-") and ".tmp." not in path.name
67
+
68
+
69
+ def collect_queue_items(root: Path, kind: str) -> list[dict[str, Any]]:
70
+ if not root.is_dir():
71
+ return []
72
+
73
+ matcher = is_pending_queue_file if kind == "pending" else is_claim_queue_file
74
+ items: list[dict[str, Any]] = []
75
+ for path in sorted((item for item in root.iterdir() if matcher(item)), key=lambda item: item.stat().st_mtime, reverse=True):
76
+ env = read_env_file(path)
77
+ issue_id_from_name, claimer_from_name = parse_issue_queue_filename(path)
78
+ claim_file = env.get("CLAIM_FILE", "")
79
+ state_kind = env.get("STATE_KIND", "")
80
+ items.append(
81
+ {
82
+ "issue_id": env.get("ISSUE_ID", "") or issue_id_from_name,
83
+ "session": env.get("SESSION", "") or claimer_from_name,
84
+ "claim_file": claim_file or (str(path) if kind == "claims" else ""),
85
+ "queued_by": env.get("QUEUED_BY", ""),
86
+ "claimed_by": env.get("CLAIMED_BY", "") or claimer_from_name,
87
+ "state_kind": state_kind or ("claim" if kind == "claims" else "pending"),
88
+ "state_format_version": env.get("STATE_FORMAT_VERSION", ""),
89
+ "updated_at": env.get("UPDATED_AT", "") or env.get("CLAIMED_AT", "") or env.get("QUEUED_AT", "") or file_mtime_iso(path),
90
+ "state_file": str(path),
91
+ }
92
+ )
93
+ return items
94
+
95
+
96
+ def collect_issue_queue(state_root: Path) -> dict[str, list[dict[str, Any]]]:
97
+ queue_root = state_root / "resident-workers" / "issue-queue"
98
+ return {
99
+ "pending": collect_queue_items(queue_root / "pending", "pending"),
100
+ "claims": collect_queue_items(queue_root / "claims", "claims"),
101
+ }
@@ -4,14 +4,18 @@ from __future__ import annotations
4
4
  import argparse
5
5
  import json
6
6
  import os
7
+ import subprocess
7
8
  from functools import partial
8
9
  from http import HTTPStatus
9
10
  from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
10
11
  from pathlib import Path
11
- from urllib.parse import urlparse
12
+ from urllib.parse import urlparse, parse_qs
12
13
 
13
14
  from dashboard_snapshot import build_snapshot
14
15
 
16
+ ROOT_DIR = Path(__file__).resolve().parents[2]
17
+ TOOLS_BIN_DIR = ROOT_DIR / "tools" / "bin"
18
+
15
19
 
16
20
  DASHBOARD_DIR = Path(__file__).resolve().parent
17
21
 
@@ -31,6 +35,124 @@ class DashboardHandler(SimpleHTTPRequestHandler):
31
35
  self.end_headers()
32
36
  self.wfile.write(encoded)
33
37
  return
38
+ if parsed.path == "/api/doctor":
39
+ query = parse_qs(parsed.query)
40
+ profile_id = (query.get("profile_id") or [""])[0]
41
+ if not profile_id:
42
+ self.send_response(HTTPStatus.BAD_REQUEST)
43
+ self.send_header("Content-Type", "application/json")
44
+ self.end_headers()
45
+ self.wfile.write(json.dumps({"error": "profile_id is required"}).encode("utf-8"))
46
+ return
47
+ doctor_script = TOOLS_BIN_DIR / "flow-runtime-doctor.sh"
48
+ if not doctor_script.is_file():
49
+ self.send_response(HTTPStatus.NOT_FOUND)
50
+ self.send_header("Content-Type", "application/json")
51
+ self.end_headers()
52
+ self.wfile.write(json.dumps({"error": "doctor script not found"}).encode("utf-8"))
53
+ return
54
+ try:
55
+ env = os.environ.copy()
56
+ env["ACP_PROJECT_ID"] = profile_id
57
+ output = subprocess.check_output(
58
+ ["bash", str(doctor_script)],
59
+ cwd=str(ROOT_DIR),
60
+ env=env,
61
+ text=True,
62
+ stderr=subprocess.STDOUT,
63
+ timeout=120,
64
+ )
65
+ payload = {"output": output}
66
+ encoded = json.dumps(payload).encode("utf-8")
67
+ self.send_response(HTTPStatus.OK)
68
+ self.send_header("Content-Type", "application/json; charset=utf-8")
69
+ self.send_header("Content-Length", str(len(encoded)))
70
+ self.end_headers()
71
+ self.wfile.write(encoded)
72
+ except subprocess.TimeoutExpired:
73
+ self.send_response(HTTPStatus.GATEWAY_TIMEOUT)
74
+ self.send_header("Content-Type", "application/json")
75
+ self.end_headers()
76
+ self.wfile.write(json.dumps({"error": "doctor timed out"}).encode("utf-8"))
77
+ except subprocess.CalledProcessError as exc:
78
+ payload = {"error": exc.returncode, "output": exc.output}
79
+ encoded = json.dumps(payload).encode("utf-8")
80
+ self.send_response(HTTPStatus.OK)
81
+ self.send_header("Content-Type", "application/json; charset=utf-8")
82
+ self.send_header("Content-Length", str(len(encoded)))
83
+ self.end_headers()
84
+ self.wfile.write(encoded)
85
+ return
86
+ if parsed.path == "/api/profile/export":
87
+ query = parse_qs(parsed.query)
88
+ profile_id = (query.get("profile_id") or [""])[0]
89
+ if not profile_id:
90
+ self.send_response(HTTPStatus.BAD_REQUEST)
91
+ self.send_header("Content-Type", "application/json")
92
+ self.end_headers()
93
+ self.wfile.write(json.dumps({"error": "profile_id is required"}).encode("utf-8"))
94
+ return
95
+ registry_root = Path(os.environ.get("ACP_PROFILE_REGISTRY_ROOT", str(Path.home() / ".agent-runtime" / "control-plane" / "profiles")))
96
+ profile_dir = registry_root / profile_id
97
+ config_file = profile_dir / "control-plane.yaml"
98
+ if not config_file.is_file():
99
+ self.send_response(HTTPStatus.NOT_FOUND)
100
+ self.send_header("Content-Type", "application/json")
101
+ self.end_headers()
102
+ self.wfile.write(json.dumps({"error": "profile config not found"}).encode("utf-8"))
103
+ return
104
+ try:
105
+ config = config_file.read_text(encoding="utf-8")
106
+ payload = {"profile_id": profile_id, "config": config, "config_file": str(config_file)}
107
+ encoded = json.dumps(payload).encode("utf-8")
108
+ self.send_response(HTTPStatus.OK)
109
+ self.send_header("Content-Type", "application/json; charset=utf-8")
110
+ self.send_header("Content-Length", str(len(encoded)))
111
+ self.end_headers()
112
+ self.wfile.write(encoded)
113
+ except Exception as exc:
114
+ self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR)
115
+ self.send_header("Content-Type", "application/json")
116
+ self.end_headers()
117
+ self.wfile.write(json.dumps({"error": str(exc)}).encode("utf-8"))
118
+ return
119
+ if parsed.path == "/api/profile/import":
120
+ if self.command != "POST":
121
+ self.send_response(HTTPStatus.METHOD_NOT_ALOWED)
122
+ self.send_header("Content-Type", "application/json")
123
+ self.end_headers()
124
+ self.wfile.write(json.dumps({"error": "POST required"}).encode("utf-8"))
125
+ return
126
+ try:
127
+ content_length = int(self.headers.get("Content-Length", 0))
128
+ body = self.rfile.read(content_length)
129
+ data = json.loads(body)
130
+ profile_id = data.get("profile_id", "")
131
+ config = data.get("config", "")
132
+ if not profile_id or not config:
133
+ self.send_response(HTTPStatus.BAD_REQUEST)
134
+ self.send_header("Content-Type", "application/json")
135
+ self.end_headers()
136
+ self.wfile.write(json.dumps({"error": "profile_id and config required"}).encode("utf-8"))
137
+ return
138
+ registry_root = Path(os.environ.get("ACP_PROFILE_REGISTRY_ROOT", str(Path.home() / ".agent-runtime" / "control-plane" / "profiles")))
139
+ profile_dir = registry_root / profile_id
140
+ config_file = profile_dir / "control-plane.yaml"
141
+ profile_dir.mkdir(parents=True, exist_ok=True)
142
+ config_file.write_text(config, encoding="utf-8")
143
+ payload = {"status": "ok", "profile_id": profile_id, "config_file": str(config_file)}
144
+ encoded = json.dumps(payload).encode("utf-8")
145
+ self.send_response(HTTPStatus.OK)
146
+ self.send_header("Content-Type", "application/json; charset=utf-8")
147
+ self.send_header("Content-Length", str(len(encoded)))
148
+ self.end_headers()
149
+ self.wfile.write(encoded)
150
+ except Exception as exc:
151
+ self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR)
152
+ self.send_header("Content-Type", "application/json")
153
+ self.end_headers()
154
+ self.wfile.write(json.dumps({"error": str(exc)}).encode("utf-8"))
155
+ return
34
156
  return super().do_GET()
35
157
 
36
158
  def end_headers(self) -> None: