agent-control-plane 0.4.9 → 0.7.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 (87) hide show
  1. package/README.md +109 -13
  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-config-lib.sh +13 -3508
  6. package/tools/bin/flow-execution-lib.sh +243 -0
  7. package/tools/bin/flow-forge-lib.sh +1770 -0
  8. package/tools/bin/flow-profile-lib.sh +335 -0
  9. package/tools/bin/flow-provider-lib.sh +981 -0
  10. package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
  11. package/tools/bin/flow-runtime-doctor.sh +5 -1
  12. package/tools/bin/flow-session-lib.sh +317 -0
  13. package/tools/bin/install-project-systemd.sh +255 -0
  14. package/tools/bin/project-runtimectl.sh +45 -0
  15. package/tools/bin/project-systemd-bootstrap.sh +74 -0
  16. package/tools/bin/uninstall-project-systemd.sh +87 -0
  17. package/tools/dashboard/app.js +238 -8
  18. package/tools/dashboard/issue_queue_state.py +101 -0
  19. package/tools/dashboard/requirements.txt +3 -0
  20. package/tools/dashboard/server.py +250 -30
  21. package/tools/dashboard/styles.css +526 -455
  22. package/tools/bin/agent-cleanup-worktree +0 -247
  23. package/tools/bin/agent-github-update-labels +0 -105
  24. package/tools/bin/agent-init-worktree +0 -216
  25. package/tools/bin/agent-project-archive-run +0 -52
  26. package/tools/bin/agent-project-capture-worker +0 -46
  27. package/tools/bin/agent-project-catch-up-issue-pr-links +0 -118
  28. package/tools/bin/agent-project-catch-up-merged-prs +0 -195
  29. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +0 -123
  30. package/tools/bin/agent-project-cleanup-session +0 -513
  31. package/tools/bin/agent-project-detached-launch +0 -127
  32. package/tools/bin/agent-project-heartbeat-loop +0 -1029
  33. package/tools/bin/agent-project-open-issue-worktree +0 -89
  34. package/tools/bin/agent-project-open-pr-worktree +0 -80
  35. package/tools/bin/agent-project-publish-issue-pr +0 -468
  36. package/tools/bin/agent-project-reconcile-issue-session +0 -1409
  37. package/tools/bin/agent-project-reconcile-pr-session +0 -1288
  38. package/tools/bin/agent-project-retry-state +0 -158
  39. package/tools/bin/agent-project-run-claude-session +0 -805
  40. package/tools/bin/agent-project-run-codex-resilient +0 -963
  41. package/tools/bin/agent-project-run-codex-session +0 -435
  42. package/tools/bin/agent-project-run-kilo-session +0 -369
  43. package/tools/bin/agent-project-run-ollama-session +0 -658
  44. package/tools/bin/agent-project-run-openclaw-session +0 -1309
  45. package/tools/bin/agent-project-run-opencode-session +0 -377
  46. package/tools/bin/agent-project-run-pi-session +0 -479
  47. package/tools/bin/agent-project-sync-anchor-repo +0 -139
  48. package/tools/bin/agent-project-sync-source-repo-main +0 -163
  49. package/tools/bin/agent-project-worker-status +0 -188
  50. package/tools/bin/branch-verification-guard.sh +0 -364
  51. package/tools/bin/capture-worker.sh +0 -18
  52. package/tools/bin/cleanup-worktree.sh +0 -52
  53. package/tools/bin/codex-quota +0 -31
  54. package/tools/bin/create-follow-up-issue.sh +0 -114
  55. package/tools/bin/dashboard-launchd-bootstrap.sh +0 -50
  56. package/tools/bin/issue-publish-localization-guard.sh +0 -142
  57. package/tools/bin/issue-publish-scope-guard.sh +0 -242
  58. package/tools/bin/issue-requires-local-workspace-install.sh +0 -31
  59. package/tools/bin/issue-resource-class.sh +0 -12
  60. package/tools/bin/kick-scheduler.sh +0 -75
  61. package/tools/bin/label-follow-up-issues.sh +0 -14
  62. package/tools/bin/new-pr-worktree.sh +0 -50
  63. package/tools/bin/new-worktree.sh +0 -49
  64. package/tools/bin/pr-risk.sh +0 -12
  65. package/tools/bin/prepare-worktree.sh +0 -142
  66. package/tools/bin/provider-cooldown-state.sh +0 -204
  67. package/tools/bin/publish-issue-worker.sh +0 -31
  68. package/tools/bin/reconcile-bootstrap-lib.sh +0 -113
  69. package/tools/bin/reconcile-issue-worker.sh +0 -34
  70. package/tools/bin/reconcile-pr-worker.sh +0 -34
  71. package/tools/bin/record-verification.sh +0 -71
  72. package/tools/bin/render-flow-config.sh +0 -98
  73. package/tools/bin/resident-issue-controller-lib.sh +0 -448
  74. package/tools/bin/retry-state.sh +0 -31
  75. package/tools/bin/reuse-issue-worktree.sh +0 -121
  76. package/tools/bin/run-codex-bypass.sh +0 -3
  77. package/tools/bin/run-codex-safe.sh +0 -3
  78. package/tools/bin/run-codex-task.sh +0 -280
  79. package/tools/bin/serve-dashboard.sh +0 -5
  80. package/tools/bin/start-issue-worker.sh +0 -943
  81. package/tools/bin/start-pr-fix-worker.sh +0 -528
  82. package/tools/bin/start-pr-merge-repair-worker.sh +0 -8
  83. package/tools/bin/start-pr-review-worker.sh +0 -261
  84. package/tools/bin/start-resident-issue-loop.sh +0 -499
  85. package/tools/bin/update-github-labels.sh +0 -14
  86. package/tools/bin/worker-status.sh +0 -19
  87. 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,12 +638,139 @@ 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
  });
541
734
 
542
735
  initializeTheme();
543
736
  void loadSnapshot();
544
- window.setInterval(() => {
545
- void loadSnapshot();
546
- }, 5000);
737
+
738
+ // WebSocket live updates
739
+ let wsReconnectDelay = 1000;
740
+ let wsConnectionActive = false;
741
+
742
+ function connectWebSocket() {
743
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
744
+ const wsUrl = `${protocol}//${location.host}/ws`;
745
+ const ws = new WebSocket(wsUrl);
746
+
747
+ ws.onopen = () => {
748
+ wsReconnectDelay = 1000;
749
+ wsConnectionActive = true;
750
+ console.log("ACP Dashboard: WebSocket connected");
751
+ };
752
+
753
+ ws.onmessage = (event) => {
754
+ try {
755
+ const snapshot = JSON.parse(event.data);
756
+ window._acpSnapshot = snapshot;
757
+ renderFromSnapshot(snapshot);
758
+ maybeNotifyAlerts(snapshot);
759
+ } catch (error) {
760
+ console.error("ACP Dashboard: Failed to parse WebSocket message", error);
761
+ }
762
+ };
763
+
764
+ ws.onclose = () => {
765
+ wsConnectionActive = false;
766
+ console.log(`ACP Dashboard: WebSocket disconnected, reconnecting in ${wsReconnectDelay}ms`);
767
+ setTimeout(connectWebSocket, wsReconnectDelay);
768
+ wsReconnectDelay = Math.min(wsReconnectDelay * 2, 30000);
769
+ };
770
+
771
+ ws.onerror = (error) => {
772
+ console.error("ACP Dashboard: WebSocket error", error);
773
+ };
774
+ }
775
+
776
+ connectWebSocket();
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ aiohttp>=3.8
2
+ aiohttp-cors>=0.7
3
+ websockets>=10.0