@julioborges/gantry 1.0.6 → 1.1.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/.agents/skills/gantry/SKILL.md +15 -1
- package/.agents/skills/gantry/capabilities/codex.json +4 -3
- package/.agents/skills/gantry/dashboard/static/app.js +776 -30
- package/.agents/skills/gantry/dashboard/static/history.html +180 -0
- package/.agents/skills/gantry/dashboard/static/index.html +95 -0
- package/.agents/skills/gantry/dashboard/static/style.css +708 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +13 -0
- package/.agents/skills/gantry/reference/round-workflow.md +90 -2
- package/.agents/skills/gantry/scripts/common.py +3 -0
- package/.agents/skills/gantry/scripts/dashboard.py +664 -11
- package/.agents/skills/gantry/scripts/discovery.py +117 -8
- package/.agents/skills/gantry/scripts/execution.py +131 -14
- package/.agents/skills/gantry/scripts/plan.py +658 -0
- package/.agents/skills/gantry/scripts/result.py +56 -1
- package/.agents/skills/gantry/scripts/runlog.py +2 -1
- package/.agents/skills/gantry/scripts/setup.py +101 -8
- package/.agents/skills/gantry/scripts/wait_gate.py +179 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +21 -2
- package/.agents/skills/gantry-plan/SKILL.md +104 -0
- package/.agents/skills/gantry-setup/SKILL.md +9 -3
- package/README.md +125 -98
- package/package.json +2 -1
|
@@ -11,13 +11,20 @@ from __future__ import annotations
|
|
|
11
11
|
import argparse
|
|
12
12
|
import datetime
|
|
13
13
|
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
14
18
|
import sys
|
|
15
19
|
import threading
|
|
16
20
|
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
17
24
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
18
25
|
from pathlib import Path
|
|
19
26
|
|
|
20
|
-
from runlog import read_valid_events
|
|
27
|
+
from runlog import ISSUE_RE, RUN_ID_RE, UNIT_ID_RE, append_event, read_valid_events, run_log_path
|
|
21
28
|
from runlog import state_root as default_state_root
|
|
22
29
|
|
|
23
30
|
COLUMNS = ["Ready", "Plan", "Implement", "Review", "Critic", "Integrate", "Done", "Blocked"]
|
|
@@ -27,6 +34,8 @@ STATIC_FILES = {
|
|
|
27
34
|
"/index.html": "index.html",
|
|
28
35
|
"/app.js": "app.js",
|
|
29
36
|
"/style.css": "style.css",
|
|
37
|
+
"/history.html": "history.html",
|
|
38
|
+
"/history": "history.html",
|
|
30
39
|
}
|
|
31
40
|
MIME_TYPES = {
|
|
32
41
|
".html": "text/html; charset=utf-8",
|
|
@@ -60,6 +69,9 @@ def build_run(unit_id: str, events: list[dict], now: float) -> dict:
|
|
|
60
69
|
activity_events = [event for event in events if event["event"] not in IGNORED_FOR_ACTIVITY]
|
|
61
70
|
last_activity_ts = _parse_ts(activity_events[-1]["ts"]) if activity_events else _parse_ts(started["ts"])
|
|
62
71
|
|
|
72
|
+
repo_root = data.get("repositoryRoot", "")
|
|
73
|
+
project_name = Path(repo_root).name if repo_root else unit_id
|
|
74
|
+
|
|
63
75
|
issues: dict[str, dict] = {}
|
|
64
76
|
compaction_at = None
|
|
65
77
|
for event in events[1:]:
|
|
@@ -80,13 +92,45 @@ def build_run(unit_id: str, events: list[dict], now: float) -> dict:
|
|
|
80
92
|
"models": {},
|
|
81
93
|
"correctionBudget": None,
|
|
82
94
|
"phaseStartedAt": None,
|
|
95
|
+
"firstPhaseStartedAt": None,
|
|
96
|
+
"completedAt": None,
|
|
97
|
+
"totalCycleSeconds": None,
|
|
98
|
+
"phaseDurations": {
|
|
99
|
+
"Plan": 0,
|
|
100
|
+
"Implement": 0,
|
|
101
|
+
"Review": 0,
|
|
102
|
+
"Critic": 0,
|
|
103
|
+
"Integrate": 0,
|
|
104
|
+
},
|
|
105
|
+
"_currentPhase": None,
|
|
106
|
+
"_currentPhaseStartedAt": None,
|
|
83
107
|
"operatorWaiting": False,
|
|
108
|
+
"project": project_name,
|
|
109
|
+
"unitId": unit_id,
|
|
110
|
+
"run": started["run"],
|
|
111
|
+
"repositoryRoot": repo_root,
|
|
84
112
|
},
|
|
85
113
|
)
|
|
86
114
|
edata = event.get("data") or {}
|
|
115
|
+
ts = event["ts"]
|
|
87
116
|
if name == "phase.started":
|
|
88
|
-
|
|
89
|
-
state["
|
|
117
|
+
phase = event["phase"]
|
|
118
|
+
if state["firstPhaseStartedAt"] is None:
|
|
119
|
+
state["firstPhaseStartedAt"] = ts
|
|
120
|
+
|
|
121
|
+
if state["_currentPhase"] is not None and state["_currentPhaseStartedAt"] is not None:
|
|
122
|
+
prev_phase = state["_currentPhase"]
|
|
123
|
+
prev_dur = max(0, int(_parse_ts(ts) - _parse_ts(state["_currentPhaseStartedAt"])))
|
|
124
|
+
state["phaseDurations"][prev_phase] = state["phaseDurations"].get(prev_phase, 0) + prev_dur
|
|
125
|
+
|
|
126
|
+
state["_currentPhase"] = phase
|
|
127
|
+
state["_currentPhaseStartedAt"] = ts
|
|
128
|
+
state["column"] = phase
|
|
129
|
+
state["phaseStartedAt"] = ts
|
|
130
|
+
if phase == "Integrate":
|
|
131
|
+
state["operatorWaiting"] = False
|
|
132
|
+
elif "operatorWaiting" in edata:
|
|
133
|
+
state["operatorWaiting"] = bool(edata.get("operatorWaiting", False))
|
|
90
134
|
if "branch" in edata:
|
|
91
135
|
state["branch"] = edata["branch"]
|
|
92
136
|
if "worktree" in edata:
|
|
@@ -95,21 +139,74 @@ def build_run(unit_id: str, events: list[dict], now: float) -> dict:
|
|
|
95
139
|
state["models"] = dict(edata["models"])
|
|
96
140
|
if "correctionBudget" in edata:
|
|
97
141
|
state["correctionBudget"] = edata["correctionBudget"]
|
|
98
|
-
|
|
142
|
+
elif name == "phase.finished":
|
|
143
|
+
finished_phase = event.get("phase")
|
|
144
|
+
if state["_currentPhase"] == finished_phase and state["_currentPhaseStartedAt"] is not None:
|
|
145
|
+
dur = max(0, int(_parse_ts(ts) - _parse_ts(state["_currentPhaseStartedAt"])))
|
|
146
|
+
state["phaseDurations"][finished_phase] = state["phaseDurations"].get(finished_phase, 0) + dur
|
|
147
|
+
state["_currentPhase"] = None
|
|
148
|
+
state["_currentPhaseStartedAt"] = None
|
|
149
|
+
if finished_phase == "Critic":
|
|
150
|
+
state["operatorWaiting"] = True
|
|
151
|
+
elif name == "refutation":
|
|
152
|
+
state["operatorWaiting"] = False
|
|
153
|
+
elif name == "operator.approved":
|
|
154
|
+
state["operatorWaiting"] = False
|
|
155
|
+
state["operatorApproved"] = True
|
|
99
156
|
elif name == "subagent.started":
|
|
100
157
|
role = edata.get("role")
|
|
101
158
|
if role:
|
|
102
159
|
state["models"][role] = edata.get("model")
|
|
103
160
|
elif name == "issue.done":
|
|
104
161
|
state["column"] = "Done"
|
|
162
|
+
state["operatorWaiting"] = False
|
|
163
|
+
state["completedAt"] = ts
|
|
164
|
+
if state["_currentPhase"] is not None and state["_currentPhaseStartedAt"] is not None:
|
|
165
|
+
dur = max(0, int(_parse_ts(ts) - _parse_ts(state["_currentPhaseStartedAt"])))
|
|
166
|
+
state["phaseDurations"][state["_currentPhase"]] = state["phaseDurations"].get(state["_currentPhase"], 0) + dur
|
|
167
|
+
state["_currentPhase"] = None
|
|
168
|
+
state["_currentPhaseStartedAt"] = None
|
|
105
169
|
elif name == "issue.blocked":
|
|
106
170
|
state["column"] = "Blocked"
|
|
171
|
+
state["operatorWaiting"] = False
|
|
172
|
+
state["completedAt"] = ts
|
|
173
|
+
if state["_currentPhase"] is not None and state["_currentPhaseStartedAt"] is not None:
|
|
174
|
+
dur = max(0, int(_parse_ts(ts) - _parse_ts(state["_currentPhaseStartedAt"])))
|
|
175
|
+
state["phaseDurations"][state["_currentPhase"]] = state["phaseDurations"].get(state["_currentPhase"], 0) + dur
|
|
176
|
+
state["_currentPhase"] = None
|
|
177
|
+
state["_currentPhaseStartedAt"] = None
|
|
178
|
+
elif name == "issue.paused":
|
|
179
|
+
state["operatorWaiting"] = False
|
|
107
180
|
|
|
108
181
|
for state in issues.values():
|
|
109
|
-
|
|
110
|
-
|
|
182
|
+
is_done = state["column"] == "Done" or state["completedAt"] is not None
|
|
183
|
+
if is_done:
|
|
184
|
+
if state["completedAt"] is not None and state["firstPhaseStartedAt"] is not None:
|
|
185
|
+
state["totalCycleSeconds"] = max(0, int(_parse_ts(state["completedAt"]) - _parse_ts(state["firstPhaseStartedAt"])))
|
|
186
|
+
else:
|
|
187
|
+
state["totalCycleSeconds"] = sum(state["phaseDurations"].values())
|
|
188
|
+
|
|
189
|
+
if state["phaseStartedAt"] is not None and state["completedAt"] is not None:
|
|
190
|
+
state["elapsedPhaseSeconds"] = max(0, int(_parse_ts(state["completedAt"]) - _parse_ts(state["phaseStartedAt"])))
|
|
191
|
+
else:
|
|
192
|
+
state["elapsedPhaseSeconds"] = None
|
|
111
193
|
else:
|
|
112
|
-
state["
|
|
194
|
+
if state["_currentPhase"] is not None and state["_currentPhaseStartedAt"] is not None:
|
|
195
|
+
active_elapsed = max(0, int(now - _parse_ts(state["_currentPhaseStartedAt"])))
|
|
196
|
+
state["phaseDurations"][state["_currentPhase"]] = state["phaseDurations"].get(state["_currentPhase"], 0) + active_elapsed
|
|
197
|
+
|
|
198
|
+
if state["phaseStartedAt"] is not None:
|
|
199
|
+
state["elapsedPhaseSeconds"] = max(0, int(now - _parse_ts(state["phaseStartedAt"])))
|
|
200
|
+
else:
|
|
201
|
+
state["elapsedPhaseSeconds"] = None
|
|
202
|
+
|
|
203
|
+
if state["firstPhaseStartedAt"] is not None:
|
|
204
|
+
state["totalCycleSeconds"] = max(0, int(now - _parse_ts(state["firstPhaseStartedAt"])))
|
|
205
|
+
else:
|
|
206
|
+
state["totalCycleSeconds"] = None
|
|
207
|
+
|
|
208
|
+
state.pop("_currentPhase", None)
|
|
209
|
+
state.pop("_currentPhaseStartedAt", None)
|
|
113
210
|
|
|
114
211
|
return {
|
|
115
212
|
"unitId": unit_id,
|
|
@@ -124,6 +221,116 @@ def build_run(unit_id: str, events: list[dict], now: float) -> dict:
|
|
|
124
221
|
}
|
|
125
222
|
|
|
126
223
|
|
|
224
|
+
def collect_projects(root: Path, runs: list[dict] | None = None) -> list[dict]:
|
|
225
|
+
"""Collect project metadata across all execution units."""
|
|
226
|
+
if runs is None:
|
|
227
|
+
runs = collect_runs(root)
|
|
228
|
+
projects_by_unit: dict[str, dict] = {}
|
|
229
|
+
for run in runs:
|
|
230
|
+
unit_id = run["unitId"]
|
|
231
|
+
repo_root = run.get("repositoryRoot", "")
|
|
232
|
+
name = Path(repo_root).name if repo_root else unit_id
|
|
233
|
+
if unit_id not in projects_by_unit:
|
|
234
|
+
projects_by_unit[unit_id] = {
|
|
235
|
+
"unitId": unit_id,
|
|
236
|
+
"name": name,
|
|
237
|
+
"repositoryRoot": repo_root,
|
|
238
|
+
}
|
|
239
|
+
if root.exists():
|
|
240
|
+
for unit_dir in sorted(path for path in root.iterdir() if path.is_dir()):
|
|
241
|
+
if unit_dir.name not in projects_by_unit:
|
|
242
|
+
projects_by_unit[unit_dir.name] = {
|
|
243
|
+
"unitId": unit_dir.name,
|
|
244
|
+
"name": unit_dir.name,
|
|
245
|
+
"repositoryRoot": "",
|
|
246
|
+
}
|
|
247
|
+
return sorted(projects_by_unit.values(), key=lambda p: p["name"].lower())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def find_transcript_file(state_root: Path, unit_id: str, run_id: str, issue_ref: str, worktree: str | None = None) -> Path | None:
|
|
251
|
+
"""Find transcript.jsonl under state root, worktree, or harness session logs."""
|
|
252
|
+
# 1. State root transcripts location: ~/.gantry/state/<unit>/transcripts/<run>/<issue>/transcript.jsonl
|
|
253
|
+
p1 = state_root / unit_id / "transcripts" / run_id / issue_ref / "transcript.jsonl"
|
|
254
|
+
if p1.exists():
|
|
255
|
+
return p1
|
|
256
|
+
|
|
257
|
+
# 2. State root artifact/session location
|
|
258
|
+
p2 = state_root / unit_id / "runs" / f"{run_id}.transcript.jsonl"
|
|
259
|
+
if p2.exists():
|
|
260
|
+
return p2
|
|
261
|
+
|
|
262
|
+
# 3. State root issue transcript: ~/.gantry/state/<unit>/transcripts/<issue>/transcript.jsonl
|
|
263
|
+
p3 = state_root / unit_id / "transcripts" / issue_ref / "transcript.jsonl"
|
|
264
|
+
if p3.exists():
|
|
265
|
+
return p3
|
|
266
|
+
|
|
267
|
+
# 4. Worktree-local logs: <worktree>/.system_generated/logs/transcript.jsonl
|
|
268
|
+
if worktree:
|
|
269
|
+
wt_path = Path(worktree)
|
|
270
|
+
p4 = wt_path / ".system_generated" / "logs" / "transcript.jsonl"
|
|
271
|
+
if p4.exists():
|
|
272
|
+
return p4
|
|
273
|
+
p5 = wt_path / ".gantry" / "transcripts" / f"{issue_ref}.jsonl"
|
|
274
|
+
if p5.exists():
|
|
275
|
+
return p5
|
|
276
|
+
|
|
277
|
+
# 5. Check if active marker in repo/worktree matches this run
|
|
278
|
+
if worktree:
|
|
279
|
+
marker_file = Path(worktree) / ".git" / "gantry" / "current-run.json"
|
|
280
|
+
if marker_file.exists():
|
|
281
|
+
try:
|
|
282
|
+
mdata = json.loads(marker_file.read_text(encoding="utf-8"))
|
|
283
|
+
if mdata.get("run") == run_id:
|
|
284
|
+
brain_root = Path.home() / ".gemini" / "antigravity-cli" / "brain"
|
|
285
|
+
if brain_root.exists():
|
|
286
|
+
candidates = sorted(brain_root.glob("*/.system_generated/logs/transcript.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
287
|
+
if candidates:
|
|
288
|
+
return candidates[0]
|
|
289
|
+
except (OSError, json.JSONDecodeError):
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def read_transcript_steps(transcript_path: Path | None) -> list[dict]:
|
|
296
|
+
"""Read transcript steps safely without throwing."""
|
|
297
|
+
if not transcript_path or not transcript_path.exists():
|
|
298
|
+
return []
|
|
299
|
+
steps: list[dict] = []
|
|
300
|
+
try:
|
|
301
|
+
with transcript_path.open("r", encoding="utf-8") as handle:
|
|
302
|
+
for line in handle:
|
|
303
|
+
line = line.strip()
|
|
304
|
+
if not line:
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
steps.append(json.loads(line))
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
continue
|
|
310
|
+
except OSError:
|
|
311
|
+
return []
|
|
312
|
+
return steps
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def derive_live_activity(steps: list[dict], operator_waiting: bool = False) -> str | None:
|
|
316
|
+
"""Derive live activity status badge from latest transcript steps."""
|
|
317
|
+
if operator_waiting:
|
|
318
|
+
return "Awaiting Operator"
|
|
319
|
+
if not steps:
|
|
320
|
+
return None
|
|
321
|
+
for step in reversed(steps):
|
|
322
|
+
tool_calls = step.get("tool_calls")
|
|
323
|
+
if tool_calls and isinstance(tool_calls, list) and len(tool_calls) > 0:
|
|
324
|
+
tool_name = tool_calls[-1].get("name", "tool")
|
|
325
|
+
return f"Tool: {tool_name}"
|
|
326
|
+
thinking = step.get("thinking")
|
|
327
|
+
if thinking and isinstance(thinking, str) and thinking.strip():
|
|
328
|
+
return "Thinking..."
|
|
329
|
+
if step.get("type") == "PLANNER_RESPONSE":
|
|
330
|
+
return "Thinking..."
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
127
334
|
def collect_runs(root: Path, now: float | None = None) -> list[dict]:
|
|
128
335
|
"""Read every Run log under every execution unit's state root."""
|
|
129
336
|
now = time.time() if now is None else now
|
|
@@ -138,10 +345,78 @@ def collect_runs(root: Path, now: float | None = None) -> list[dict]:
|
|
|
138
345
|
events = read_valid_events(log_path)
|
|
139
346
|
if not events or events[0]["event"] != "run.started":
|
|
140
347
|
continue
|
|
141
|
-
|
|
348
|
+
run_data = build_run(unit_dir.name, events, now)
|
|
349
|
+
# Enrich issues with liveActivity if transcript is found
|
|
350
|
+
for issue in run_data.get("issues", []):
|
|
351
|
+
approval_file = root / run_data["unitId"] / "approvals" / f"{issue['issue']}.json"
|
|
352
|
+
if approval_file.exists():
|
|
353
|
+
issue["operatorWaiting"] = False
|
|
354
|
+
issue["operatorApproved"] = True
|
|
355
|
+
|
|
356
|
+
t_file = find_transcript_file(root, run_data["unitId"], run_data["run"], issue["issue"], issue.get("worktree"))
|
|
357
|
+
if t_file:
|
|
358
|
+
steps = read_transcript_steps(t_file)
|
|
359
|
+
activity = derive_live_activity(steps, issue.get("operatorWaiting", False))
|
|
360
|
+
if activity:
|
|
361
|
+
issue["liveActivity"] = activity
|
|
362
|
+
elif issue.get("operatorWaiting"):
|
|
363
|
+
issue["liveActivity"] = "Awaiting Operator"
|
|
364
|
+
runs.append(run_data)
|
|
142
365
|
return runs
|
|
143
366
|
|
|
144
367
|
|
|
368
|
+
def read_gates_for_issue(state_root: Path, unit_id: str, run_id: str, issue_ref: str) -> dict[str, dict]:
|
|
369
|
+
"""Read structured gate artifacts from ~/.gantry/state/<unit>/artifacts/<run>/<issue>/gate-<phase>.json."""
|
|
370
|
+
gates: dict[str, dict] = {}
|
|
371
|
+
artifact_dir = state_root / unit_id / "artifacts" / run_id / issue_ref
|
|
372
|
+
if artifact_dir.exists():
|
|
373
|
+
for path in sorted(artifact_dir.glob("gate-*.json")):
|
|
374
|
+
phase_name = path.stem.removeprefix("gate-").lower()
|
|
375
|
+
try:
|
|
376
|
+
gates[phase_name] = json.loads(path.read_text(encoding="utf-8"))
|
|
377
|
+
except (OSError, json.JSONDecodeError):
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
log_path = run_log_path(state_root, unit_id, run_id)
|
|
381
|
+
if log_path.exists():
|
|
382
|
+
events = read_valid_events(log_path)
|
|
383
|
+
for event in events:
|
|
384
|
+
if event.get("issue") != issue_ref:
|
|
385
|
+
continue
|
|
386
|
+
ename = event["event"]
|
|
387
|
+
edata = event.get("data") or {}
|
|
388
|
+
if "plan" not in gates and ename == "subagent.stopped" and edata.get("role") in ("planner", "plan-critic"):
|
|
389
|
+
res = edata.get("result") or {}
|
|
390
|
+
gates["plan"] = {
|
|
391
|
+
"verdict": res.get("verdict", "accepted"),
|
|
392
|
+
"criteria": res.get("criteria", []),
|
|
393
|
+
"scope": res.get("scope", []),
|
|
394
|
+
}
|
|
395
|
+
elif "critic" not in gates and ename == "subagent.stopped" and edata.get("role") == "critic":
|
|
396
|
+
res = edata.get("result") or {}
|
|
397
|
+
gates["critic"] = {
|
|
398
|
+
"complete": res.get("complete", True),
|
|
399
|
+
"verdict": "complete" if res.get("complete") else "refuted",
|
|
400
|
+
"criteria": res.get("criteria", []),
|
|
401
|
+
"evidence": res.get("evidence", []),
|
|
402
|
+
"gateFailures": res.get("gateFailures", []),
|
|
403
|
+
"requiredFixes": res.get("requiredFixes", []),
|
|
404
|
+
}
|
|
405
|
+
elif "implement" not in gates and ename == "phase.finished" and event.get("phase") == "Implement":
|
|
406
|
+
gates["implement"] = {
|
|
407
|
+
"verdict": "tests_passed",
|
|
408
|
+
"tddProofs": True,
|
|
409
|
+
"attempt": edata.get("attempt", 1),
|
|
410
|
+
}
|
|
411
|
+
elif "integrate" not in gates and ename == "issue.done":
|
|
412
|
+
gates["integrate"] = {
|
|
413
|
+
"verdict": "merged",
|
|
414
|
+
"worktree": edata.get("worktree"),
|
|
415
|
+
"strategy": edata.get("strategy", "branch-merge"),
|
|
416
|
+
}
|
|
417
|
+
return gates
|
|
418
|
+
|
|
419
|
+
|
|
145
420
|
def make_handler(state_root: Path) -> type[BaseHTTPRequestHandler]:
|
|
146
421
|
class DashboardRequestHandler(BaseHTTPRequestHandler):
|
|
147
422
|
server_version = "GantryDashboard/1"
|
|
@@ -149,6 +424,16 @@ def make_handler(state_root: Path) -> type[BaseHTTPRequestHandler]:
|
|
|
149
424
|
def log_message(self, format: str, *args: object) -> None: # noqa: A002 - stdlib signature
|
|
150
425
|
pass
|
|
151
426
|
|
|
427
|
+
def _check_loopback(self) -> bool:
|
|
428
|
+
client_ip = self.client_address[0]
|
|
429
|
+
if client_ip not in ("127.0.0.1", "::ffff:127.0.0.1"):
|
|
430
|
+
self.send_response(403)
|
|
431
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
432
|
+
self.end_headers()
|
|
433
|
+
self.wfile.write(b"forbidden: loopback only")
|
|
434
|
+
return False
|
|
435
|
+
return True
|
|
436
|
+
|
|
152
437
|
def _send_json(self, status: int, payload: object) -> None:
|
|
153
438
|
body = json.dumps(payload).encode("utf-8")
|
|
154
439
|
self.send_response(status)
|
|
@@ -176,20 +461,114 @@ def make_handler(state_root: Path) -> type[BaseHTTPRequestHandler]:
|
|
|
176
461
|
self.end_headers()
|
|
177
462
|
self.wfile.write(body)
|
|
178
463
|
|
|
464
|
+
def _unquote(self, s: str) -> str:
|
|
465
|
+
return re.sub(r"%([0-9a-fA-F]{2})", lambda m: chr(int(m.group(1), 16)), s)
|
|
466
|
+
|
|
179
467
|
def do_GET(self) -> None: # noqa: N802 - stdlib method name
|
|
180
|
-
|
|
468
|
+
if not self._check_loopback():
|
|
469
|
+
return
|
|
470
|
+
raw_path = self.path.split("?", 1)[0]
|
|
471
|
+
# Strip fragment if raw_path contained '#' without being percent-encoded
|
|
472
|
+
path = self._unquote(raw_path)
|
|
181
473
|
if path == "/api/state":
|
|
182
|
-
|
|
474
|
+
runs = collect_runs(state_root)
|
|
475
|
+
projects = collect_projects(state_root, runs)
|
|
476
|
+
self._send_json(200, {"columns": COLUMNS, "runs": runs, "projects": projects})
|
|
183
477
|
return
|
|
478
|
+
|
|
479
|
+
parts = [self._unquote(p) for p in raw_path.split("/") if p]
|
|
480
|
+
if len(parts) == 7 and parts[0] == "api" and parts[1] == "runs" and parts[4] == "issues":
|
|
481
|
+
unit_id = parts[2]
|
|
482
|
+
run_id = parts[3]
|
|
483
|
+
issue_ref = parts[5]
|
|
484
|
+
action = parts[6]
|
|
485
|
+
if action == "transcript":
|
|
486
|
+
t_file = find_transcript_file(state_root, unit_id, run_id, issue_ref)
|
|
487
|
+
steps = read_transcript_steps(t_file) if t_file else []
|
|
488
|
+
self._send_json(200, {"steps": steps})
|
|
489
|
+
return
|
|
490
|
+
elif action == "gates":
|
|
491
|
+
gates = read_gates_for_issue(state_root, unit_id, run_id, issue_ref)
|
|
492
|
+
self._send_json(200, {"gates": gates})
|
|
493
|
+
return
|
|
494
|
+
|
|
184
495
|
filename = STATIC_FILES.get(path)
|
|
185
496
|
if filename is not None:
|
|
186
497
|
self._send_static(filename)
|
|
187
498
|
return
|
|
188
499
|
self._send_not_found()
|
|
189
500
|
|
|
501
|
+
def do_POST(self) -> None: # noqa: N802 - stdlib method name
|
|
502
|
+
if not self._check_loopback():
|
|
503
|
+
return
|
|
504
|
+
raw_path = self.path.split("?", 1)[0]
|
|
505
|
+
path = self._unquote(raw_path)
|
|
506
|
+
if path == "/api/state":
|
|
507
|
+
self.send_response(405)
|
|
508
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
509
|
+
self.end_headers()
|
|
510
|
+
self.wfile.write(b"method not allowed")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if path == "/api/shutdown":
|
|
514
|
+
self._send_json(200, {"status": "shutting_down"})
|
|
515
|
+
def _async_shutdown() -> None:
|
|
516
|
+
time.sleep(0.1)
|
|
517
|
+
self.server.shutdown()
|
|
518
|
+
threading.Thread(target=_async_shutdown, daemon=True).start()
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
parts = [self._unquote(p) for p in raw_path.split("/") if p]
|
|
522
|
+
if len(parts) == 7 and parts[0] == "api" and parts[1] == "runs" and parts[4] == "issues" and parts[6] == "approve":
|
|
523
|
+
unit_id = parts[2]
|
|
524
|
+
run_id = parts[3]
|
|
525
|
+
issue_ref = parts[5]
|
|
526
|
+
|
|
527
|
+
if not UNIT_ID_RE.fullmatch(unit_id) or not RUN_ID_RE.fullmatch(run_id) or not ISSUE_RE.fullmatch(issue_ref):
|
|
528
|
+
self._send_json(400, {"error": "invalid identifier format"})
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
approvals_dir = state_root / unit_id / "approvals"
|
|
532
|
+
approvals_dir.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
marker_file = approvals_dir / f"{issue_ref}.json"
|
|
534
|
+
approved_at = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
535
|
+
payload = {
|
|
536
|
+
"unit": unit_id,
|
|
537
|
+
"run": run_id,
|
|
538
|
+
"issue": issue_ref,
|
|
539
|
+
"approvedAt": approved_at,
|
|
540
|
+
"source": "dashboard",
|
|
541
|
+
}
|
|
542
|
+
marker_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
543
|
+
|
|
544
|
+
log_path = run_log_path(state_root, unit_id, run_id)
|
|
545
|
+
if log_path.exists():
|
|
546
|
+
try:
|
|
547
|
+
append_event(
|
|
548
|
+
log_path,
|
|
549
|
+
{
|
|
550
|
+
"ts": approved_at,
|
|
551
|
+
"run": run_id,
|
|
552
|
+
"event": "operator.approved",
|
|
553
|
+
"issue": issue_ref,
|
|
554
|
+
"data": {
|
|
555
|
+
"approvedAt": approved_at,
|
|
556
|
+
"source": "dashboard",
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
)
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
self._send_json(200, {"status": "ok", "issue": issue_ref, "approved": True, "approvedAt": approved_at})
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
self._send_not_found()
|
|
567
|
+
|
|
190
568
|
return DashboardRequestHandler
|
|
191
569
|
|
|
192
570
|
|
|
571
|
+
|
|
193
572
|
def create_server(host: str, port: int, state_root: Path) -> ThreadingHTTPServer:
|
|
194
573
|
"""Build a validated, loopback-only HTTP server for the dashboard."""
|
|
195
574
|
require_loopback_host(host)
|
|
@@ -198,8 +577,219 @@ def create_server(host: str, port: int, state_root: Path) -> ThreadingHTTPServer
|
|
|
198
577
|
return server
|
|
199
578
|
|
|
200
579
|
|
|
580
|
+
def dashboard_state_path(state_root: Path) -> Path:
|
|
581
|
+
return state_root / "dashboard.json"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def write_dashboard_state(state_root: Path, host: str, port: int, pid: int) -> None:
|
|
585
|
+
state_file = dashboard_state_path(state_root)
|
|
586
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
587
|
+
payload = {
|
|
588
|
+
"running": True,
|
|
589
|
+
"host": host,
|
|
590
|
+
"port": port,
|
|
591
|
+
"pid": pid,
|
|
592
|
+
"stateRoot": str(state_root),
|
|
593
|
+
"url": f"http://{host}:{port}",
|
|
594
|
+
}
|
|
595
|
+
state_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def remove_dashboard_state(state_root: Path) -> None:
|
|
599
|
+
state_file = dashboard_state_path(state_root)
|
|
600
|
+
try:
|
|
601
|
+
state_file.unlink(missing_ok=True)
|
|
602
|
+
except OSError:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def is_pid_alive(pid: int) -> bool:
|
|
607
|
+
if pid <= 0:
|
|
608
|
+
return False
|
|
609
|
+
try:
|
|
610
|
+
os.kill(pid, 0)
|
|
611
|
+
return True
|
|
612
|
+
except OSError:
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def is_port_in_use(host: str, port: int) -> bool:
|
|
617
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
618
|
+
try:
|
|
619
|
+
s.bind((host, port))
|
|
620
|
+
return False
|
|
621
|
+
except OSError:
|
|
622
|
+
return True
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def probe_dashboard(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
626
|
+
try:
|
|
627
|
+
req = urllib.request.Request(f"http://{host}:{port}/api/state")
|
|
628
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
629
|
+
if resp.status == 200:
|
|
630
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
631
|
+
return isinstance(data, dict) and "columns" in data and "runs" in data
|
|
632
|
+
except Exception:
|
|
633
|
+
return False
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def get_dashboard_status(state_root: Path, host: str = "127.0.0.1", port: int = 4600) -> dict:
|
|
638
|
+
state_file = dashboard_state_path(state_root)
|
|
639
|
+
if state_file.exists():
|
|
640
|
+
try:
|
|
641
|
+
data = json.loads(state_file.read_text(encoding="utf-8"))
|
|
642
|
+
pid = data.get("pid")
|
|
643
|
+
h = data.get("host", host)
|
|
644
|
+
p = data.get("port", port)
|
|
645
|
+
alive = isinstance(pid, int) and is_pid_alive(pid)
|
|
646
|
+
responding = probe_dashboard(h, p, timeout=0.5)
|
|
647
|
+
if alive and responding:
|
|
648
|
+
return {
|
|
649
|
+
"running": True,
|
|
650
|
+
"host": h,
|
|
651
|
+
"port": p,
|
|
652
|
+
"pid": pid,
|
|
653
|
+
"stateRoot": str(state_root),
|
|
654
|
+
"url": f"http://{h}:{p}",
|
|
655
|
+
}
|
|
656
|
+
else:
|
|
657
|
+
remove_dashboard_state(state_root)
|
|
658
|
+
except Exception:
|
|
659
|
+
remove_dashboard_state(state_root)
|
|
660
|
+
|
|
661
|
+
if probe_dashboard(host, port, timeout=0.5):
|
|
662
|
+
return {
|
|
663
|
+
"running": True,
|
|
664
|
+
"host": host,
|
|
665
|
+
"port": port,
|
|
666
|
+
"pid": None,
|
|
667
|
+
"stateRoot": str(state_root),
|
|
668
|
+
"url": f"http://{host}:{port}",
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
"running": False,
|
|
673
|
+
"host": host,
|
|
674
|
+
"port": port,
|
|
675
|
+
"pid": None,
|
|
676
|
+
"stateRoot": str(state_root),
|
|
677
|
+
"url": None,
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def start_daemon(host: str, port: int, state_root: Path) -> dict:
|
|
682
|
+
require_loopback_host(host)
|
|
683
|
+
current = get_dashboard_status(state_root, host, port)
|
|
684
|
+
if current["running"]:
|
|
685
|
+
return current
|
|
686
|
+
|
|
687
|
+
if is_port_in_use(host, port):
|
|
688
|
+
raise DashboardError(f"port {port} is occupied by an alien process")
|
|
689
|
+
|
|
690
|
+
cmd = [
|
|
691
|
+
sys.executable,
|
|
692
|
+
str(Path(__file__).resolve()),
|
|
693
|
+
"serve",
|
|
694
|
+
"--host",
|
|
695
|
+
host,
|
|
696
|
+
"--port",
|
|
697
|
+
str(port),
|
|
698
|
+
"--state-root",
|
|
699
|
+
str(state_root),
|
|
700
|
+
]
|
|
701
|
+
proc = subprocess.Popen(
|
|
702
|
+
cmd,
|
|
703
|
+
stdin=subprocess.DEVNULL,
|
|
704
|
+
stdout=subprocess.DEVNULL,
|
|
705
|
+
stderr=subprocess.DEVNULL,
|
|
706
|
+
start_new_session=True,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
deadline = time.time() + 5.0
|
|
710
|
+
ready = False
|
|
711
|
+
while time.time() < deadline:
|
|
712
|
+
if probe_dashboard(host, port, timeout=0.5):
|
|
713
|
+
ready = True
|
|
714
|
+
break
|
|
715
|
+
time.sleep(0.1)
|
|
716
|
+
|
|
717
|
+
if not ready:
|
|
718
|
+
try:
|
|
719
|
+
proc.terminate()
|
|
720
|
+
except OSError:
|
|
721
|
+
pass
|
|
722
|
+
raise DashboardError(f"timed out waiting for dashboard daemon on port {port}")
|
|
723
|
+
|
|
724
|
+
write_dashboard_state(state_root, host, port, proc.pid)
|
|
725
|
+
return {
|
|
726
|
+
"running": True,
|
|
727
|
+
"host": host,
|
|
728
|
+
"port": port,
|
|
729
|
+
"pid": proc.pid,
|
|
730
|
+
"stateRoot": str(state_root),
|
|
731
|
+
"url": f"http://{host}:{port}",
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def stop_daemon(host: str, port: int, state_root: Path) -> dict:
|
|
736
|
+
current = get_dashboard_status(state_root, host, port)
|
|
737
|
+
target_host = current.get("host") or host
|
|
738
|
+
target_port = current.get("port") or port
|
|
739
|
+
pid = current.get("pid")
|
|
740
|
+
|
|
741
|
+
if not current["running"] and not is_port_in_use(target_host, target_port):
|
|
742
|
+
remove_dashboard_state(state_root)
|
|
743
|
+
return {
|
|
744
|
+
"running": False,
|
|
745
|
+
"host": target_host,
|
|
746
|
+
"port": target_port,
|
|
747
|
+
"pid": None,
|
|
748
|
+
"stateRoot": str(state_root),
|
|
749
|
+
"stopped": False,
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
req = urllib.request.Request(
|
|
754
|
+
f"http://{target_host}:{target_port}/api/shutdown",
|
|
755
|
+
data=b"{}",
|
|
756
|
+
headers={"Content-Type": "application/json"},
|
|
757
|
+
method="POST",
|
|
758
|
+
)
|
|
759
|
+
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
|
760
|
+
pass
|
|
761
|
+
except Exception:
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
deadline = time.time() + 5.0
|
|
765
|
+
while time.time() < deadline:
|
|
766
|
+
if not is_port_in_use(target_host, target_port):
|
|
767
|
+
break
|
|
768
|
+
time.sleep(0.1)
|
|
769
|
+
|
|
770
|
+
if pid and is_pid_alive(pid):
|
|
771
|
+
try:
|
|
772
|
+
os.kill(pid, 15)
|
|
773
|
+
time.sleep(0.2)
|
|
774
|
+
if is_pid_alive(pid):
|
|
775
|
+
os.kill(pid, 9)
|
|
776
|
+
except OSError:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
remove_dashboard_state(state_root)
|
|
780
|
+
return {
|
|
781
|
+
"running": False,
|
|
782
|
+
"host": target_host,
|
|
783
|
+
"port": target_port,
|
|
784
|
+
"pid": None,
|
|
785
|
+
"stateRoot": str(state_root),
|
|
786
|
+
"stopped": True,
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
|
|
201
790
|
def serve(host: str, port: int, state_root: Path) -> None:
|
|
202
791
|
server = create_server(host, port, state_root)
|
|
792
|
+
write_dashboard_state(state_root, host, server.server_port, os.getpid())
|
|
203
793
|
print(json.dumps({"host": host, "port": server.server_port, "stateRoot": str(state_root)}))
|
|
204
794
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
205
795
|
thread.start()
|
|
@@ -207,25 +797,88 @@ def serve(host: str, port: int, state_root: Path) -> None:
|
|
|
207
797
|
thread.join()
|
|
208
798
|
except KeyboardInterrupt:
|
|
209
799
|
server.shutdown()
|
|
800
|
+
finally:
|
|
801
|
+
remove_dashboard_state(state_root)
|
|
210
802
|
|
|
211
803
|
|
|
212
804
|
def main(argv: list[str] | None = None) -> int:
|
|
213
805
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
214
806
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
807
|
+
|
|
215
808
|
serve_parser = subparsers.add_parser("serve", help="serve the read-only kanban dashboard")
|
|
216
809
|
serve_parser.add_argument("--host", default="127.0.0.1", help="must be 127.0.0.1")
|
|
217
810
|
serve_parser.add_argument("--port", type=int, default=4600, help="TCP port, 0 for an ephemeral port")
|
|
218
811
|
serve_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
812
|
+
|
|
813
|
+
status_parser = subparsers.add_parser("status", help="check dashboard running status")
|
|
814
|
+
status_parser.add_argument("--host", default="127.0.0.1", help="host to check (default 127.0.0.1)")
|
|
815
|
+
status_parser.add_argument("--port", type=int, default=4600, help="TCP port to check (default 4600)")
|
|
816
|
+
status_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
817
|
+
status_parser.add_argument("--json", action="store_true", help="output status in JSON")
|
|
818
|
+
|
|
819
|
+
start_parser = subparsers.add_parser("start", help="start dashboard server")
|
|
820
|
+
start_parser.add_argument("--daemon", action="store_true", default=False, help="run in background daemon mode")
|
|
821
|
+
start_parser.add_argument("--host", default="127.0.0.1", help="must be 127.0.0.1")
|
|
822
|
+
start_parser.add_argument("--port", type=int, default=4600, help="TCP port (default 4600)")
|
|
823
|
+
start_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
824
|
+
start_parser.add_argument("--json", action="store_true", help="output status in JSON")
|
|
825
|
+
|
|
826
|
+
stop_parser = subparsers.add_parser("stop", help="stop running dashboard server")
|
|
827
|
+
stop_parser.add_argument("--host", default="127.0.0.1", help="host (default 127.0.0.1)")
|
|
828
|
+
stop_parser.add_argument("--port", type=int, default=4600, help="TCP port (default 4600)")
|
|
829
|
+
stop_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
830
|
+
stop_parser.add_argument("--json", action="store_true", help="output status in JSON")
|
|
831
|
+
|
|
219
832
|
args = parser.parse_args(argv)
|
|
833
|
+
state_root = Path(args.state_root).expanduser().resolve() if getattr(args, "state_root", None) else default_state_root(None)
|
|
220
834
|
|
|
221
835
|
if args.command == "serve":
|
|
222
|
-
state_root = Path(args.state_root).expanduser().resolve() if args.state_root else default_state_root(None)
|
|
223
836
|
try:
|
|
224
837
|
serve(args.host, args.port, state_root)
|
|
225
838
|
except DashboardError as error:
|
|
226
839
|
print(f"dashboard error: {error}", file=sys.stderr)
|
|
227
840
|
return 1
|
|
228
841
|
return 0
|
|
842
|
+
|
|
843
|
+
elif args.command == "status":
|
|
844
|
+
try:
|
|
845
|
+
st = get_dashboard_status(state_root, args.host, args.port)
|
|
846
|
+
if args.json:
|
|
847
|
+
print(json.dumps(st))
|
|
848
|
+
else:
|
|
849
|
+
if st["running"]:
|
|
850
|
+
print(f"Dashboard is running at {st['url']} (PID: {st['pid']})")
|
|
851
|
+
else:
|
|
852
|
+
print("Dashboard is not running.")
|
|
853
|
+
except DashboardError as error:
|
|
854
|
+
print(f"dashboard error: {error}", file=sys.stderr)
|
|
855
|
+
return 1
|
|
856
|
+
return 0
|
|
857
|
+
|
|
858
|
+
elif args.command == "start":
|
|
859
|
+
try:
|
|
860
|
+
res = start_daemon(args.host, args.port, state_root)
|
|
861
|
+
if args.json:
|
|
862
|
+
print(json.dumps(res))
|
|
863
|
+
else:
|
|
864
|
+
print(f"Dashboard started on {res['url']} (PID: {res['pid']})")
|
|
865
|
+
except DashboardError as error:
|
|
866
|
+
print(f"dashboard error: {error}", file=sys.stderr)
|
|
867
|
+
return 1
|
|
868
|
+
return 0
|
|
869
|
+
|
|
870
|
+
elif args.command == "stop":
|
|
871
|
+
try:
|
|
872
|
+
res = stop_daemon(args.host, args.port, state_root)
|
|
873
|
+
if args.json:
|
|
874
|
+
print(json.dumps(res))
|
|
875
|
+
else:
|
|
876
|
+
print("Dashboard stopped.")
|
|
877
|
+
except DashboardError as error:
|
|
878
|
+
print(f"dashboard error: {error}", file=sys.stderr)
|
|
879
|
+
return 1
|
|
880
|
+
return 0
|
|
881
|
+
|
|
229
882
|
return 2
|
|
230
883
|
|
|
231
884
|
|