visualagents 0.1.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.
visualagents/server.py ADDED
@@ -0,0 +1,209 @@
1
+ """Local web server: serves the dashboard and streams live state over Server-Sent Events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import threading
8
+ from http import HTTPStatus
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from importlib import resources
11
+ from typing import Any
12
+ from urllib.parse import parse_qs, urlparse
13
+
14
+ from .hub import Hub
15
+ from .projects import Projects
16
+
17
+ _TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon"}
19
+
20
+
21
+ def _asset(name: str) -> bytes | None:
22
+ if "/" in name or "\\" in name or name.startswith("."):
23
+ return None
24
+ try:
25
+ return (resources.files("visualagents") / "web" / name).read_bytes()
26
+ except (FileNotFoundError, OSError):
27
+ return None
28
+
29
+
30
+ class _Handler(BaseHTTPRequestHandler):
31
+ projects: Projects
32
+ server_version = "VisualAgents"
33
+ protocol_version = "HTTP/1.1"
34
+
35
+ def log_message(self, fmt: str, *args: Any) -> None: # keep the terminal clean for the agent's output
36
+ pass
37
+
38
+ # -- helpers -----------------------------------------------------------
39
+ def _send(self, status: int, body: bytes, ctype: str = "application/json", extra: dict[str, str] | None = None) -> None:
40
+ self.send_response(status)
41
+ self.send_header("Content-Type", ctype)
42
+ self.send_header("Content-Length", str(len(body)))
43
+ self.send_header("Cache-Control", "no-store")
44
+ self.send_header("X-Content-Type-Options", "nosniff")
45
+ for k, v in (extra or {}).items():
46
+ self.send_header(k, v)
47
+ self.end_headers()
48
+ if self.command != "HEAD":
49
+ self.wfile.write(body)
50
+
51
+ def _json(self, obj: Any, status: int = 200) -> None:
52
+ self._send(status, json.dumps(obj, ensure_ascii=False).encode("utf-8"))
53
+
54
+ def _hub(self, url) -> Hub:
55
+ return self.projects.resolve(parse_qs(url.query).get("project", [None])[0])
56
+
57
+ def _same_origin(self) -> bool:
58
+ origin = self.headers.get("Origin")
59
+ return origin is None or urlparse(origin).netloc == self.headers.get("Host")
60
+
61
+ # -- routes ------------------------------------------------------------
62
+ def do_HEAD(self) -> None: # noqa: N802
63
+ self.do_GET()
64
+
65
+ def do_GET(self) -> None: # noqa: N802
66
+ url = urlparse(self.path)
67
+ path = url.path
68
+ if path in ("/", "/index.html"):
69
+ body = _asset("index.html") or b"missing index.html"
70
+ self._send(200, body, _TYPES[".html"])
71
+ elif path.startswith("/assets/"):
72
+ name = path[len("/assets/"):]
73
+ body = _asset(name)
74
+ if body is None:
75
+ self._send(404, b"not found", "text/plain")
76
+ else:
77
+ ext = name[name.rfind("."):]
78
+ self._send(200, body, _TYPES.get(ext, "application/octet-stream"))
79
+ elif path == "/api/state":
80
+ hub = self._hub(url)
81
+ snap = hub.snapshot()
82
+ snap["meta"]["daemon"] = self.projects.daemon
83
+ self._json(snap)
84
+ elif path == "/api/projects":
85
+ self._json({"daemon": self.projects.daemon, "projects": self.projects.listing()})
86
+ elif path == "/api/blueprint":
87
+ self._json(self._hub(url).blueprint)
88
+ elif path == "/api/stream":
89
+ self._stream(url)
90
+ elif path == "/favicon.ico":
91
+ self._send(204, b"", "image/x-icon")
92
+ else:
93
+ self._send(404, b"not found", "text/plain")
94
+
95
+ def do_POST(self) -> None: # noqa: N802
96
+ path = urlparse(self.path).path
97
+ length = int(self.headers.get("Content-Length") or 0)
98
+ raw = self.rfile.read(min(length, 8_000_000)) if length else b""
99
+ if path == "/api/ingest":
100
+ if self.headers.get("X-VisualAgents-Token") != self.projects.token:
101
+ self._json({"error": "bad token"}, HTTPStatus.FORBIDDEN)
102
+ return
103
+ try:
104
+ payload = json.loads(raw or b"null")
105
+ except ValueError:
106
+ self._json({"error": "invalid json"}, HTTPStatus.BAD_REQUEST)
107
+ return
108
+ items = payload if isinstance(payload, list) else [payload]
109
+ for item in items:
110
+ if not isinstance(item, dict):
111
+ continue
112
+ hub = self.projects.for_item(item)
113
+ if item.get("type") == "hello": # a project was just hooked: the hub now exists
114
+ continue
115
+ if item.get("type") == "line":
116
+ hub.feed_line(str(item.get("text", "")), str(item.get("stream", "stdout")))
117
+ else:
118
+ hub.feed_structured(item)
119
+ self._json({"ok": True, "count": len(items)})
120
+ elif path == "/api/start":
121
+ if not self._same_origin():
122
+ self._json({"error": "cross-origin"}, HTTPStatus.FORBIDDEN)
123
+ return
124
+ hub = self._hub(urlparse(self.path))
125
+ if hub.launcher is None:
126
+ self._json({"ok": False, "message": "Start it the way you usually do - it shows up here."})
127
+ return
128
+ try:
129
+ body = json.loads(raw or b"{}")
130
+ except ValueError:
131
+ body = {}
132
+ task = str(body.get("task") or "").strip() or None
133
+ ok, message = hub.launcher.start(task)
134
+ self._json({"ok": ok, "message": message, "launch": hub.launcher.info()})
135
+ elif path == "/api/reset":
136
+ if not self._same_origin():
137
+ self._json({"error": "cross-origin"}, HTTPStatus.FORBIDDEN)
138
+ return
139
+ self._hub(urlparse(self.path)).reset_view()
140
+ self._json({"ok": True})
141
+ else:
142
+ self._send(404, b"not found", "text/plain")
143
+
144
+ def _stream(self, url) -> None:
145
+ q = parse_qs(url.query)
146
+ try:
147
+ last_seq = int(self.headers.get("Last-Event-ID") or q.get("since", ["0"])[0])
148
+ except ValueError:
149
+ last_seq = 0
150
+ self.send_response(200)
151
+ self.send_header("Content-Type", "text/event-stream")
152
+ self.send_header("Cache-Control", "no-store")
153
+ self.send_header("Connection", "keep-alive")
154
+ self.send_header("X-Accel-Buffering", "no")
155
+ self.end_headers()
156
+ self.close_connection = True
157
+ version = -1
158
+ hub = self._hub(url)
159
+ try:
160
+ self.wfile.write(b"retry: 1500\n\n")
161
+ while True:
162
+ events, state, version = hub.wait(last_seq, version, timeout=15.0)
163
+ chunks = []
164
+ for ev in events:
165
+ last_seq = ev["seq"]
166
+ chunks.append(f"id: {ev['seq']}\nevent: ev\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n")
167
+ if state is not None:
168
+ chunks.append(f"event: state\ndata: {json.dumps(state, ensure_ascii=False)}\n\n")
169
+ if not chunks:
170
+ chunks.append(": ping\n\n")
171
+ self.wfile.write("".join(chunks).encode("utf-8"))
172
+ self.wfile.flush()
173
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
174
+ return
175
+
176
+
177
+ class Server:
178
+ def __init__(self, target: Hub | Projects, host: str = "127.0.0.1", port: int = 7420):
179
+ self.projects = target if isinstance(target, Projects) else Projects.single(target)
180
+ handler = type("Handler", (_Handler,), {"projects": self.projects})
181
+ self.httpd = _bind(host, port, handler)
182
+ self.httpd.daemon_threads = True
183
+ self.host, self.port = self.httpd.server_address[:2]
184
+ self.thread = threading.Thread(target=self.httpd.serve_forever, name="visualagents-http", daemon=True)
185
+
186
+ @property
187
+ def url(self) -> str:
188
+ host = "127.0.0.1" if self.host in ("0.0.0.0", "") else self.host
189
+ return f"http://{host}:{self.port}"
190
+
191
+ def start(self) -> "Server":
192
+ self.thread.start()
193
+ return self
194
+
195
+ def stop(self) -> None:
196
+ self.httpd.shutdown()
197
+ self.httpd.server_close()
198
+
199
+
200
+ def _bind(host: str, port: int, handler) -> ThreadingHTTPServer:
201
+ last: OSError | None = None
202
+ for p in [port + i for i in range(20)] if port else [0]:
203
+ try:
204
+ return ThreadingHTTPServer((host, p), handler)
205
+ except OSError as exc: # port in use - try the next one
206
+ last = exc
207
+ if getattr(exc, "errno", None) not in (48, 98, 10048) and not isinstance(exc, socket.error):
208
+ raise
209
+ raise last or OSError("could not bind a port")