forgeo-cli 0.3.0__py3-none-any.whl

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.
forgeo/central.py ADDED
@@ -0,0 +1,620 @@
1
+ """Central multi-instance web dashboard (``forgeo web``).
2
+
3
+ A standalone server that aggregates every forgeo registered in the instance
4
+ registry (:mod:`forgeo.instances`). It reads each instance's data straight
5
+ from its files (``backlog.json``, ``runs.jsonl``, ``forgeo.log``,
6
+ ``BLOCKER.md``, ``daemon.state.json``), so it works whether or not that
7
+ instance's daemon is running — the daemon binds no ports at all.
8
+
9
+ Routes:
10
+
11
+ * ``GET /`` — home page listing every registered instance: name, repo,
12
+ daemon state, last outcome, next run, and backlog counts.
13
+ * ``GET /instances/<name>/`` — per-instance page: that instance's kanban
14
+ backlog (with a form to add tasks) plus tabs for logs, runs, blocker, and
15
+ config.
16
+ * ``GET /api/instances`` — JSON summary of every registered instance.
17
+ * ``GET /api/instances/<name>/tasks``, ``/tasks/<id>``, ``/status``,
18
+ ``/logs?lines=N``, ``/runs?limit=N``, ``/blocker``, ``/config`` — the
19
+ per-instance API.
20
+ * ``POST /api/instances/<name>/tasks`` — add a new task to that instance's
21
+ backlog.
22
+ * ``PATCH /api/instances/<name>/tasks/<id>`` — update an existing task's
23
+ editable fields (title, description, acceptance criteria, dependencies,
24
+ files to modify, agent command, agent timeout).
25
+
26
+ An unknown instance name returns ``404``; a registered instance with missing
27
+ data files renders with empty data and ``daemon_running=false`` rather than
28
+ erroring.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import logging
36
+ import re
37
+ import signal
38
+ import threading
39
+ from datetime import timedelta
40
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
41
+ from pathlib import Path
42
+ from typing import Any
43
+ from urllib.parse import parse_qs, unquote, urlparse
44
+
45
+ from pydantic import ValidationError
46
+ from rich.console import Console
47
+ from rich.panel import Panel
48
+
49
+ from forgeo.backlog import JSONBacklog, backlog_status_counts
50
+ from forgeo.daemon import read_lock_pid
51
+ from forgeo.instances import (
52
+ InstanceInfo,
53
+ get_instance,
54
+ list_instances,
55
+ registry_path,
56
+ )
57
+ from forgeo.models import Task, TaskStatus
58
+ from forgeo.runs import RunRecorder, runs_path_for
59
+ from forgeo.web_common import (
60
+ DEFAULT_LOG_LINES,
61
+ DEFAULT_RUN_LIMIT,
62
+ MAX_LOG_LINES,
63
+ MAX_RUN_LIMIT,
64
+ clamp_query_int,
65
+ guess_content_type,
66
+ iso,
67
+ json_bytes,
68
+ safe_static_path,
69
+ tail_lines,
70
+ )
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ DEFAULT_HOST = "0.0.0.0"
75
+ DEFAULT_PORT = 8790
76
+
77
+ _HOME_PAGE = "/central/index.html"
78
+ _INSTANCE_PAGE = "/central/instance.html"
79
+
80
+ _WEB_TASK_ID_RE = re.compile(r"^WEB-(\d+)$")
81
+
82
+
83
+ def web_task_id_for(tasks: list[Task]) -> str:
84
+ """Next ``WEB-###`` id after the highest existing ``WEB-###`` id."""
85
+ highest = 0
86
+ for task in tasks:
87
+ match = _WEB_TASK_ID_RE.match(task.id)
88
+ if match:
89
+ highest = max(highest, int(match.group(1)))
90
+ return f"WEB-{highest + 1:03d}"
91
+
92
+
93
+ def _daemon_state(config: Any) -> dict[str, Any] | None:
94
+ """The daemon's persisted live state, or ``None`` when unavailable.
95
+
96
+ The daemon writes ``daemon.state.json`` (next to the backlog) after
97
+ every cycle; a missing, unreadable, or stale file reads as ``None`` and
98
+ callers fall back to estimates from ``runs.jsonl``.
99
+ """
100
+ if config is None:
101
+ return None
102
+ state_path = Path(config.backlog).with_suffix(".state.json")
103
+ try:
104
+ data = json.loads(state_path.read_text(encoding="utf-8"))
105
+ except (OSError, json.JSONDecodeError):
106
+ return None
107
+ if not isinstance(data, dict):
108
+ return None
109
+ return data
110
+
111
+
112
+ def _read_tasks(config: Any) -> list[Task]:
113
+ """All tasks for ``config``, tolerating a missing or corrupt backlog.
114
+
115
+ Reads the file directly so the dashboard never writes to an instance's
116
+ files (``JSONBacklog`` renames a corrupt backlog; here it is skipped).
117
+ """
118
+ if config is None:
119
+ return []
120
+ try:
121
+ data = json.loads(Path(config.backlog).read_text(encoding="utf-8"))
122
+ except (OSError, json.JSONDecodeError):
123
+ return []
124
+ if not isinstance(data, dict):
125
+ return []
126
+ tasks = data.get("tasks")
127
+ if not isinstance(tasks, list):
128
+ return []
129
+ parsed: list[Task] = []
130
+ for entry in tasks:
131
+ if not isinstance(entry, dict):
132
+ continue
133
+ try:
134
+ parsed.append(Task.model_validate(entry))
135
+ except ValidationError:
136
+ continue
137
+ return parsed
138
+
139
+
140
+ def _blocker_content(config: Any) -> str | None:
141
+ """The ``BLOCKER.md`` contents, or ``None`` when absent or unreadable."""
142
+ if config is None:
143
+ return None
144
+ blocker = Path(config.blocker_file)
145
+ if not blocker.is_file():
146
+ return None
147
+ try:
148
+ return blocker.read_text(encoding="utf-8")
149
+ except OSError:
150
+ return None
151
+
152
+
153
+ def _last_outcome(config: Any) -> str | None:
154
+ """The most recent run's outcome string, or ``None``.
155
+
156
+ Prefers the daemon's persisted state; falls back to ``runs.jsonl`` when
157
+ no state file exists (e.g. an older daemon version).
158
+ """
159
+ if config is None:
160
+ return None
161
+ state = _daemon_state(config)
162
+ outcome = state.get("last_outcome") if state is not None else None
163
+ if isinstance(outcome, str):
164
+ return outcome
165
+ last_run = RunRecorder(runs_path_for(config.backlog)).read_last()
166
+ return last_run.outcome.value if last_run is not None else None
167
+
168
+
169
+ def _next_run(info: InstanceInfo, config: Any) -> str | None:
170
+ """The next scheduled run, when it can be derived.
171
+
172
+ Prefers the daemon's persisted state (written every cycle). With no state
173
+ file, the next run is approximated as the last run's finish time plus the
174
+ interval — but only while the daemon is running.
175
+ """
176
+ if not info.daemon_running or config is None:
177
+ return None
178
+ state = _daemon_state(config)
179
+ next_run_at = state.get("next_run_at") if state is not None else None
180
+ if isinstance(next_run_at, str):
181
+ return next_run_at
182
+ last_run = RunRecorder(runs_path_for(config.backlog)).read_last()
183
+ if last_run is None:
184
+ return None
185
+ estimate = last_run.finished_at + timedelta(minutes=config.interval_minutes)
186
+ return iso(estimate)
187
+
188
+
189
+ def _status_payload(info: InstanceInfo) -> dict[str, Any]:
190
+ """The per-instance status payload."""
191
+ config = info.config
192
+ if config is None:
193
+ return {
194
+ "name": info.name,
195
+ "repo": None,
196
+ "interval_minutes": None,
197
+ "daemon_running": False,
198
+ "pid": None,
199
+ "last_outcome": None,
200
+ "next_run_at": None,
201
+ }
202
+ state = _daemon_state(config)
203
+ pid: int | None = read_lock_pid(config.backlog.with_suffix(".lock"))
204
+ if state is not None:
205
+ state_pid = state.get("pid")
206
+ if isinstance(state_pid, int):
207
+ pid = state_pid
208
+ return {
209
+ "name": config.name,
210
+ "repo": str(config.repo),
211
+ "interval_minutes": config.interval_minutes,
212
+ "daemon_running": info.daemon_running,
213
+ "pid": pid,
214
+ "last_outcome": _last_outcome(config),
215
+ "next_run_at": _next_run(info, config),
216
+ }
217
+
218
+
219
+ def _summary(info: InstanceInfo) -> dict[str, Any]:
220
+ """One home-page/API row for a registered instance."""
221
+ config = info.config
222
+ if config is None:
223
+ return {
224
+ "name": info.name,
225
+ "config_path": str(info.config_path),
226
+ "repo": None,
227
+ "daemon_running": False,
228
+ "last_outcome": None,
229
+ "next_run_at": None,
230
+ "backlog_counts": {status.value: 0 for status in TaskStatus},
231
+ }
232
+ counts = backlog_status_counts(_read_tasks(config))
233
+ return {
234
+ "name": info.name,
235
+ "config_path": str(info.config_path),
236
+ "repo": str(config.repo),
237
+ "daemon_running": info.daemon_running,
238
+ "last_outcome": _last_outcome(config),
239
+ "next_run_at": _next_run(info, config),
240
+ "backlog_counts": counts,
241
+ }
242
+
243
+
244
+ def make_handler() -> type[BaseHTTPRequestHandler]:
245
+ """Build the request-handler class for the central dashboard."""
246
+
247
+ class CentralRequestHandler(BaseHTTPRequestHandler):
248
+ def log_message(self, format: str, *args: Any) -> None:
249
+ logger.debug("central web %s - %s", self.address_string(), format % args)
250
+
251
+ def _send_json(self, status: int, payload: Any) -> None:
252
+ body = json_bytes(payload)
253
+ self.send_response(status)
254
+ self.send_header("Content-Type", "application/json; charset=utf-8")
255
+ self.send_header("Content-Length", str(len(body)))
256
+ self.send_header("Cache-Control", "no-store")
257
+ self.end_headers()
258
+ self.wfile.write(body)
259
+
260
+ def _send_bytes(self, status: int, body: bytes, content_type: str) -> None:
261
+ self.send_response(status)
262
+ self.send_header("Content-Type", content_type)
263
+ self.send_header("Content-Length", str(len(body)))
264
+ self.end_headers()
265
+ self.wfile.write(body)
266
+
267
+ def _send_static(self, static: Path | None) -> None:
268
+ if static is None:
269
+ self._send_json(404, {"error": "not found"})
270
+ return
271
+ self._send_bytes(200, static.read_bytes(), guess_content_type(static))
272
+
273
+ def do_GET(self) -> None:
274
+ parsed = urlparse(self.path)
275
+ path = parsed.path
276
+ query = parse_qs(parsed.query)
277
+
278
+ try:
279
+ if path == "/api/instances":
280
+ self._send_json(200, [_summary(info) for info in list_instances()])
281
+ return
282
+ if path.startswith("/api/instances/"):
283
+ self._handle_instance_api(path, query)
284
+ return
285
+ if path == "/":
286
+ self._send_static(safe_static_path(_HOME_PAGE))
287
+ return
288
+ if path.startswith("/instances/"):
289
+ self._handle_instance_page(path)
290
+ return
291
+ static = safe_static_path(path)
292
+ if static is not None:
293
+ self._send_static(static)
294
+ return
295
+ self._send_json(404, {"error": "not found"})
296
+ except Exception:
297
+ logger.exception("Web request failed: %s", path)
298
+ self._send_json(500, {"error": "internal server error"})
299
+
300
+ def do_POST(self) -> None:
301
+ parsed = urlparse(self.path)
302
+ path = parsed.path
303
+
304
+ try:
305
+ if path.startswith("/api/instances/"):
306
+ self._post_instance_task(path)
307
+ return
308
+ self._send_json(404, {"error": "not found"})
309
+ except Exception:
310
+ logger.exception("Web request failed: %s", path)
311
+ self._send_json(500, {"error": "internal server error"})
312
+
313
+ def do_PATCH(self) -> None:
314
+ parsed = urlparse(self.path)
315
+ path = parsed.path
316
+
317
+ try:
318
+ if path.startswith("/api/instances/"):
319
+ self._patch_instance_task(path)
320
+ return
321
+ self._send_json(404, {"error": "not found"})
322
+ except Exception:
323
+ logger.exception("Web request failed: %s", path)
324
+ self._send_json(500, {"error": "internal server error"})
325
+
326
+ def _read_json_body(self) -> dict[str, Any] | None:
327
+ """Read and parse the request body as a JSON object.
328
+
329
+ Sends a 400 error and returns ``None`` when the body is missing,
330
+ malformed, or not a JSON object.
331
+ """
332
+ raw_length = self.headers.get("Content-Length")
333
+ if raw_length is None:
334
+ self._send_json(400, {"error": "request body is required"})
335
+ return None
336
+ try:
337
+ length = int(raw_length)
338
+ except ValueError:
339
+ self._send_json(400, {"error": "invalid Content-Length"})
340
+ return None
341
+ body = self.rfile.read(max(length, 0))
342
+ try:
343
+ payload = json.loads(body.decode("utf-8"))
344
+ except (json.JSONDecodeError, UnicodeDecodeError):
345
+ self._send_json(400, {"error": "request body must be JSON"})
346
+ return None
347
+ if not isinstance(payload, dict):
348
+ self._send_json(400, {"error": "request body must be a JSON object"})
349
+ return None
350
+ return payload
351
+
352
+ def _post_instance_task(self, path: str) -> None:
353
+ """Create a task in an instance's backlog from a JSON body."""
354
+ parts = path[len("/api/instances/") :].split("/")
355
+ name = unquote(parts[0])
356
+ info = get_instance(name)
357
+ if info is None:
358
+ self._send_json(404, {"error": "unknown instance"})
359
+ return
360
+ if len(parts) != 2 or parts[1] != "tasks":
361
+ self._send_json(404, {"error": "not found"})
362
+ return
363
+ if info.config is None:
364
+ self._send_json(500, {"error": "instance config not available"})
365
+ return
366
+
367
+ payload = self._read_json_body()
368
+ if payload is None:
369
+ return
370
+ title = payload.get("title")
371
+ if not isinstance(title, str) or not title.strip():
372
+ self._send_json(400, {"error": "title is required"})
373
+ return
374
+ description = payload.get("description", "")
375
+ if not isinstance(description, str) or not description.strip():
376
+ self._send_json(400, {"error": "description is required"})
377
+ return
378
+ acceptance_criteria = payload.get("acceptance_criteria", [])
379
+ if not isinstance(acceptance_criteria, list) or not all(
380
+ isinstance(criterion, str) for criterion in acceptance_criteria
381
+ ):
382
+ self._send_json(
383
+ 400, {"error": "acceptance_criteria must be a list of strings"}
384
+ )
385
+ return
386
+ agent_command = payload.get("agent_command")
387
+ if agent_command is not None and (
388
+ not isinstance(agent_command, str) or not agent_command.strip()
389
+ ):
390
+ self._send_json(
391
+ 400, {"error": "agent_command must be a non-blank string or null"}
392
+ )
393
+ return
394
+
395
+ backlog = JSONBacklog(info.config.backlog)
396
+ existing = asyncio.run(backlog.list_tasks())
397
+ task = Task(
398
+ id=web_task_id_for(existing),
399
+ title=title.strip(),
400
+ description=description.strip(),
401
+ acceptance_criteria=acceptance_criteria,
402
+ agent_command=agent_command.strip() if agent_command else None,
403
+ )
404
+ try:
405
+ created = asyncio.run(backlog.create_task(task))
406
+ except ValueError:
407
+ self._send_json(
408
+ 409, {"error": f"task id already exists in backlog: {task.id!r}"}
409
+ )
410
+ return
411
+ self._send_json(201, created.model_dump(mode="json"))
412
+
413
+ def _patch_instance_task(self, path: str) -> None:
414
+ """Update a task in an instance's backlog from a JSON body."""
415
+ parts = path[len("/api/instances/") :].split("/")
416
+ name = unquote(parts[0])
417
+ info = get_instance(name)
418
+ if info is None:
419
+ self._send_json(404, {"error": "unknown instance"})
420
+ return
421
+ if len(parts) != 3 or parts[1] != "tasks":
422
+ self._send_json(404, {"error": "not found"})
423
+ return
424
+ if info.config is None:
425
+ self._send_json(500, {"error": "instance config not available"})
426
+ return
427
+ task_id = unquote(parts[2])
428
+
429
+ payload = self._read_json_body()
430
+ if payload is None:
431
+ return
432
+ if not payload:
433
+ self._send_json(400, {"error": "request body must not be empty"})
434
+ return
435
+
436
+ backlog = JSONBacklog(info.config.backlog)
437
+ try:
438
+ updated = asyncio.run(backlog.update_task(task_id, payload))
439
+ except ValueError as exc:
440
+ self._send_json(400, {"error": str(exc)})
441
+ return
442
+ if updated is None:
443
+ self._send_json(404, {"error": "not found"})
444
+ return
445
+ self._send_json(200, updated.model_dump(mode="json"))
446
+
447
+ def _handle_instance_page(self, path: str) -> None:
448
+ name = unquote(path[len("/instances/") :]).strip("/")
449
+ if not name or "/" in name or get_instance(name) is None:
450
+ self._send_json(404, {"error": "unknown instance"})
451
+ return
452
+ self._send_static(safe_static_path(_INSTANCE_PAGE))
453
+
454
+ def _handle_instance_api(self, path: str, query: dict[str, list[str]]) -> None:
455
+ parts = path[len("/api/instances/") :].split("/")
456
+ name = unquote(parts[0])
457
+ info = get_instance(name)
458
+ if info is None:
459
+ self._send_json(404, {"error": "unknown instance"})
460
+ return
461
+ if len(parts) < 2:
462
+ self._send_json(404, {"error": "not found"})
463
+ return
464
+ endpoint = parts[1]
465
+
466
+ if endpoint == "tasks":
467
+ if len(parts) == 2:
468
+ tasks = _read_tasks(info.config)
469
+ self._send_json(200, [t.model_dump(mode="json") for t in tasks])
470
+ return
471
+ if len(parts) == 3:
472
+ task_id = unquote(parts[2])
473
+ for task in _read_tasks(info.config):
474
+ if task.id == task_id:
475
+ self._send_json(200, task.model_dump(mode="json"))
476
+ return
477
+ self._send_json(404, {"error": "not found"})
478
+ return
479
+ self._send_json(404, {"error": "not found"})
480
+ return
481
+ if len(parts) > 2:
482
+ self._send_json(404, {"error": "not found"})
483
+ return
484
+
485
+ if endpoint == "status":
486
+ self._send_json(200, _status_payload(info))
487
+ return
488
+ if endpoint == "logs":
489
+ n = clamp_query_int(query, "lines", DEFAULT_LOG_LINES, MAX_LOG_LINES)
490
+ if info.config is None:
491
+ self._send_json(200, {"lines": []})
492
+ return
493
+ lines = tail_lines(Path(info.config.log_file), n)
494
+ self._send_json(200, {"lines": lines})
495
+ return
496
+ if endpoint == "runs":
497
+ n = clamp_query_int(query, "limit", DEFAULT_RUN_LIMIT, MAX_RUN_LIMIT)
498
+ if info.config is None:
499
+ self._send_json(200, [])
500
+ return
501
+ records = RunRecorder(runs_path_for(info.config.backlog)).read(limit=n)
502
+ self._send_json(200, [r.model_dump(mode="json") for r in records])
503
+ return
504
+ if endpoint == "blocker":
505
+ self._send_json(200, {"content": _blocker_content(info.config)})
506
+ return
507
+ if endpoint == "config":
508
+ if info.config is None:
509
+ self._send_json(200, {"error": "config not available"})
510
+ return
511
+ self._send_json(200, info.config.model_dump(mode="json"))
512
+ return
513
+ self._send_json(404, {"error": "not found"})
514
+
515
+ return CentralRequestHandler
516
+
517
+
518
+ class CentralWebServer:
519
+ """Threading HTTP server lifecycle around the central dashboard."""
520
+
521
+ def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
522
+ self.host = host
523
+ self._port = port
524
+ self._httpd: ThreadingHTTPServer | None = None
525
+ self._thread: threading.Thread | None = None
526
+
527
+ @property
528
+ def port(self) -> int | None:
529
+ """The port actually bound, once started (port 0 picks a free one)."""
530
+ if self._httpd is None:
531
+ return None
532
+ return int(self._httpd.server_address[1])
533
+
534
+ def start(self) -> bool:
535
+ """Bind and start serving in a background thread. False on bind failure."""
536
+ handler = make_handler()
537
+ try:
538
+ httpd = ThreadingHTTPServer((self.host, self._port), handler)
539
+ except OSError as exc:
540
+ logger.error(
541
+ "Central web server failed to bind %s:%s: %s",
542
+ self.host,
543
+ self._port,
544
+ exc,
545
+ )
546
+ return False
547
+ self._httpd = httpd
548
+ thread = threading.Thread(
549
+ target=httpd.serve_forever,
550
+ name="forgeo-central-web",
551
+ daemon=True,
552
+ )
553
+ self._thread = thread
554
+ thread.start()
555
+ logger.info(
556
+ "Central web server listening on http://%s:%s",
557
+ self.host,
558
+ httpd.server_address[1],
559
+ )
560
+ return True
561
+
562
+ def stop(self) -> None:
563
+ """Stop the server and join the serve thread."""
564
+ httpd = self._httpd
565
+ if httpd is None:
566
+ return
567
+ httpd.shutdown()
568
+ httpd.server_close()
569
+ thread = self._thread
570
+ if thread is not None and thread.is_alive():
571
+ thread.join(timeout=5)
572
+ self._httpd = None
573
+ self._thread = None
574
+ logger.info("Central web server stopped.")
575
+
576
+
577
+ def _instance_count() -> int:
578
+ """The number of registered instances (used by the CLI banner)."""
579
+ return len(list_instances())
580
+
581
+
582
+ async def _serve_forever(server: CentralWebServer, host: str) -> None:
583
+ """Run the foreground server until SIGINT/SIGTERM, then stop it."""
584
+ loop = asyncio.get_running_loop()
585
+ stop_event = asyncio.Event()
586
+ for sig in (signal.SIGINT, signal.SIGTERM):
587
+ try:
588
+ loop.add_signal_handler(sig, stop_event.set)
589
+ except NotImplementedError:
590
+ pass
591
+ Console().print(
592
+ Panel.fit(
593
+ f"[bold]Forgeo central dashboard[/bold]\n"
594
+ f"[bold]Listening:[/bold] http://{host}:{server.port}\n"
595
+ f"[bold]Instances:[/bold] {_instance_count()} registered "
596
+ f"(registry: {registry_path()})",
597
+ title="Forgeo Web",
598
+ border_style="green",
599
+ )
600
+ )
601
+ await stop_event.wait()
602
+
603
+
604
+ def run_foreground(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> int:
605
+ """Start ``forgeo web`` in the foreground; returns the process exit code.
606
+
607
+ Binds the dashboard, prints the listening banner, and blocks until the
608
+ user interrupts it with Ctrl-C or a SIGTERM arrives.
609
+ """
610
+ server = CentralWebServer(host=host, port=port)
611
+ if not server.start():
612
+ Console().print(f"[red]Central dashboard failed to bind {host}:{port}.[/red]")
613
+ return 1
614
+ try:
615
+ asyncio.run(_serve_forever(server, host))
616
+ except KeyboardInterrupt:
617
+ pass
618
+ finally:
619
+ server.stop()
620
+ return 0