agent-control-plane 0.6.0 → 0.7.1

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.
@@ -2,167 +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
  import subprocess
8
- from functools import partial
9
- from http import HTTPStatus
10
- from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
11
9
  from pathlib import Path
12
- from urllib.parse import urlparse, parse_qs
10
+ from functools import partial
11
+
12
+ from aiohttp import web, WSMsgType
13
+ from aiohttp_cors import setup as cors_setup, ResourceOptions
13
14
 
14
15
  from dashboard_snapshot import build_snapshot
15
16
 
16
17
  ROOT_DIR = Path(__file__).resolve().parents[2]
17
18
  TOOLS_BIN_DIR = ROOT_DIR / "tools" / "bin"
19
+ DASHBOARD_DIR = Path(__file__).resolve().parent
18
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
+ )
19
134
 
20
- DASHBOARD_DIR = Path(__file__).resolve().parent
21
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
+ )
22
175
 
23
- class DashboardHandler(SimpleHTTPRequestHandler):
24
- server_version = "ACPDashboard/1.0"
25
-
26
- def do_GET(self) -> None:
27
- parsed = urlparse(self.path)
28
- if parsed.path == "/api/snapshot.json":
29
- payload = build_snapshot()
30
- encoded = json.dumps(payload, indent=2).encode("utf-8")
31
- self.send_response(HTTPStatus.OK)
32
- self.send_header("Content-Type", "application/json; charset=utf-8")
33
- self.send_header("Content-Length", str(len(encoded)))
34
- self.send_header("Cache-Control", "no-store")
35
- self.end_headers()
36
- self.wfile.write(encoded)
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
156
- return super().do_GET()
157
-
158
- def end_headers(self) -> None:
159
- if self.path != "/api/snapshot.json":
160
- self.send_header("Cache-Control", "no-store")
161
- super().end_headers()
162
-
163
-
164
- def main(argv: list[str] | None = None) -> int:
165
- parser = argparse.ArgumentParser(description="Serve the ACP worker dashboard.")
176
+
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
224
+
225
+
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)
238
+
239
+
240
+ async def main(argv: list[str] | None = None) -> int:
241
+ parser = argparse.ArgumentParser(description="Serve the ACP worker dashboard with WebSocket support.")
166
242
  parser.add_argument("--host", default="127.0.0.1", help="Bind host. Default: 127.0.0.1")
167
243
  parser.add_argument("--port", type=int, default=8765, help="Bind port. Default: 8765")
168
244
  parser.add_argument(
@@ -170,17 +246,39 @@ def main(argv: list[str] | None = None) -> int:
170
246
  default="",
171
247
  help="Override ACP profile registry root for dashboard snapshot generation.",
172
248
  )
249
+ parser.add_argument(
250
+ "--no-poll",
251
+ action="store_true",
252
+ help="Disable snapshot polling (manual refresh only)",
253
+ )
173
254
  args = parser.parse_args(argv)
174
255
 
175
256
  if args.registry_root:
176
257
  os.environ["ACP_PROFILE_REGISTRY_ROOT"] = args.registry_root
177
258
 
178
- handler = partial(DashboardHandler, directory=str(DASHBOARD_DIR))
179
- 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
+
180
270
  print(f"ACP_DASHBOARD_URL=http://{args.host}:{args.port}", flush=True)
181
- 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()
182
280
  return 0
183
281
 
184
282
 
185
283
  if __name__ == "__main__":
186
- raise SystemExit(main())
284
+ raise SystemExit(asyncio.run(main()))