agent-control-plane 0.3.0 → 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 +141 -28
- package/assets/workflow-catalog.json +1 -1
- package/bin/pr-risk.sh +22 -7
- package/bin/sync-pr-labels.sh +1 -1
- package/hooks/heartbeat-hooks.sh +125 -12
- package/hooks/issue-reconcile-hooks.sh +1 -1
- package/hooks/pr-reconcile-hooks.sh +1 -1
- package/npm/bin/agent-control-plane.js +257 -59
- package/package.json +39 -32
- package/tools/bin/debug-session.sh +106 -0
- package/tools/bin/flow-config-lib.sh +1203 -60
- package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
- package/tools/bin/flow-runtime-doctor.sh +5 -1
- package/tools/bin/flow-shell-lib.sh +32 -0
- package/tools/bin/github-core-rate-limit-state.sh +77 -0
- package/tools/bin/github-write-outbox.sh +470 -0
- package/tools/bin/heartbeat-loop-scheduling-lib.sh +7 -7
- package/tools/bin/heartbeat-safe-auto.sh +42 -0
- package/tools/bin/install-project-launchd.sh +17 -2
- package/tools/bin/install-project-systemd.sh +255 -0
- package/tools/bin/project-init.sh +21 -1
- package/tools/bin/project-launchd-bootstrap.sh +5 -1
- package/tools/bin/project-runtimectl.sh +91 -2
- package/tools/bin/project-systemd-bootstrap.sh +74 -0
- package/tools/bin/scaffold-profile.sh +61 -3
- package/tools/bin/uninstall-project-systemd.sh +87 -0
- package/tools/dashboard/app.js +228 -6
- package/tools/dashboard/dashboard_snapshot.py +55 -0
- 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/templates/pr-fix-template.md +3 -1
- package/tools/templates/pr-merge-repair-template.md +2 -1
- package/references/architecture.md +0 -217
- package/references/commands.md +0 -128
- package/references/control-plane-map.md +0 -124
- package/references/docs-map.md +0 -73
- package/references/release-checklist.md +0 -65
- package/references/repo-map.md +0 -36
- package/tools/bin/agent-cleanup-worktree +0 -247
- package/tools/bin/agent-github-update-labels +0 -71
- 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 -194
- 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 -465
- package/tools/bin/agent-project-reconcile-issue-session +0 -1398
- package/tools/bin/agent-project-reconcile-pr-session +0 -1230
- package/tools/bin/agent-project-retry-state +0 -147
- package/tools/bin/agent-project-run-claude-session +0 -805
- package/tools/bin/agent-project-run-codex-resilient +0 -955
- 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-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/resident-issue-queue-status.py +0 -35
- 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/split-retained-slice.sh +0 -124
- package/tools/bin/start-issue-worker.sh +0 -943
- package/tools/bin/start-pr-fix-worker.sh +0 -491
- 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
|
@@ -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:
|