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/__init__.py +10 -0
- forgeo/__main__.py +6 -0
- forgeo/agent.py +332 -0
- forgeo/backlog.py +206 -0
- forgeo/central.py +620 -0
- forgeo/cli.py +759 -0
- forgeo/config.py +36 -0
- forgeo/daemon.py +209 -0
- forgeo/forgeo.py +446 -0
- forgeo/git.py +100 -0
- forgeo/instances.py +187 -0
- forgeo/io.py +30 -0
- forgeo/models.py +252 -0
- forgeo/notify.py +77 -0
- forgeo/runs.py +73 -0
- forgeo/setup.py +185 -0
- forgeo/web/central/central.css +380 -0
- forgeo/web/central/central.js +780 -0
- forgeo/web/central/index.html +41 -0
- forgeo/web/central/instance.html +189 -0
- forgeo/web/style.css +656 -0
- forgeo/web_common.py +92 -0
- forgeo_cli-0.3.0.dist-info/METADATA +146 -0
- forgeo_cli-0.3.0.dist-info/RECORD +27 -0
- forgeo_cli-0.3.0.dist-info/WHEEL +4 -0
- forgeo_cli-0.3.0.dist-info/entry_points.txt +2 -0
- forgeo_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|