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.
- package/README.md +109 -13
- 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-config-lib.sh +13 -3508
- package/tools/bin/flow-execution-lib.sh +243 -0
- package/tools/bin/flow-forge-lib.sh +1770 -0
- package/tools/bin/flow-profile-lib.sh +335 -0
- package/tools/bin/flow-provider-lib.sh +981 -0
- package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
- package/tools/bin/flow-runtime-doctor.sh +5 -1
- package/tools/bin/flow-session-lib.sh +317 -0
- 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 +238 -8
- package/tools/dashboard/issue_queue_state.py +101 -0
- package/tools/dashboard/requirements.txt +3 -0
- package/tools/dashboard/server.py +250 -30
- 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
|
@@ -2,45 +2,243 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
|
-
|
|
8
|
-
from http import HTTPStatus
|
|
9
|
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
import subprocess
|
|
10
9
|
from pathlib import Path
|
|
11
|
-
from
|
|
10
|
+
from functools import partial
|
|
12
11
|
|
|
13
|
-
from
|
|
12
|
+
from aiohttp import web, WSMsgType
|
|
13
|
+
from aiohttp_cors import setup as cors_setup, ResourceOptions
|
|
14
14
|
|
|
15
|
+
from dashboard_snapshot import build_snapshot
|
|
15
16
|
|
|
17
|
+
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
18
|
+
TOOLS_BIN_DIR = ROOT_DIR / "tools" / "bin"
|
|
16
19
|
DASHBOARD_DIR = Path(__file__).resolve().parent
|
|
17
20
|
|
|
21
|
+
# Store connected WebSocket clients
|
|
22
|
+
ws_clients: set[web.WebSocketResponse] = set()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def broadcast_snapshot():
|
|
26
|
+
"""Broadcast current snapshot to all connected WebSocket clients."""
|
|
27
|
+
if not ws_clients:
|
|
28
|
+
return
|
|
29
|
+
payload = build_snapshot()
|
|
30
|
+
encoded = json.dumps(payload, indent=2).encode("utf-8")
|
|
31
|
+
disconnected = set()
|
|
32
|
+
for ws in ws_clients:
|
|
33
|
+
try:
|
|
34
|
+
await ws.send_bytes(encoded)
|
|
35
|
+
except Exception:
|
|
36
|
+
disconnected.add(ws)
|
|
37
|
+
for ws in disconnected:
|
|
38
|
+
ws_clients.discard(ws)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def snapshot_handler(request: web.Request) -> web.Response:
|
|
42
|
+
"""HTTP endpoint: GET /api/snapshot.json"""
|
|
43
|
+
payload = build_snapshot()
|
|
44
|
+
encoded = json.dumps(payload, indent=2).encode("utf-8")
|
|
45
|
+
return web.Response(
|
|
46
|
+
body=encoded,
|
|
47
|
+
content_type="application/json; charset=utf-8",
|
|
48
|
+
headers={"Cache-Control": "no-store"},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def doctor_handler(request: web.Request) -> web.Response:
|
|
53
|
+
"""HTTP endpoint: GET /api/doctor?profile_id=xxx"""
|
|
54
|
+
profile_id = request.query.get("profile_id", "")
|
|
55
|
+
if not profile_id:
|
|
56
|
+
return web.Response(
|
|
57
|
+
body=json.dumps({"error": "profile_id is required"}),
|
|
58
|
+
status=400,
|
|
59
|
+
content_type="application/json",
|
|
60
|
+
)
|
|
61
|
+
doctor_script = TOOLS_BIN_DIR / "flow-runtime-doctor.sh"
|
|
62
|
+
if not doctor_script.is_file():
|
|
63
|
+
return web.Response(
|
|
64
|
+
body=json.dumps({"error": "doctor script not found"}),
|
|
65
|
+
status=404,
|
|
66
|
+
content_type="application/json",
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
env = os.environ.copy()
|
|
70
|
+
env["ACP_PROJECT_ID"] = profile_id
|
|
71
|
+
proc = await asyncio.create_subprocess_exec(
|
|
72
|
+
"bash",
|
|
73
|
+
str(doctor_script),
|
|
74
|
+
cwd=str(ROOT_DIR),
|
|
75
|
+
env=env,
|
|
76
|
+
stdout=subprocess.PIPE,
|
|
77
|
+
stderr=subprocess.STDOUT,
|
|
78
|
+
)
|
|
79
|
+
output, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
80
|
+
payload = {"output": output.decode("utf-8", errors="replace")}
|
|
81
|
+
return web.Response(
|
|
82
|
+
body=json.dumps(payload),
|
|
83
|
+
content_type="application/json; charset=utf-8",
|
|
84
|
+
)
|
|
85
|
+
except asyncio.TimeoutError:
|
|
86
|
+
return web.Response(
|
|
87
|
+
body=json.dumps({"error": "doctor timed out"}),
|
|
88
|
+
status=504,
|
|
89
|
+
content_type="application/json",
|
|
90
|
+
)
|
|
91
|
+
except subprocess.CalledProcessError as exc:
|
|
92
|
+
payload = {"error": exc.returncode, "output": exc.output}
|
|
93
|
+
return web.Response(
|
|
94
|
+
body=json.dumps(payload),
|
|
95
|
+
content_type="application/json; charset=utf-8",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def profile_export_handler(request: web.Request) -> web.Response:
|
|
100
|
+
"""HTTP endpoint: GET /api/profile/export?profile_id=xxx"""
|
|
101
|
+
profile_id = request.query.get("profile_id", "")
|
|
102
|
+
if not profile_id:
|
|
103
|
+
return web.Response(
|
|
104
|
+
body=json.dumps({"error": "profile_id is required"}),
|
|
105
|
+
status=400,
|
|
106
|
+
content_type="application/json",
|
|
107
|
+
)
|
|
108
|
+
registry_root = Path(
|
|
109
|
+
os.environ.get(
|
|
110
|
+
"ACP_PROFILE_REGISTRY_ROOT",
|
|
111
|
+
str(Path.home() / ".agent-runtime" / "control-plane" / "profiles"),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
config_file = registry_root / profile_id / "control-plane.yaml"
|
|
115
|
+
if not config_file.is_file():
|
|
116
|
+
return web.Response(
|
|
117
|
+
body=json.dumps({"error": "profile config not found"}),
|
|
118
|
+
status=404,
|
|
119
|
+
content_type="application/json",
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
config = config_file.read_text(encoding="utf-8")
|
|
123
|
+
payload = {"profile_id": profile_id, "config": config, "config_file": str(config_file)}
|
|
124
|
+
return web.Response(
|
|
125
|
+
body=json.dumps(payload),
|
|
126
|
+
content_type="application/json; charset=utf-8",
|
|
127
|
+
)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
return web.Response(
|
|
130
|
+
body=json.dumps({"error": str(exc)}),
|
|
131
|
+
status=500,
|
|
132
|
+
content_type="application/json",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def profile_import_handler(request: web.Request) -> web.Response:
|
|
137
|
+
"""HTTP endpoint: POST /api/profile/import"""
|
|
138
|
+
if request.method != "POST":
|
|
139
|
+
return web.Response(
|
|
140
|
+
body=json.dumps({"error": "POST required"}),
|
|
141
|
+
status=405,
|
|
142
|
+
content_type="application/json",
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
data = await request.json()
|
|
146
|
+
profile_id = data.get("profile_id", "")
|
|
147
|
+
config = data.get("config", "")
|
|
148
|
+
if not profile_id or not config:
|
|
149
|
+
return web.Response(
|
|
150
|
+
body=json.dumps({"error": "profile_id and config required"}),
|
|
151
|
+
status=400,
|
|
152
|
+
content_type="application/json",
|
|
153
|
+
)
|
|
154
|
+
registry_root = Path(
|
|
155
|
+
os.environ.get(
|
|
156
|
+
"ACP_PROFILE_REGISTRY_ROOT",
|
|
157
|
+
str(Path.home() / ".agent-runtime" / "control-plane" / "profiles"),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
profile_dir = registry_root / profile_id
|
|
161
|
+
config_file = profile_dir / "control-plane.yaml"
|
|
162
|
+
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
config_file.write_text(config, encoding="utf-8")
|
|
164
|
+
payload = {"status": "ok", "profile_id": profile_id, "config_file": str(config_file)}
|
|
165
|
+
return web.Response(
|
|
166
|
+
body=json.dumps(payload),
|
|
167
|
+
content_type="application/json; charset=utf-8",
|
|
168
|
+
)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
return web.Response(
|
|
171
|
+
body=json.dumps({"error": str(exc)}),
|
|
172
|
+
status=500,
|
|
173
|
+
content_type="application/json",
|
|
174
|
+
)
|
|
175
|
+
|
|
18
176
|
|
|
19
|
-
|
|
20
|
-
|
|
177
|
+
async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
|
|
178
|
+
"""WebSocket endpoint: GET /ws"""
|
|
179
|
+
ws = web.WebSocketResponse()
|
|
180
|
+
await ws.prepare(request)
|
|
181
|
+
ws_clients.add(ws)
|
|
182
|
+
try:
|
|
183
|
+
# Send initial snapshot
|
|
184
|
+
payload = build_snapshot()
|
|
185
|
+
encoded = json.dumps(payload, indent=2).encode("utf-8")
|
|
186
|
+
await ws.send_bytes(encoded)
|
|
187
|
+
# Keep connection alive, listen for client messages (ping/pong)
|
|
188
|
+
async for msg in ws:
|
|
189
|
+
if msg.type == WSMsgType.TEXT:
|
|
190
|
+
# Echo back or handle commands if needed
|
|
191
|
+
await ws.send_str(msg.data)
|
|
192
|
+
elif msg.type == WSMsgType.ERROR:
|
|
193
|
+
break
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
finally:
|
|
197
|
+
ws_clients.discard(ws)
|
|
198
|
+
return ws
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_app() -> web.Application:
|
|
202
|
+
app = web.Application()
|
|
203
|
+
# Routes
|
|
204
|
+
app.router.add_get("/api/snapshot.json", snapshot_handler)
|
|
205
|
+
app.router.add_get("/api/doctor", doctor_handler)
|
|
206
|
+
app.router.add_get("/api/profile/export", profile_export_handler)
|
|
207
|
+
app.router.add_post("/api/profile/import", profile_import_handler)
|
|
208
|
+
app.router.add_get("/ws", websocket_handler)
|
|
209
|
+
# Static files (dashboard HTML/CSS/JS)
|
|
210
|
+
app.router.add_static("/", path=str(DASHBOARD_DIR), show_index=True)
|
|
211
|
+
# CORS setup
|
|
212
|
+
cors = cors_setup(
|
|
213
|
+
app,
|
|
214
|
+
defaults={
|
|
215
|
+
"*": ResourceOptions(
|
|
216
|
+
allow_credentials=True,
|
|
217
|
+
expose_headers="*",
|
|
218
|
+
allow_headers="*",
|
|
219
|
+
allow_methods=["GET", "POST", "OPTIONS"],
|
|
220
|
+
)
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
return app
|
|
21
224
|
|
|
22
|
-
def do_GET(self) -> None:
|
|
23
|
-
parsed = urlparse(self.path)
|
|
24
|
-
if parsed.path == "/api/snapshot.json":
|
|
25
|
-
payload = build_snapshot()
|
|
26
|
-
encoded = json.dumps(payload, indent=2).encode("utf-8")
|
|
27
|
-
self.send_response(HTTPStatus.OK)
|
|
28
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
29
|
-
self.send_header("Content-Length", str(len(encoded)))
|
|
30
|
-
self.send_header("Cache-Control", "no-store")
|
|
31
|
-
self.end_headers()
|
|
32
|
-
self.wfile.write(encoded)
|
|
33
|
-
return
|
|
34
|
-
return super().do_GET()
|
|
35
225
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
226
|
+
async def poll_snapshot_changes(interval: int = 5):
|
|
227
|
+
"""Periodically check for snapshot changes and broadcast to WS clients."""
|
|
228
|
+
last_snapshot = None
|
|
229
|
+
while True:
|
|
230
|
+
try:
|
|
231
|
+
current = build_snapshot()
|
|
232
|
+
if current != last_snapshot:
|
|
233
|
+
await broadcast_snapshot()
|
|
234
|
+
last_snapshot = current
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
await asyncio.sleep(interval)
|
|
40
238
|
|
|
41
239
|
|
|
42
|
-
def main(argv: list[str] | None = None) -> int:
|
|
43
|
-
parser = argparse.ArgumentParser(description="Serve the ACP worker dashboard.")
|
|
240
|
+
async def main(argv: list[str] | None = None) -> int:
|
|
241
|
+
parser = argparse.ArgumentParser(description="Serve the ACP worker dashboard with WebSocket support.")
|
|
44
242
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Default: 127.0.0.1")
|
|
45
243
|
parser.add_argument("--port", type=int, default=8765, help="Bind port. Default: 8765")
|
|
46
244
|
parser.add_argument(
|
|
@@ -48,17 +246,39 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
48
246
|
default="",
|
|
49
247
|
help="Override ACP profile registry root for dashboard snapshot generation.",
|
|
50
248
|
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--no-poll",
|
|
251
|
+
action="store_true",
|
|
252
|
+
help="Disable snapshot polling (manual refresh only)",
|
|
253
|
+
)
|
|
51
254
|
args = parser.parse_args(argv)
|
|
52
255
|
|
|
53
256
|
if args.registry_root:
|
|
54
257
|
os.environ["ACP_PROFILE_REGISTRY_ROOT"] = args.registry_root
|
|
55
258
|
|
|
56
|
-
|
|
57
|
-
|
|
259
|
+
app = build_app()
|
|
260
|
+
|
|
261
|
+
# Start background task for polling if not disabled
|
|
262
|
+
if not args.no_poll:
|
|
263
|
+
asyncio.create_task(poll_snapshot_changes())
|
|
264
|
+
|
|
265
|
+
runner = web.AppRunner(app)
|
|
266
|
+
await runner.setup()
|
|
267
|
+
site = web.TCPSite(runner, args.host, args.port)
|
|
268
|
+
await site.start()
|
|
269
|
+
|
|
58
270
|
print(f"ACP_DASHBOARD_URL=http://{args.host}:{args.port}", flush=True)
|
|
59
|
-
|
|
271
|
+
print(f"WebSocket endpoint: ws://{args.host}:{args.port}/ws", flush=True)
|
|
272
|
+
|
|
273
|
+
# Run forever
|
|
274
|
+
try:
|
|
275
|
+
await asyncio.Event().wait()
|
|
276
|
+
except (KeyboardInterrupt, SystemExit):
|
|
277
|
+
pass
|
|
278
|
+
finally:
|
|
279
|
+
await runner.cleanup()
|
|
60
280
|
return 0
|
|
61
281
|
|
|
62
282
|
|
|
63
283
|
if __name__ == "__main__":
|
|
64
|
-
raise SystemExit(main())
|
|
284
|
+
raise SystemExit(asyncio.run(main()))
|