@julioborges/gantry 1.0.5 → 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.
@@ -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
- state["column"] = event["phase"]
89
- state["phaseStartedAt"] = event["ts"]
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
- state["operatorWaiting"] = bool(edata.get("operatorWaiting", False))
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
- if state["phaseStartedAt"] is not None:
110
- state["elapsedPhaseSeconds"] = max(0, int(now - _parse_ts(state["phaseStartedAt"])))
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["elapsedPhaseSeconds"] = None
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
- runs.append(build_run(unit_dir.name, events, now))
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
- path = self.path.split("?", 1)[0]
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
- self._send_json(200, {"columns": COLUMNS, "runs": collect_runs(state_root)})
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