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.
- package/README.md +72 -9
- package/npm/bin/agent-control-plane.js +1 -1
- package/package.json +39 -33
- package/tools/bin/debug-session.sh +106 -0
- package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
- package/tools/bin/flow-runtime-doctor.sh +5 -1
- package/tools/bin/install-project-systemd.sh +255 -0
- package/tools/bin/project-runtimectl.sh +45 -0
- package/tools/bin/project-systemd-bootstrap.sh +74 -0
- package/tools/bin/uninstall-project-systemd.sh +87 -0
- package/tools/dashboard/app.js +198 -5
- package/tools/dashboard/issue_queue_state.py +101 -0
- package/tools/dashboard/server.py +123 -1
- package/tools/dashboard/styles.css +526 -455
- package/tools/bin/agent-cleanup-worktree +0 -247
- package/tools/bin/agent-github-update-labels +0 -105
- package/tools/bin/agent-init-worktree +0 -216
- package/tools/bin/agent-project-archive-run +0 -52
- package/tools/bin/agent-project-capture-worker +0 -46
- package/tools/bin/agent-project-catch-up-issue-pr-links +0 -118
- package/tools/bin/agent-project-catch-up-merged-prs +0 -195
- package/tools/bin/agent-project-catch-up-scheduled-issue-retries +0 -123
- package/tools/bin/agent-project-cleanup-session +0 -513
- package/tools/bin/agent-project-detached-launch +0 -127
- package/tools/bin/agent-project-heartbeat-loop +0 -1029
- package/tools/bin/agent-project-open-issue-worktree +0 -89
- package/tools/bin/agent-project-open-pr-worktree +0 -80
- package/tools/bin/agent-project-publish-issue-pr +0 -468
- package/tools/bin/agent-project-reconcile-issue-session +0 -1409
- package/tools/bin/agent-project-reconcile-pr-session +0 -1288
- package/tools/bin/agent-project-retry-state +0 -158
- package/tools/bin/agent-project-run-claude-session +0 -805
- package/tools/bin/agent-project-run-codex-resilient +0 -963
- package/tools/bin/agent-project-run-codex-session +0 -435
- package/tools/bin/agent-project-run-kilo-session +0 -369
- package/tools/bin/agent-project-run-ollama-session +0 -658
- package/tools/bin/agent-project-run-openclaw-session +0 -1309
- package/tools/bin/agent-project-run-opencode-session +0 -377
- package/tools/bin/agent-project-run-pi-session +0 -479
- package/tools/bin/agent-project-sync-anchor-repo +0 -139
- package/tools/bin/agent-project-sync-source-repo-main +0 -163
- package/tools/bin/agent-project-worker-status +0 -188
- package/tools/bin/branch-verification-guard.sh +0 -364
- package/tools/bin/capture-worker.sh +0 -18
- package/tools/bin/cleanup-worktree.sh +0 -52
- package/tools/bin/codex-quota +0 -31
- package/tools/bin/create-follow-up-issue.sh +0 -114
- package/tools/bin/dashboard-launchd-bootstrap.sh +0 -50
- package/tools/bin/issue-publish-localization-guard.sh +0 -142
- package/tools/bin/issue-publish-scope-guard.sh +0 -242
- package/tools/bin/issue-requires-local-workspace-install.sh +0 -31
- package/tools/bin/issue-resource-class.sh +0 -12
- package/tools/bin/kick-scheduler.sh +0 -75
- package/tools/bin/label-follow-up-issues.sh +0 -14
- package/tools/bin/new-pr-worktree.sh +0 -50
- package/tools/bin/new-worktree.sh +0 -49
- package/tools/bin/pr-risk.sh +0 -12
- package/tools/bin/prepare-worktree.sh +0 -142
- package/tools/bin/provider-cooldown-state.sh +0 -204
- package/tools/bin/publish-issue-worker.sh +0 -31
- package/tools/bin/reconcile-bootstrap-lib.sh +0 -113
- package/tools/bin/reconcile-issue-worker.sh +0 -34
- package/tools/bin/reconcile-pr-worker.sh +0 -34
- package/tools/bin/record-verification.sh +0 -71
- package/tools/bin/render-flow-config.sh +0 -98
- package/tools/bin/resident-issue-controller-lib.sh +0 -448
- package/tools/bin/retry-state.sh +0 -31
- package/tools/bin/reuse-issue-worktree.sh +0 -121
- package/tools/bin/run-codex-bypass.sh +0 -3
- package/tools/bin/run-codex-safe.sh +0 -3
- package/tools/bin/run-codex-task.sh +0 -280
- package/tools/bin/serve-dashboard.sh +0 -5
- package/tools/bin/start-issue-worker.sh +0 -943
- package/tools/bin/start-pr-fix-worker.sh +0 -528
- package/tools/bin/start-pr-merge-repair-worker.sh +0 -8
- package/tools/bin/start-pr-review-worker.sh +0 -261
- package/tools/bin/start-resident-issue-loop.sh +0 -499
- package/tools/bin/update-github-labels.sh +0 -14
- package/tools/bin/worker-status.sh +0 -19
- package/tools/bin/workflow-catalog.sh +0 -77
package/tools/dashboard/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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:
|