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.
- package/README.md +38 -5
- package/package.json +5 -3
- package/tools/bin/adapter-capabilities.sh +84 -0
- package/tools/bin/adapter-interface.sh +97 -0
- package/tools/bin/claude-adapter.sh +73 -0
- package/tools/bin/codex-adapter.sh +123 -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-session-lib.sh +317 -0
- package/tools/bin/kilo-adapter.sh +108 -0
- package/tools/bin/ollama-adapter.sh +160 -0
- package/tools/bin/openclaw-adapter.sh +69 -0
- package/tools/bin/opencode-adapter.sh +98 -0
- package/tools/bin/pi-adapter.sh +95 -0
- package/tools/bin/run-with-adapter.sh +34 -0
- package/tools/dashboard/app.js +40 -3
- package/tools/dashboard/requirements.txt +3 -0
- package/tools/dashboard/server.py +250 -152
|
@@ -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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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()))
|