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
@@ -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
- from functools import partial
8
- from http import HTTPStatus
9
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
8
+ import subprocess
10
9
  from pathlib import Path
11
- from urllib.parse import urlparse
10
+ from functools import partial
12
11
 
13
- from dashboard_snapshot import build_snapshot
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
- class DashboardHandler(SimpleHTTPRequestHandler):
20
- server_version = "ACPDashboard/1.0"
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
- def end_headers(self) -> None:
37
- if self.path != "/api/snapshot.json":
38
- self.send_header("Cache-Control", "no-store")
39
- super().end_headers()
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
- handler = partial(DashboardHandler, directory=str(DASHBOARD_DIR))
57
- server = ThreadingHTTPServer((args.host, args.port), handler)
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
- server.serve_forever()
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()))