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/CHANGELOG.md +209 -0
- package/LICENSE +21 -0
- package/README.ja.md +345 -0
- package/README.ko.md +337 -0
- package/README.md +368 -0
- package/README.zh-CN.md +317 -0
- package/README.zh-TW.md +327 -0
- package/SECURITY.md +37 -0
- package/VERSION +1 -0
- package/bin/omnilane +72 -0
- package/completions/_omnilane +114 -0
- package/completions/omnilane.bash +123 -0
- package/local.sh.example +37 -0
- package/package.json +54 -0
- package/routing.local.yaml.example +41 -0
- package/routing.yaml +34 -0
- package/scripts/configure.sh +134 -0
- package/scripts/dispatch.sh +705 -0
- package/scripts/doctor.sh +166 -0
- package/scripts/jobs.sh +802 -0
- package/scripts/lib/common.sh +247 -0
- package/scripts/lib/i18n.sh +104 -0
- package/scripts/lib/job-timeout.pl +109 -0
- package/scripts/lib/job-worker.sh +23 -0
- package/scripts/release-audit.sh +314 -0
- package/scripts/runners/run-claude.sh +37 -0
- package/scripts/runners/run-codex.sh +51 -0
- package/scripts/runners/run-exec.sh +33 -0
- package/scripts/runners/run-gemini.sh +60 -0
- package/scripts/runners/run-grok.sh +56 -0
- package/scripts/runners/run-vote.sh +119 -0
- package/scripts/ui.py +1236 -0
- package/ui/app.js +1106 -0
- package/ui/index.html +192 -0
- package/ui/styles.css +1023 -0
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())
|