omnilane 0.7.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/scripts/ui.py ADDED
@@ -0,0 +1,1236 @@
1
+ #!/usr/bin/env python3
2
+ """Local, read-only Omnilane job board.
3
+
4
+ Core dispatch remains Bash-only. This optional module intentionally uses only
5
+ the Python standard library so `omnilane ui` has no package-manager runtime.
6
+ """
7
+
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ import argparse
11
+ from datetime import datetime, timezone
12
+ import errno
13
+ import fcntl
14
+ import heapq
15
+ import hmac
16
+ import http.client
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ import secrets
23
+ import select
24
+ import signal
25
+ import socket
26
+ import stat
27
+ import subprocess
28
+ import sys
29
+ import threading
30
+ import time
31
+ from urllib.parse import parse_qs, unquote, urlsplit
32
+
33
+
34
+ JOB_ID_RE = re.compile(r"^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$")
35
+ META_LIMIT = 64 * 1024
36
+ INTEGER_LIMIT = 64
37
+ TEXT_LIMIT = 512 * 1024
38
+ TEXT_HEAD = 384 * 1024
39
+ TEXT_TAIL = 128 * 1024
40
+ PID_MAX = 2**31 - 1
41
+ INT_MIN = -(2**31)
42
+ INT_MAX = 2**31 - 1
43
+ ALLOWED_FILES = ("meta.json", "task.txt", "pid", "exit", "out.txt")
44
+ STRING_META_FIELDS = (
45
+ "lane",
46
+ "vendor",
47
+ "model",
48
+ "effort",
49
+ "mode",
50
+ "workdir",
51
+ "candidate",
52
+ "started",
53
+ )
54
+
55
+
56
+ class JobNotFound(Exception):
57
+ """The requested job ID is invalid or no longer names a safe directory."""
58
+
59
+
60
+ @dataclass
61
+ class FileRead:
62
+ status: str
63
+ data: bytes = b""
64
+ signal: object = None
65
+ truncated: bool = False
66
+
67
+
68
+ class JobStore:
69
+ """Read the canonical jobs directory without following job-owned links."""
70
+
71
+ def __init__(self, jobs_root):
72
+ self.jobs_root = Path(jobs_root).expanduser().absolute()
73
+ self._directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
74
+ self._file_flags = (
75
+ os.O_RDONLY
76
+ | getattr(os, "O_NONBLOCK", 0)
77
+ | getattr(os, "O_NOFOLLOW", 0)
78
+ )
79
+
80
+ def snapshot(self):
81
+ jobs = []
82
+ for job_id in self._newest_job_ids():
83
+ try:
84
+ with self._job_fd(job_id) as job_fd:
85
+ jobs.append(self._summary_from_fd(job_id, job_fd))
86
+ except JobNotFound:
87
+ # A runner or cleanup may remove a directory between scan/open.
88
+ continue
89
+ return {"ok": True, "jobs": jobs}
90
+
91
+ def detail(self, job_id):
92
+ if not JOB_ID_RE.fullmatch(job_id or ""):
93
+ raise JobNotFound(job_id)
94
+ with self._job_fd(job_id) as job_fd:
95
+ summary = self._summary_from_fd(job_id, job_fd)
96
+ task = self._read_text(job_fd, "task.txt")
97
+ output = self._read_text(job_fd, "out.txt")
98
+ invalid_files = []
99
+ if task.status not in ("ok", "missing"):
100
+ invalid_files.append("task.txt")
101
+ if output.status not in ("ok", "missing"):
102
+ invalid_files.append("out.txt")
103
+ return {
104
+ "summary": summary,
105
+ "task": self._decode_text(task),
106
+ "output": self._decode_text(output),
107
+ "taskTruncated": task.truncated,
108
+ "outputTruncated": output.truncated,
109
+ "invalidFiles": invalid_files,
110
+ }
111
+
112
+ def _newest_job_ids(self):
113
+ try:
114
+ entries = os.scandir(self.jobs_root)
115
+ except (FileNotFoundError, NotADirectoryError, PermissionError, OSError):
116
+ return []
117
+ with entries:
118
+ names = (
119
+ entry.name
120
+ for entry in entries
121
+ if JOB_ID_RE.fullmatch(entry.name)
122
+ and entry.is_dir(follow_symlinks=False)
123
+ )
124
+ return heapq.nlargest(50, names)
125
+
126
+ @contextmanager
127
+ def _job_fd(self, job_id):
128
+ if not JOB_ID_RE.fullmatch(job_id or ""):
129
+ raise JobNotFound(job_id)
130
+ root_fd = None
131
+ job_fd = None
132
+ try:
133
+ root_fd = os.open(str(self.jobs_root), self._directory_flags)
134
+ job_flags = self._directory_flags | getattr(os, "O_NOFOLLOW", 0)
135
+ job_fd = os.open(job_id, job_flags, dir_fd=root_fd)
136
+ if not stat.S_ISDIR(os.fstat(job_fd).st_mode):
137
+ raise JobNotFound(job_id)
138
+ yield job_fd
139
+ except (FileNotFoundError, NotADirectoryError, PermissionError, OSError) as exc:
140
+ if isinstance(exc, JobNotFound):
141
+ raise
142
+ raise JobNotFound(job_id) from None
143
+ finally:
144
+ if job_fd is not None:
145
+ os.close(job_fd)
146
+ if root_fd is not None:
147
+ os.close(root_fd)
148
+
149
+ def _open_regular(self, job_fd, filename):
150
+ try:
151
+ fd = os.open(filename, self._file_flags, dir_fd=job_fd)
152
+ except FileNotFoundError:
153
+ return None, FileRead("missing")
154
+ except OSError:
155
+ return None, FileRead("invalid")
156
+ try:
157
+ info = os.fstat(fd)
158
+ if not stat.S_ISREG(info.st_mode):
159
+ os.close(fd)
160
+ return None, FileRead("invalid")
161
+ signal = {
162
+ "size": info.st_size,
163
+ "mtimeNs": info.st_mtime_ns,
164
+ "ctimeNs": info.st_ctime_ns,
165
+ "inode": info.st_ino,
166
+ }
167
+ return fd, FileRead("ok", signal=signal)
168
+ except OSError:
169
+ os.close(fd)
170
+ return None, FileRead("invalid")
171
+
172
+ def _read_small(self, job_fd, filename, limit):
173
+ fd, result = self._open_regular(job_fd, filename)
174
+ if fd is None:
175
+ return result
176
+ try:
177
+ if result.signal["size"] > limit:
178
+ return FileRead("oversized", signal=result.signal)
179
+ data = self._read_up_to(fd, limit + 1)
180
+ if len(data) > limit:
181
+ return FileRead("oversized", signal=result.signal)
182
+ return FileRead("ok", data=data, signal=result.signal)
183
+ except OSError:
184
+ return FileRead("invalid", signal=result.signal)
185
+ finally:
186
+ os.close(fd)
187
+
188
+ def _read_text(self, job_fd, filename):
189
+ fd, result = self._open_regular(job_fd, filename)
190
+ if fd is None:
191
+ return result
192
+ try:
193
+ size = result.signal["size"]
194
+ if size <= TEXT_LIMIT:
195
+ data = self._read_up_to(fd, TEXT_LIMIT + 1)
196
+ if len(data) <= TEXT_LIMIT:
197
+ return FileRead("ok", data=data, signal=result.signal)
198
+ size = max(size, len(data))
199
+
200
+ marker = (
201
+ "\n\n--- TRUNCATED (original: {} bytes) ---\n\n".format(size)
202
+ ).encode("ascii")
203
+ tail_budget = max(0, TEXT_LIMIT - TEXT_HEAD - len(marker))
204
+ os.lseek(fd, 0, os.SEEK_SET)
205
+ head = self._read_up_to(fd, TEXT_HEAD)
206
+ os.lseek(fd, max(0, size - tail_budget), os.SEEK_SET)
207
+ tail = self._read_up_to(fd, tail_budget)
208
+ return FileRead(
209
+ "ok",
210
+ data=head + marker + tail,
211
+ signal=result.signal,
212
+ truncated=True,
213
+ )
214
+ except OSError:
215
+ return FileRead("invalid", signal=result.signal)
216
+ finally:
217
+ os.close(fd)
218
+
219
+ @staticmethod
220
+ def _read_up_to(fd, count):
221
+ chunks = []
222
+ remaining = count
223
+ while remaining > 0:
224
+ chunk = os.read(fd, min(65536, remaining))
225
+ if not chunk:
226
+ break
227
+ chunks.append(chunk)
228
+ remaining -= len(chunk)
229
+ return b"".join(chunks)
230
+
231
+ @staticmethod
232
+ def _decode_text(result):
233
+ if result.status != "ok":
234
+ return ""
235
+ return result.data.decode("utf-8", errors="replace")
236
+
237
+ def _summary_from_fd(self, job_id, job_fd):
238
+ signals = {}
239
+ for filename in ALLOWED_FILES:
240
+ fd, result = self._open_regular(job_fd, filename)
241
+ if fd is not None:
242
+ os.close(fd)
243
+ if result.signal is not None:
244
+ signals[filename] = result.signal
245
+ elif result.status == "invalid":
246
+ signals[filename] = {"invalid": True}
247
+
248
+ metadata_result = self._read_small(job_fd, "meta.json", META_LIMIT)
249
+ if metadata_result.status == "missing":
250
+ return self._summary(job_id, "starting", None, {}, signals)
251
+ if metadata_result.status != "ok":
252
+ return self._summary(job_id, "invalid", None, {}, signals)
253
+ try:
254
+ raw_metadata = json.loads(metadata_result.data.decode("utf-8"))
255
+ metadata = self._validated_metadata(raw_metadata)
256
+ except (UnicodeDecodeError, ValueError, TypeError):
257
+ return self._summary(job_id, "invalid", None, {}, signals)
258
+
259
+ exit_result = self._read_small(job_fd, "exit", INTEGER_LIMIT)
260
+ if exit_result.status == "ok":
261
+ exit_code = self._parse_integer(exit_result.data, INT_MIN, INT_MAX)
262
+ if exit_code is None:
263
+ return self._summary(job_id, "invalid", None, metadata, signals)
264
+ state = "succeeded" if exit_code == 0 else "failed"
265
+ return self._summary(job_id, state, exit_code, metadata, signals)
266
+ if exit_result.status not in ("missing",):
267
+ return self._summary(job_id, "invalid", None, metadata, signals)
268
+
269
+ pid_result = self._read_small(job_fd, "pid", INTEGER_LIMIT)
270
+ if pid_result.status == "missing":
271
+ return self._summary(job_id, "dead", None, metadata, signals)
272
+ if pid_result.status != "ok":
273
+ return self._summary(job_id, "invalid", None, metadata, signals)
274
+ pid = self._parse_integer(pid_result.data, 1, PID_MAX)
275
+ if pid is None:
276
+ return self._summary(job_id, "invalid", None, metadata, signals)
277
+ return self._summary(
278
+ job_id,
279
+ "running" if self._pid_exists(pid) else "dead",
280
+ None,
281
+ metadata,
282
+ signals,
283
+ )
284
+
285
+ @staticmethod
286
+ def _summary(job_id, state_name, exit_code, metadata, signals):
287
+ return {
288
+ "id": job_id,
289
+ "state": state_name,
290
+ "exitCode": exit_code,
291
+ "meta": metadata,
292
+ "signals": signals,
293
+ }
294
+
295
+ @staticmethod
296
+ def _validated_metadata(value):
297
+ if not isinstance(value, dict):
298
+ raise ValueError("metadata must be an object")
299
+ result = {}
300
+ for field in STRING_META_FIELDS:
301
+ if field not in value:
302
+ continue
303
+ item = value[field]
304
+ limit = 8192 if field == "workdir" else 1024
305
+ if not isinstance(item, str) or len(item) > limit:
306
+ raise ValueError("invalid metadata field")
307
+ result[field] = item
308
+ if "timeout" in value:
309
+ timeout = value["timeout"]
310
+ if (
311
+ isinstance(timeout, bool)
312
+ or not isinstance(timeout, int)
313
+ or timeout < 1
314
+ or timeout > 2**63 - 1
315
+ ):
316
+ raise ValueError("invalid timeout")
317
+ result["timeout"] = timeout
318
+ return result
319
+
320
+ @staticmethod
321
+ def _parse_integer(data, minimum, maximum):
322
+ try:
323
+ text = data.decode("ascii").strip()
324
+ except UnicodeDecodeError:
325
+ return None
326
+ if not re.fullmatch(r"-?[0-9]+", text):
327
+ return None
328
+ try:
329
+ value = int(text, 10)
330
+ except (ValueError, OverflowError):
331
+ return None
332
+ return value if minimum <= value <= maximum else None
333
+
334
+ @staticmethod
335
+ def _pid_exists(pid):
336
+ try:
337
+ os.kill(pid, 0)
338
+ return True
339
+ except ProcessLookupError:
340
+ return False
341
+ except PermissionError:
342
+ return True
343
+ except OSError as exc:
344
+ return exc.errno != errno.ESRCH
345
+ except (OverflowError, ValueError):
346
+ return False
347
+
348
+
349
+ SECURITY_HEADERS = {
350
+ "Cache-Control": "no-store",
351
+ "Content-Security-Policy": (
352
+ "default-src 'none'; connect-src 'self'; script-src 'self'; "
353
+ "style-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; "
354
+ "frame-ancestors 'none'"
355
+ ),
356
+ "Referrer-Policy": "no-referrer",
357
+ "X-Content-Type-Options": "nosniff",
358
+ "X-Frame-Options": "DENY",
359
+ }
360
+
361
+
362
+ class SnapshotBroadcaster:
363
+ """Poll once and fan one cached, body-free snapshot out to every client."""
364
+
365
+ def __init__(self, store, poll_interval=1.0):
366
+ self.store = store
367
+ self.poll_interval = poll_interval
368
+ self._condition = threading.Condition()
369
+ self._stop_event = threading.Event()
370
+ self._thread = None
371
+ self._snapshot = self.store.snapshot()
372
+ self._encoded = self._encode(self._snapshot)
373
+ self._version = 1
374
+ self.scan_count = 1
375
+ self.change_count = 1
376
+
377
+ @staticmethod
378
+ def _encode(snapshot):
379
+ return json.dumps(
380
+ snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":")
381
+ ).encode("utf-8")
382
+
383
+ def start(self):
384
+ if self._thread is not None:
385
+ return
386
+ self._thread = threading.Thread(
387
+ target=self._run, name="omnilane-ui-snapshots", daemon=True
388
+ )
389
+ self._thread.start()
390
+
391
+ def stop(self):
392
+ self._stop_event.set()
393
+ with self._condition:
394
+ self._condition.notify_all()
395
+ if self._thread is not None:
396
+ self._thread.join(timeout=3)
397
+
398
+ def current(self):
399
+ with self._condition:
400
+ return self._version, self._snapshot
401
+
402
+ def wait_for_change(self, version, timeout):
403
+ with self._condition:
404
+ self._condition.wait_for(
405
+ lambda: self._version != version or self._stop_event.is_set(),
406
+ timeout=timeout,
407
+ )
408
+ return self._version, self._snapshot, self._version != version
409
+
410
+ def _run(self):
411
+ while not self._stop_event.wait(self.poll_interval):
412
+ try:
413
+ snapshot = self.store.snapshot()
414
+ encoded = self._encode(snapshot)
415
+ except Exception:
416
+ # A transient filesystem race keeps the last known-good view.
417
+ continue
418
+ with self._condition:
419
+ self.scan_count += 1
420
+ if encoded == self._encoded:
421
+ continue
422
+ self._snapshot = snapshot
423
+ self._encoded = encoded
424
+ self._version += 1
425
+ self.change_count += 1
426
+ self._condition.notify_all()
427
+
428
+
429
+ class LiveHTTPServer(ThreadingHTTPServer):
430
+ """Bounded local HTTP service for static assets, JSON, and SSE."""
431
+
432
+ allow_reuse_address = True
433
+ daemon_threads = True
434
+ block_on_close = False
435
+ request_queue_size = 16
436
+
437
+ def __init__(
438
+ self,
439
+ server_address,
440
+ job_store,
441
+ token,
442
+ server_id,
443
+ static_root,
444
+ *,
445
+ poll_interval=1.0,
446
+ keepalive_interval=3.0,
447
+ ):
448
+ host, _port = server_address
449
+ if host != "127.0.0.1":
450
+ raise ValueError("Live UI must bind to 127.0.0.1")
451
+ self.job_store = job_store
452
+ self.token = token
453
+ self.server_id = server_id
454
+ self.static_root = Path(static_root).absolute()
455
+ self.keepalive_interval = keepalive_interval
456
+ self.stop_event = threading.Event()
457
+ self.sse_slots = threading.BoundedSemaphore(8)
458
+ self.broadcaster = SnapshotBroadcaster(job_store, poll_interval=poll_interval)
459
+ super().__init__(server_address, LiveRequestHandler)
460
+ self.expected_host = "127.0.0.1:{}".format(self.server_address[1])
461
+ self.broadcaster.start()
462
+
463
+ def stop(self):
464
+ if self.stop_event.is_set():
465
+ return
466
+ self.stop_event.set()
467
+ self.broadcaster.stop()
468
+ self.shutdown()
469
+
470
+ def handle_error(self, request, client_address):
471
+ # Default socketserver tracebacks can include handler internals. The
472
+ # lifecycle log intentionally records no requests, tokens, or bodies.
473
+ return
474
+
475
+
476
+ class LiveRequestHandler(BaseHTTPRequestHandler):
477
+ protocol_version = "HTTP/1.1"
478
+ server_version = "omnilane"
479
+ sys_version = ""
480
+
481
+ def log_message(self, format_string, *args):
482
+ return
483
+
484
+ def log_request(self, code="-", size="-"):
485
+ return
486
+
487
+ def log_error(self, format_string, *args):
488
+ return
489
+
490
+ def send_error(self, code, message=None, explain=None):
491
+ # BaseHTTPRequestHandler's stock HTML errors omit our security headers
492
+ # and echo request details. Keep parser errors generic and turn unknown
493
+ # but syntactically valid HTTP methods into the documented 405.
494
+ self._response_started = False
495
+ if code == 501:
496
+ self._send_json(
497
+ 405,
498
+ {"ok": False, "error": "method not allowed"},
499
+ extra_headers={"Allow": "GET"},
500
+ )
501
+ else:
502
+ self._send_json(code, {"ok": False, "error": "request error"})
503
+
504
+ def do_GET(self):
505
+ self._response_started = False
506
+ try:
507
+ self._do_get()
508
+ except (BrokenPipeError, ConnectionResetError, socket.timeout, OSError):
509
+ self.close_connection = True
510
+ except Exception:
511
+ if not self._response_started:
512
+ self._send_json(500, {"ok": False, "error": "internal error"})
513
+ self.close_connection = True
514
+
515
+ def do_POST(self):
516
+ self._method_not_allowed()
517
+
518
+ def do_PUT(self):
519
+ self._method_not_allowed()
520
+
521
+ def do_PATCH(self):
522
+ self._method_not_allowed()
523
+
524
+ def do_DELETE(self):
525
+ self._method_not_allowed()
526
+
527
+ def do_OPTIONS(self):
528
+ self._method_not_allowed()
529
+
530
+ def do_HEAD(self):
531
+ self._method_not_allowed()
532
+
533
+ def _method_not_allowed(self):
534
+ self._response_started = False
535
+ if not self._valid_host():
536
+ self._send_json(421, {"ok": False, "error": "misdirected request"})
537
+ return
538
+ self._send_json(
539
+ 405,
540
+ {"ok": False, "error": "method not allowed"},
541
+ extra_headers={"Allow": "GET"},
542
+ )
543
+
544
+ def _do_get(self):
545
+ if not self._valid_host():
546
+ self._send_json(421, {"ok": False, "error": "misdirected request"})
547
+ return
548
+ parts = urlsplit(self.path)
549
+ if parts.scheme or parts.netloc:
550
+ self._send_json(400, {"ok": False, "error": "invalid request target"})
551
+ return
552
+
553
+ static_files = {
554
+ "/": ("index.html", "text/html; charset=utf-8"),
555
+ "/styles.css": ("styles.css", "text/css; charset=utf-8"),
556
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
557
+ }
558
+ if parts.path in static_files:
559
+ filename, content_type = static_files[parts.path]
560
+ try:
561
+ content = (self.server.static_root / filename).read_bytes()
562
+ except (FileNotFoundError, PermissionError, OSError):
563
+ self._send_json(404, {"ok": False, "error": "not found"})
564
+ return
565
+ self._send_bytes(200, content, content_type)
566
+ return
567
+
568
+ if not parts.path.startswith("/api/"):
569
+ self._send_json(404, {"ok": False, "error": "not found"})
570
+ return
571
+ is_sse = parts.path == "/api/events"
572
+ if not self._authorized(parts, allow_query_token=is_sse):
573
+ self._send_json(
574
+ 401,
575
+ {"ok": False, "error": "unauthorized"},
576
+ extra_headers={"WWW-Authenticate": "Bearer"},
577
+ )
578
+ return
579
+
580
+ if parts.path == "/api/health":
581
+ self._send_json(
582
+ 200,
583
+ {
584
+ "ok": True,
585
+ "apiVersion": 1,
586
+ "pid": os.getpid(),
587
+ "port": self.server.server_address[1],
588
+ "serverId": self.server.server_id,
589
+ },
590
+ )
591
+ elif parts.path == "/api/jobs":
592
+ self._send_json(200, self.server.job_store.snapshot())
593
+ elif parts.path.startswith("/api/jobs/"):
594
+ job_id = unquote(parts.path[len("/api/jobs/") :])
595
+ try:
596
+ detail = self.server.job_store.detail(job_id)
597
+ except JobNotFound:
598
+ self._send_json(404, {"ok": False, "error": "job not found"})
599
+ return
600
+ self._send_json(200, {"ok": True, "job": detail})
601
+ elif is_sse:
602
+ self._serve_events()
603
+ else:
604
+ self._send_json(404, {"ok": False, "error": "not found"})
605
+
606
+ def _valid_host(self):
607
+ values = self.headers.get_all("Host") or []
608
+ return len(values) == 1 and values[0] == self.server.expected_host
609
+
610
+ def _authorized(self, parts, allow_query_token=False):
611
+ candidate = None
612
+ values = self.headers.get_all("Authorization") or []
613
+ if len(values) == 1 and values[0].startswith("Bearer "):
614
+ candidate = values[0][7:]
615
+ if candidate is None and allow_query_token:
616
+ query = parse_qs(parts.query, keep_blank_values=True)
617
+ if set(query) == {"token"} and len(query["token"]) == 1:
618
+ candidate = query["token"][0]
619
+ if candidate is None:
620
+ return False
621
+ return hmac.compare_digest(
622
+ candidate.encode("utf-8"), self.server.token.encode("utf-8")
623
+ )
624
+
625
+ def _serve_events(self):
626
+ if not self.server.sse_slots.acquire(blocking=False):
627
+ self._send_json(503, {"ok": False, "error": "too many event streams"})
628
+ return
629
+ try:
630
+ self._response_started = True
631
+ self.send_response(200)
632
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
633
+ self._send_security_headers()
634
+ self.send_header("Connection", "close")
635
+ self.end_headers()
636
+ version, snapshot = self.server.broadcaster.current()
637
+ self._write_sse_snapshot(snapshot)
638
+ next_keepalive = time.monotonic() + self.server.keepalive_interval
639
+ while not self.server.stop_event.is_set():
640
+ if self._client_disconnected():
641
+ break
642
+ wait_timeout = min(
643
+ 0.25,
644
+ max(0.0, next_keepalive - time.monotonic()),
645
+ )
646
+ next_version, next_snapshot, changed = (
647
+ self.server.broadcaster.wait_for_change(
648
+ version, wait_timeout
649
+ )
650
+ )
651
+ if self.server.stop_event.is_set():
652
+ break
653
+ if changed:
654
+ version = next_version
655
+ self._write_sse_snapshot(next_snapshot)
656
+ next_keepalive = (
657
+ time.monotonic() + self.server.keepalive_interval
658
+ )
659
+ elif time.monotonic() >= next_keepalive:
660
+ self.wfile.write(b": keepalive\n\n")
661
+ self.wfile.flush()
662
+ next_keepalive = (
663
+ time.monotonic() + self.server.keepalive_interval
664
+ )
665
+ finally:
666
+ self.server.sse_slots.release()
667
+ self.close_connection = True
668
+
669
+ def _client_disconnected(self):
670
+ try:
671
+ readable, _writable, _exceptional = select.select(
672
+ [self.connection], [], [], 0
673
+ )
674
+ if not readable:
675
+ return False
676
+ return self.connection.recv(1, socket.MSG_PEEK) == b""
677
+ except (BlockingIOError, InterruptedError):
678
+ return False
679
+ except (OSError, ValueError):
680
+ return True
681
+
682
+ def _write_sse_snapshot(self, snapshot):
683
+ payload = json.dumps(
684
+ snapshot, ensure_ascii=False, separators=(",", ":")
685
+ ).encode("utf-8")
686
+ self.wfile.write(b"event: snapshot\n")
687
+ self.wfile.write(b"data: " + payload + b"\n\n")
688
+ self.wfile.flush()
689
+
690
+ def _send_json(self, status_code, value, extra_headers=None):
691
+ body = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode(
692
+ "utf-8"
693
+ )
694
+ self._send_bytes(
695
+ status_code,
696
+ body,
697
+ "application/json; charset=utf-8",
698
+ extra_headers=extra_headers,
699
+ )
700
+
701
+ def _send_bytes(self, status_code, body, content_type, extra_headers=None):
702
+ self._response_started = True
703
+ self.send_response(status_code)
704
+ self.send_header("Content-Type", content_type)
705
+ self._send_security_headers()
706
+ if extra_headers:
707
+ for name, value in extra_headers.items():
708
+ self.send_header(name, value)
709
+ self.send_header("Content-Length", str(len(body)))
710
+ self.end_headers()
711
+ if body and self.command != "HEAD":
712
+ self.wfile.write(body)
713
+
714
+ def _send_security_headers(self):
715
+ for name, value in SECURITY_HEADERS.items():
716
+ self.send_header(name, value)
717
+
718
+
719
+ class UIRuntimeError(Exception):
720
+ """A safe lifecycle operation could not be completed."""
721
+
722
+
723
+ class UIRuntime:
724
+ """Own one protected loopback listener for one OMNILANE_HOME."""
725
+
726
+ def __init__(self, home=None):
727
+ selected_home = home or os.environ.get("OMNILANE_HOME") or "~/.omnilane"
728
+ self.home = Path(selected_home).expanduser().absolute()
729
+ self.runtime_dir = self.home / "ui"
730
+ self.state_path = self.runtime_dir / "state.json"
731
+ self.lock_path = self.runtime_dir / "lifecycle.lock"
732
+ self.log_path = self.runtime_dir / "server.log"
733
+ self.jobs_path = self.home / "jobs"
734
+ self.static_root = Path(__file__).resolve().parents[1] / "ui"
735
+
736
+ def ensure_runtime_dir(self):
737
+ try:
738
+ self.runtime_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
739
+ info = os.lstat(self.runtime_dir)
740
+ except OSError as exc:
741
+ raise UIRuntimeError("cannot create protected runtime directory") from exc
742
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
743
+ raise UIRuntimeError("runtime path is not a safe directory")
744
+ os.chmod(self.runtime_dir, 0o700)
745
+
746
+ @contextmanager
747
+ def lifecycle_lock(self):
748
+ self.ensure_runtime_dir()
749
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
750
+ fd = None
751
+ try:
752
+ fd = os.open(self.lock_path, flags, 0o600)
753
+ info = os.fstat(fd)
754
+ if not stat.S_ISREG(info.st_mode):
755
+ raise UIRuntimeError("lifecycle lock is not a regular file")
756
+ os.fchmod(fd, 0o600)
757
+ fcntl.flock(fd, fcntl.LOCK_EX)
758
+ except (OSError, UIRuntimeError) as exc:
759
+ if fd is not None:
760
+ os.close(fd)
761
+ raise UIRuntimeError("cannot acquire lifecycle lock") from exc
762
+ try:
763
+ yield
764
+ finally:
765
+ fcntl.flock(fd, fcntl.LOCK_UN)
766
+ os.close(fd)
767
+
768
+ def read_state(self):
769
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
770
+ try:
771
+ fd = os.open(self.state_path, flags)
772
+ except FileNotFoundError:
773
+ return None
774
+ except OSError as exc:
775
+ raise UIRuntimeError("state path is unsafe") from exc
776
+ try:
777
+ info = os.fstat(fd)
778
+ if not stat.S_ISREG(info.st_mode) or info.st_size > 32768:
779
+ raise UIRuntimeError("state file is invalid")
780
+ if stat.S_IMODE(info.st_mode) & 0o077:
781
+ raise UIRuntimeError("state file permissions are too open")
782
+ data = self._read_fd(fd, 32769)
783
+ finally:
784
+ os.close(fd)
785
+ if len(data) > 32768:
786
+ raise UIRuntimeError("state file is too large")
787
+ try:
788
+ value = json.loads(data.decode("utf-8"))
789
+ except (UnicodeDecodeError, ValueError):
790
+ return None
791
+ return value if isinstance(value, dict) else None
792
+
793
+ def write_state(self, state_value):
794
+ self.ensure_runtime_dir()
795
+ data = json.dumps(
796
+ state_value, ensure_ascii=True, sort_keys=True, separators=(",", ":")
797
+ ).encode("utf-8")
798
+ temp_path = self.runtime_dir / (
799
+ ".state-{}-{}.tmp".format(os.getpid(), secrets.token_hex(8))
800
+ )
801
+ flags = (
802
+ os.O_WRONLY
803
+ | os.O_CREAT
804
+ | os.O_EXCL
805
+ | getattr(os, "O_NOFOLLOW", 0)
806
+ )
807
+ fd = None
808
+ try:
809
+ fd = os.open(temp_path, flags, 0o600)
810
+ os.fchmod(fd, 0o600)
811
+ self._write_fd(fd, data)
812
+ os.fsync(fd)
813
+ os.close(fd)
814
+ fd = None
815
+ os.replace(temp_path, self.state_path)
816
+ os.chmod(self.state_path, 0o600)
817
+ except OSError as exc:
818
+ raise UIRuntimeError("cannot write protected state") from exc
819
+ finally:
820
+ if fd is not None:
821
+ os.close(fd)
822
+ try:
823
+ os.unlink(temp_path)
824
+ except FileNotFoundError:
825
+ pass
826
+
827
+ def clear_state(self, expected_server_id=None):
828
+ try:
829
+ info = os.lstat(self.state_path)
830
+ except FileNotFoundError:
831
+ return False
832
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
833
+ raise UIRuntimeError("state path is unsafe")
834
+ if expected_server_id is not None:
835
+ current = self.read_state()
836
+ if not current or current.get("serverId") != expected_server_id:
837
+ return False
838
+ os.unlink(self.state_path)
839
+ return True
840
+
841
+ def open_log(self):
842
+ self.ensure_runtime_dir()
843
+ flags = (
844
+ os.O_WRONLY
845
+ | os.O_CREAT
846
+ | os.O_APPEND
847
+ | getattr(os, "O_NOFOLLOW", 0)
848
+ )
849
+ fd = None
850
+ try:
851
+ fd = os.open(self.log_path, flags, 0o600)
852
+ info = os.fstat(fd)
853
+ if not stat.S_ISREG(info.st_mode):
854
+ raise UIRuntimeError("server log is not a regular file")
855
+ os.fchmod(fd, 0o600)
856
+ return fd
857
+ except (OSError, UIRuntimeError) as exc:
858
+ if fd is not None:
859
+ os.close(fd)
860
+ raise UIRuntimeError("cannot open protected server log") from exc
861
+
862
+ @staticmethod
863
+ def complete_state(state_value):
864
+ if not isinstance(state_value, dict):
865
+ return False
866
+ required = ("serverId", "token", "pid", "port")
867
+ if any(key not in state_value for key in required):
868
+ return False
869
+ if not isinstance(state_value["serverId"], str) or len(state_value["serverId"]) < 8:
870
+ return False
871
+ if not isinstance(state_value["token"], str) or len(state_value["token"]) < 16:
872
+ return False
873
+ pid = state_value["pid"]
874
+ port = state_value["port"]
875
+ return (
876
+ not isinstance(pid, bool)
877
+ and isinstance(pid, int)
878
+ and 1 <= pid <= PID_MAX
879
+ and not isinstance(port, bool)
880
+ and isinstance(port, int)
881
+ and 1 <= port <= 65535
882
+ and state_value.get("apiVersion") == 1
883
+ )
884
+
885
+ def health(self, state_value, timeout=0.6):
886
+ if not self.complete_state(state_value):
887
+ return None
888
+ connection = http.client.HTTPConnection(
889
+ "127.0.0.1", state_value["port"], timeout=timeout
890
+ )
891
+ try:
892
+ connection.request(
893
+ "GET",
894
+ "/api/health",
895
+ headers={
896
+ "Authorization": "Bearer " + state_value["token"],
897
+ "Host": "127.0.0.1:{}".format(state_value["port"]),
898
+ },
899
+ )
900
+ response = connection.getresponse()
901
+ body = response.read(65537)
902
+ if response.status != 200 or len(body) > 65536:
903
+ return None
904
+ payload = json.loads(body.decode("utf-8"))
905
+ except (OSError, ValueError, UnicodeDecodeError, http.client.HTTPException):
906
+ return None
907
+ finally:
908
+ connection.close()
909
+ if not isinstance(payload, dict) or not payload.get("ok"):
910
+ return None
911
+ if (
912
+ payload.get("serverId") != state_value["serverId"]
913
+ or payload.get("pid") != state_value["pid"]
914
+ or payload.get("port") != state_value["port"]
915
+ ):
916
+ return None
917
+ return payload
918
+
919
+ def recorded_server_process_exists(self, state_value):
920
+ pid = state_value.get("pid") if isinstance(state_value, dict) else None
921
+ server_id = (
922
+ state_value.get("serverId") if isinstance(state_value, dict) else None
923
+ )
924
+ if (
925
+ isinstance(pid, bool)
926
+ or not isinstance(pid, int)
927
+ or pid < 1
928
+ or not isinstance(server_id, str)
929
+ ):
930
+ return False
931
+ try:
932
+ os.kill(pid, 0)
933
+ except ProcessLookupError:
934
+ return False
935
+ except PermissionError:
936
+ return True
937
+ except (OSError, ValueError) as exc:
938
+ return getattr(exc, "errno", None) != errno.ESRCH
939
+ try:
940
+ result = subprocess.run(
941
+ ["ps", "-p", str(pid), "-o", "command="],
942
+ stdout=subprocess.PIPE,
943
+ stderr=subprocess.DEVNULL,
944
+ text=True,
945
+ timeout=1,
946
+ check=False,
947
+ )
948
+ except (OSError, subprocess.TimeoutExpired):
949
+ # A live but uninspectable PID is ambiguous. Retaining state is
950
+ # safer than orphaning a possibly healthy authenticated server.
951
+ return True
952
+ command = result.stdout
953
+ return (
954
+ result.returncode == 0
955
+ and str(Path(__file__).resolve()) in command
956
+ and " serve " in command
957
+ and "--server-id " + server_id in command
958
+ )
959
+
960
+ @staticmethod
961
+ def url(state_value):
962
+ return "http://127.0.0.1:{}/#token={}".format(
963
+ state_value["port"], state_value["token"]
964
+ )
965
+
966
+ @staticmethod
967
+ def _read_fd(fd, count):
968
+ chunks = []
969
+ remaining = count
970
+ while remaining > 0:
971
+ chunk = os.read(fd, min(65536, remaining))
972
+ if not chunk:
973
+ break
974
+ chunks.append(chunk)
975
+ remaining -= len(chunk)
976
+ return b"".join(chunks)
977
+
978
+ @staticmethod
979
+ def _write_fd(fd, data):
980
+ offset = 0
981
+ while offset < len(data):
982
+ offset += os.write(fd, data[offset:])
983
+
984
+
985
+ def utc_now():
986
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
987
+ "+00:00", "Z"
988
+ )
989
+
990
+
991
+ def start_ui(runtime, requested_port):
992
+ with runtime.lifecycle_lock():
993
+ state_value = runtime.read_state()
994
+ if state_value is not None and runtime.health(state_value):
995
+ print("Live UI already running: {}".format(runtime.url(state_value)))
996
+ return 0
997
+ if state_value is not None and runtime.recorded_server_process_exists(
998
+ state_value
999
+ ):
1000
+ raise UIRuntimeError(
1001
+ "recorded server process is alive but health failed; "
1002
+ "state retained and no replacement started"
1003
+ )
1004
+ runtime.clear_state()
1005
+
1006
+ server_id = secrets.token_urlsafe(18)
1007
+ token = secrets.token_urlsafe(32)
1008
+ initial_state = {
1009
+ "apiVersion": 1,
1010
+ "serverId": server_id,
1011
+ "token": token,
1012
+ "pid": None,
1013
+ "port": None,
1014
+ "requestedPort": requested_port,
1015
+ "started": utc_now(),
1016
+ }
1017
+ runtime.write_state(initial_state)
1018
+ try:
1019
+ log_fd = runtime.open_log()
1020
+ except UIRuntimeError:
1021
+ runtime.clear_state(expected_server_id=server_id)
1022
+ raise
1023
+ command = [
1024
+ sys.executable,
1025
+ str(Path(__file__).resolve()),
1026
+ "serve",
1027
+ "--server-id",
1028
+ server_id,
1029
+ "--port",
1030
+ str(requested_port),
1031
+ ]
1032
+ child = None
1033
+ try:
1034
+ child = subprocess.Popen(
1035
+ command,
1036
+ stdin=subprocess.DEVNULL,
1037
+ stdout=log_fd,
1038
+ stderr=log_fd,
1039
+ start_new_session=True,
1040
+ close_fds=True,
1041
+ env=os.environ.copy(),
1042
+ )
1043
+ except OSError:
1044
+ runtime.clear_state(expected_server_id=server_id)
1045
+ raise
1046
+ finally:
1047
+ os.close(log_fd)
1048
+
1049
+ # 10s, not 6s: when the whole test suite (or a busy host) is churning
1050
+ # sibling servers, a healthy child can need more than 6s to come up,
1051
+ # and callers time out at 12s — keep the margin on both sides.
1052
+ deadline = time.monotonic() + 10.0
1053
+ while time.monotonic() < deadline and child.poll() is None:
1054
+ candidate = runtime.read_state()
1055
+ if (
1056
+ candidate
1057
+ and candidate.get("serverId") == server_id
1058
+ and candidate.get("pid") == child.pid
1059
+ and runtime.health(candidate)
1060
+ ):
1061
+ print("Live UI started: {}".format(runtime.url(candidate)))
1062
+ return 0
1063
+ time.sleep(0.05)
1064
+
1065
+ if child.poll() is None:
1066
+ child.terminate()
1067
+ try:
1068
+ child.wait(timeout=2)
1069
+ except subprocess.TimeoutExpired:
1070
+ child.kill()
1071
+ child.wait(timeout=2)
1072
+ runtime.clear_state(expected_server_id=server_id)
1073
+ raise UIRuntimeError("server failed to start; inspect {}".format(runtime.log_path))
1074
+
1075
+
1076
+ def status_ui(runtime):
1077
+ with runtime.lifecycle_lock():
1078
+ state_value = runtime.read_state()
1079
+ if state_value is not None and runtime.health(state_value):
1080
+ print("running pid={} port={}".format(state_value["pid"], state_value["port"]))
1081
+ return 0
1082
+ print("stopped")
1083
+ return 1
1084
+
1085
+
1086
+ def url_ui(runtime):
1087
+ with runtime.lifecycle_lock():
1088
+ state_value = runtime.read_state()
1089
+ if state_value is None or not runtime.health(state_value):
1090
+ raise UIRuntimeError("Live UI is not running")
1091
+ print(runtime.url(state_value))
1092
+ return 0
1093
+
1094
+
1095
+ def stop_ui(runtime):
1096
+ with runtime.lifecycle_lock():
1097
+ state_value = runtime.read_state()
1098
+ if state_value is None:
1099
+ if runtime.clear_state():
1100
+ print("stale state cleared; no process signalled")
1101
+ else:
1102
+ print("stopped")
1103
+ return 0
1104
+ first_health = runtime.health(state_value)
1105
+ second_health = runtime.health(state_value) if first_health else None
1106
+ if not first_health or not second_health:
1107
+ if runtime.recorded_server_process_exists(state_value):
1108
+ raise UIRuntimeError(
1109
+ "recorded server process is alive but health failed; "
1110
+ "state retained and no process signalled"
1111
+ )
1112
+ runtime.clear_state()
1113
+ print("stale state cleared; no process signalled")
1114
+ return 0
1115
+ try:
1116
+ os.kill(state_value["pid"], signal.SIGTERM)
1117
+ except ProcessLookupError:
1118
+ runtime.clear_state(expected_server_id=state_value["serverId"])
1119
+ print("stopped")
1120
+ return 0
1121
+ deadline = time.monotonic() + 6.0
1122
+ while time.monotonic() < deadline:
1123
+ if runtime.health(state_value, timeout=0.2) is None:
1124
+ runtime.clear_state(expected_server_id=state_value["serverId"])
1125
+ print("stopped")
1126
+ return 0
1127
+ time.sleep(0.05)
1128
+ raise UIRuntimeError("server did not stop; state retained")
1129
+
1130
+
1131
+ def serve_ui(runtime, server_id, requested_port):
1132
+ state_value = runtime.read_state()
1133
+ if not state_value or state_value.get("serverId") != server_id:
1134
+ raise UIRuntimeError("startup state does not match this server")
1135
+ token = state_value.get("token")
1136
+ if not isinstance(token, str) or len(token) < 16:
1137
+ raise UIRuntimeError("startup token is invalid")
1138
+ try:
1139
+ server = LiveHTTPServer(
1140
+ ("127.0.0.1", requested_port),
1141
+ JobStore(runtime.jobs_path),
1142
+ token,
1143
+ server_id,
1144
+ runtime.static_root,
1145
+ )
1146
+ except OSError as exc:
1147
+ if requested_port == 0 or exc.errno != errno.EADDRINUSE:
1148
+ raise
1149
+ server = LiveHTTPServer(
1150
+ ("127.0.0.1", 0),
1151
+ JobStore(runtime.jobs_path),
1152
+ token,
1153
+ server_id,
1154
+ runtime.static_root,
1155
+ )
1156
+
1157
+ current = runtime.read_state()
1158
+ if not current or current.get("serverId") != server_id:
1159
+ server.broadcaster.stop()
1160
+ server.server_close()
1161
+ raise UIRuntimeError("startup state changed before bind completed")
1162
+ current.update(
1163
+ {
1164
+ "apiVersion": 1,
1165
+ "pid": os.getpid(),
1166
+ "port": server.server_address[1],
1167
+ }
1168
+ )
1169
+ runtime.write_state(current)
1170
+
1171
+ def request_shutdown(_signum, _frame):
1172
+ threading.Thread(target=server.stop, daemon=True).start()
1173
+
1174
+ signal.signal(signal.SIGTERM, request_shutdown)
1175
+ signal.signal(signal.SIGHUP, request_shutdown)
1176
+ try:
1177
+ server.serve_forever(poll_interval=0.2)
1178
+ finally:
1179
+ server.stop_event.set()
1180
+ server.broadcaster.stop()
1181
+ server.server_close()
1182
+ with runtime.lifecycle_lock():
1183
+ runtime.clear_state(expected_server_id=server_id)
1184
+ return 0
1185
+
1186
+
1187
+ def parse_port(value):
1188
+ try:
1189
+ port = int(value, 10)
1190
+ except ValueError as exc:
1191
+ raise argparse.ArgumentTypeError("port must be an integer") from exc
1192
+ if not 0 <= port <= 65535:
1193
+ raise argparse.ArgumentTypeError("port must be between 0 and 65535")
1194
+ return port
1195
+
1196
+
1197
+ def main(argv=None):
1198
+ if sys.version_info < (3, 9):
1199
+ print("omnilane ui: Python 3.9 or newer is required", file=sys.stderr)
1200
+ return 1
1201
+ arguments = list(sys.argv[1:] if argv is None else argv)
1202
+ runtime = UIRuntime()
1203
+ try:
1204
+ if arguments[:1] == ["serve"]:
1205
+ private = argparse.ArgumentParser(prog="omnilane ui serve")
1206
+ private.add_argument("serve")
1207
+ private.add_argument("--server-id", required=True)
1208
+ private.add_argument("--port", type=parse_port, required=True)
1209
+ parsed = private.parse_args(arguments)
1210
+ return serve_ui(runtime, parsed.server_id, parsed.port)
1211
+
1212
+ parser = argparse.ArgumentParser(prog="omnilane ui")
1213
+ commands = parser.add_subparsers(dest="command", required=True)
1214
+ start_parser = commands.add_parser("start")
1215
+ start_parser.add_argument("--port", type=parse_port, default=8765)
1216
+ commands.add_parser("status")
1217
+ commands.add_parser("url")
1218
+ commands.add_parser("stop")
1219
+ parsed = parser.parse_args(arguments)
1220
+ if parsed.command == "start":
1221
+ return start_ui(runtime, parsed.port)
1222
+ if parsed.command == "status":
1223
+ return status_ui(runtime)
1224
+ if parsed.command == "url":
1225
+ return url_ui(runtime)
1226
+ return stop_ui(runtime)
1227
+ except UIRuntimeError as exc:
1228
+ print("omnilane ui: {}".format(exc), file=sys.stderr)
1229
+ return 1
1230
+ except (OSError, ValueError) as exc:
1231
+ print("omnilane ui: lifecycle operation failed: {}".format(exc), file=sys.stderr)
1232
+ return 1
1233
+
1234
+
1235
+ if __name__ == "__main__":
1236
+ sys.exit(main())