taskproof 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/api/server.py
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
"""Local REST API.
|
|
2
|
+
|
|
3
|
+
Security choices (deliberate, and the whole reason this module exists):
|
|
4
|
+
|
|
5
|
+
* It binds to **127.0.0.1 only** — never ``0.0.0.0``. The API exposes task
|
|
6
|
+
briefs, project paths and the audit trail; nothing here should ever be
|
|
7
|
+
reachable from another machine. Loopback is the boundary.
|
|
8
|
+
* It is **read-only**. There is no write surface and no session token: every
|
|
9
|
+
mutation lives behind the CLI, and the only routed verbs are ``GET`` (reads)
|
|
10
|
+
and ``OPTIONS`` (the CORS preflight). A write-shaped request to a path that
|
|
11
|
+
used to be a write endpoint is an unrouted method/path pair, so it is a plain
|
|
12
|
+
404 — never a 403. There is no gate left to forbid anything.
|
|
13
|
+
* CORS is narrow on purpose. The browser renderer is never same-origin with
|
|
14
|
+
this API: the dev renderer is a Vite dev server, and a packaged renderer is a
|
|
15
|
+
``file://`` page whose Origin is the literal string ``null``. Responses echo
|
|
16
|
+
``Access-Control-Allow-Origin`` back to *allowed* callers only — that literal
|
|
17
|
+
``null`` and loopback ``http`` origins (``http://localhost[:port]`` /
|
|
18
|
+
``http://127.0.0.1[:port]``). Every other http(s) origin
|
|
19
|
+
(``https://evil.example`` …) gets no CORS header, and never ``*``. This opens
|
|
20
|
+
cross-origin *reads* to the renderer, and that read access is the whole
|
|
21
|
+
surface.
|
|
22
|
+
|
|
23
|
+
Standard library only (``http.server``). The frontend consumes these endpoints.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
31
|
+
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
|
32
|
+
|
|
33
|
+
from .. import __version__, concurrency, dispatch, registry, storage, verify
|
|
34
|
+
from ..errors import RegistryError, UsageError
|
|
35
|
+
|
|
36
|
+
#: Loopback only. Do not make this configurable to a routable address.
|
|
37
|
+
HOST = "127.0.0.1"
|
|
38
|
+
DEFAULT_PORT = 8787
|
|
39
|
+
_DEFAULT_LIMIT = 20
|
|
40
|
+
|
|
41
|
+
#: Stable buckets in the grouped `/api/projects?by=project` summary. Every key is
|
|
42
|
+
#: always present so a client can draw the same set of columns without guessing.
|
|
43
|
+
_PROJECT_SUMMARY_STATUSES = (
|
|
44
|
+
"running",
|
|
45
|
+
"verifying",
|
|
46
|
+
"done",
|
|
47
|
+
"failed",
|
|
48
|
+
"blocked",
|
|
49
|
+
"timeout",
|
|
50
|
+
"cancelled",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: `GET /api/tasks/<id>/log` — with no `offset`, return the file's last
|
|
54
|
+
#: LOG_TAIL_BYTES. There is deliberately no "page backwards" affordance: the
|
|
55
|
+
#: window is the tail, and later requests resume forward from `next`.
|
|
56
|
+
LOG_TAIL_BYTES = 65536
|
|
57
|
+
#: Hard cap on the bytes any single log response may carry, tail or incremental.
|
|
58
|
+
LOG_CHUNK_BYTES = 262144
|
|
59
|
+
#: Floor between live `git status` probes for one task, in seconds.
|
|
60
|
+
LIVE_FILES_MIN_INTERVAL = 3.0
|
|
61
|
+
#: Short bound (seconds) on each live probe; a slow repo yields ``null``.
|
|
62
|
+
LIVE_PROBE_TIMEOUT = 5
|
|
63
|
+
|
|
64
|
+
#: The origins the desktop renderer may legitimately present, and nothing else.
|
|
65
|
+
#:
|
|
66
|
+
#: * ``null`` — a packaged renderer fetched from a ``file://`` page: the browser
|
|
67
|
+
#: sends the literal string ``null`` as ``Origin``.
|
|
68
|
+
#: * ``http://localhost[:port]`` and ``http://127.0.0.1[:port]`` — the Vite dev
|
|
69
|
+
#: server during development, which serves the renderer over loopback http.
|
|
70
|
+
#:
|
|
71
|
+
#: Everything else (``https://evil.example`` and any other routable http(s)
|
|
72
|
+
#: origin) is refused a CORS header. We echo the caller's origin instead of
|
|
73
|
+
#: sending ``*``: ``*`` would hand the API to any web page the user happens to
|
|
74
|
+
#: have open.
|
|
75
|
+
_LOOPBACK_ORIGIN = re.compile(
|
|
76
|
+
r"^http://(?:localhost|127\.0\.0\.1)(?::\d+)?$", re.IGNORECASE
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
#: Verbs a browser may use across origins. GET is the only routed verb; HEAD
|
|
80
|
+
#: and the write verbs are not advertised (they are rejected shapes, not usable
|
|
81
|
+
#: endpoints).
|
|
82
|
+
_CORS_METHODS = "GET"
|
|
83
|
+
|
|
84
|
+
#: Request header a cross-origin read may carry. The write-gate token is gone,
|
|
85
|
+
#: so nothing secret is advertised here; the browser matches case-insensitively.
|
|
86
|
+
_CORS_HEADERS = "Content-Type"
|
|
87
|
+
|
|
88
|
+
#: How long a browser may cache a successful preflight, in seconds.
|
|
89
|
+
_CORS_MAX_AGE = "600"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _allowed_origin(origin):
|
|
93
|
+
"""True when ``origin`` may receive an ``Access-Control-Allow-Origin`` echo.
|
|
94
|
+
|
|
95
|
+
Only the packaged ``file://`` renderer (origin ``null``) and loopback http
|
|
96
|
+
dev origins qualify; any other value is refused.
|
|
97
|
+
"""
|
|
98
|
+
if not origin:
|
|
99
|
+
return False
|
|
100
|
+
if origin == "null":
|
|
101
|
+
return True
|
|
102
|
+
return bool(_LOOPBACK_ORIGIN.match(origin))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _json_default(value):
|
|
106
|
+
# sqlite3.Row / datetime / etc. — keep the response always serialisable.
|
|
107
|
+
return str(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _first(query, key):
|
|
111
|
+
values = query.get(key)
|
|
112
|
+
if not values:
|
|
113
|
+
return None
|
|
114
|
+
value = values[0]
|
|
115
|
+
return value if value != "" else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
#: Statuses whose worktree we probe for `files_changed_live`. Every other row
|
|
119
|
+
#: reports ``None`` -- the final `files_changed` is written once at step ⑥.
|
|
120
|
+
_LIVE_STATUSES = ("running", "verifying")
|
|
121
|
+
|
|
122
|
+
#: Process-wide `{task_id: (monotonic, value)}` cache for the live probe.
|
|
123
|
+
#:
|
|
124
|
+
#: It must live at module scope, not on the handler: ``ThreadingHTTPServer``
|
|
125
|
+
#: builds a fresh handler instance per request, so an instance attribute would
|
|
126
|
+
#: never be hit and every poll would re-shell out to `git`.
|
|
127
|
+
_LIVE_FILES_CACHE = {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _utf8_lead_length(byte):
|
|
131
|
+
"""Expected total length of the UTF-8 character starting at ``byte``."""
|
|
132
|
+
if byte < 0x80:
|
|
133
|
+
return 1
|
|
134
|
+
if byte >= 0xF0:
|
|
135
|
+
return 4
|
|
136
|
+
if byte >= 0xE0:
|
|
137
|
+
return 3
|
|
138
|
+
if byte >= 0xC0:
|
|
139
|
+
return 2
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _utf8_prefix_length(raw):
|
|
144
|
+
"""Largest prefix length of ``raw`` that ends on a UTF-8 character boundary.
|
|
145
|
+
|
|
146
|
+
The caller picked the slice; if its final bytes open a multi-byte character
|
|
147
|
+
that the slice cuts in half, drop them and let the caller resume at that
|
|
148
|
+
character's first byte. Decoding only a boundary-aligned prefix keeps the
|
|
149
|
+
resume point loss-free: the next read starts exactly where the dropped bytes
|
|
150
|
+
began.
|
|
151
|
+
"""
|
|
152
|
+
back = 1
|
|
153
|
+
while back <= 3 and back <= len(raw):
|
|
154
|
+
byte = raw[-back]
|
|
155
|
+
if byte & 0xC0 != 0x80:
|
|
156
|
+
# ``byte`` is a lead (or ASCII) byte at distance ``back`` from the end.
|
|
157
|
+
needed = _utf8_lead_length(byte)
|
|
158
|
+
if needed <= back:
|
|
159
|
+
return len(raw) # its character completes inside the slice
|
|
160
|
+
return len(raw) - back # cut: drop from this character's first byte
|
|
161
|
+
back += 1
|
|
162
|
+
return len(raw)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _log_leading_partial(raw):
|
|
166
|
+
"""Count leading continuation bytes: a character whose start lies before ``raw``.
|
|
167
|
+
|
|
168
|
+
Only used for a tail slice, whose window start we chose. Skipping them lets
|
|
169
|
+
the response begin on a character boundary instead of a replacement glyph.
|
|
170
|
+
"""
|
|
171
|
+
skipped = 0
|
|
172
|
+
while skipped < 3 and skipped < len(raw) and raw[skipped] & 0xC0 == 0x80:
|
|
173
|
+
skipped += 1
|
|
174
|
+
return skipped
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _empty_log(task_id, size=0, offset=0):
|
|
178
|
+
return {
|
|
179
|
+
"task_id": task_id,
|
|
180
|
+
"offset": offset,
|
|
181
|
+
"next": offset,
|
|
182
|
+
"text": "",
|
|
183
|
+
"eof": True,
|
|
184
|
+
"size": size,
|
|
185
|
+
"omitted": 0,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _read_log(log_path, task_id, offset):
|
|
190
|
+
"""Read one log window. ``offset is None`` requests the tail.
|
|
191
|
+
|
|
192
|
+
Contract (Card B depends on these exact names): a 200 body is
|
|
193
|
+
``{task_id, offset, next, text, eof, size, omitted}`` -- ``offset`` is where
|
|
194
|
+
the returned bytes start, ``next`` where the client resumes, and ``omitted``
|
|
195
|
+
the bytes before the window the client does not have (0 for an incremental
|
|
196
|
+
request). A missing log file is a normal empty tail, never an error.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
size = os.stat(log_path).st_size
|
|
200
|
+
except OSError:
|
|
201
|
+
return _empty_log(task_id)
|
|
202
|
+
|
|
203
|
+
if offset is None:
|
|
204
|
+
start = max(0, size - LOG_TAIL_BYTES)
|
|
205
|
+
omitted = start
|
|
206
|
+
tail = True
|
|
207
|
+
else:
|
|
208
|
+
if offset > size:
|
|
209
|
+
# File was truncated/rotated under us: nothing to show, resume at EOF.
|
|
210
|
+
return _empty_log(task_id, size=size, offset=size)
|
|
211
|
+
start = offset
|
|
212
|
+
omitted = 0
|
|
213
|
+
tail = False
|
|
214
|
+
|
|
215
|
+
end = min(size, start + LOG_CHUNK_BYTES)
|
|
216
|
+
try:
|
|
217
|
+
with open(log_path, "rb") as handle:
|
|
218
|
+
handle.seek(start)
|
|
219
|
+
raw = handle.read(end - start)
|
|
220
|
+
except OSError:
|
|
221
|
+
return _empty_log(task_id)
|
|
222
|
+
|
|
223
|
+
if tail:
|
|
224
|
+
skipped = _log_leading_partial(raw)
|
|
225
|
+
if skipped:
|
|
226
|
+
start += skipped
|
|
227
|
+
omitted += skipped
|
|
228
|
+
raw = raw[skipped:]
|
|
229
|
+
|
|
230
|
+
kept = _utf8_prefix_length(raw)
|
|
231
|
+
raw = raw[:kept]
|
|
232
|
+
next_offset = start + len(raw)
|
|
233
|
+
return {
|
|
234
|
+
"task_id": task_id,
|
|
235
|
+
"offset": start,
|
|
236
|
+
"next": next_offset,
|
|
237
|
+
"text": raw.decode("utf-8", errors="replace"),
|
|
238
|
+
"eof": next_offset >= size,
|
|
239
|
+
"size": size,
|
|
240
|
+
"omitted": omitted,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _live_files_changed(row):
|
|
245
|
+
"""`files_changed_live` for one row: an int while running/verifying, else None.
|
|
246
|
+
|
|
247
|
+
Same yardstick as the final `files_changed` (`git status --porcelain` line
|
|
248
|
+
count), probed through `verify.detect_changes` with a short timeout. The
|
|
249
|
+
result is cached per task for `LIVE_FILES_MIN_INTERVAL` seconds -- a cache
|
|
250
|
+
hit returns the previous value without shelling out again. Any probe failure
|
|
251
|
+
(not a repo, no git, timeout) is `None`, never an exception: this must never
|
|
252
|
+
500 or stall a list request.
|
|
253
|
+
"""
|
|
254
|
+
if row.get("status") not in _LIVE_STATUSES:
|
|
255
|
+
return None
|
|
256
|
+
workdir = row.get("workdir")
|
|
257
|
+
if not workdir:
|
|
258
|
+
return None
|
|
259
|
+
task_id = row.get("id")
|
|
260
|
+
now = time.monotonic()
|
|
261
|
+
cached = _LIVE_FILES_CACHE.get(task_id)
|
|
262
|
+
if cached is not None and now - cached[0] < LIVE_FILES_MIN_INTERVAL:
|
|
263
|
+
return cached[1]
|
|
264
|
+
try:
|
|
265
|
+
value = verify.detect_changes(workdir, timeout=LIVE_PROBE_TIMEOUT)
|
|
266
|
+
except Exception:
|
|
267
|
+
value = None
|
|
268
|
+
_LIVE_FILES_CACHE[task_id] = (now, value)
|
|
269
|
+
return value
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _with_live_files(row):
|
|
273
|
+
"""Copy ``row`` and add the derived ``files_changed_live`` field."""
|
|
274
|
+
row = dict(row)
|
|
275
|
+
row["files_changed_live"] = _live_files_changed(row)
|
|
276
|
+
return row
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _with_live_files_many(rows):
|
|
280
|
+
"""One shared helper for both the list and the detail endpoint."""
|
|
281
|
+
return [_with_live_files(row) for row in rows]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
#: Static-asset content types for the served web build. Anything not listed is
|
|
285
|
+
#: ``application/octet-stream`` -- an unknown extension must never be guessed.
|
|
286
|
+
_CONTENT_TYPES = {
|
|
287
|
+
".html": "text/html; charset=utf-8",
|
|
288
|
+
".htm": "text/html; charset=utf-8",
|
|
289
|
+
".js": "text/javascript; charset=utf-8",
|
|
290
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
291
|
+
".css": "text/css; charset=utf-8",
|
|
292
|
+
".svg": "image/svg+xml",
|
|
293
|
+
".png": "image/png",
|
|
294
|
+
".ico": "image/x-icon",
|
|
295
|
+
".json": "application/json; charset=utf-8",
|
|
296
|
+
".map": "application/json; charset=utf-8",
|
|
297
|
+
".woff2": "font/woff2",
|
|
298
|
+
".woff": "font/woff",
|
|
299
|
+
".ttf": "font/ttf",
|
|
300
|
+
".webp": "image/webp",
|
|
301
|
+
".jpg": "image/jpeg",
|
|
302
|
+
".jpeg": "image/jpeg",
|
|
303
|
+
".gif": "image/gif",
|
|
304
|
+
}
|
|
305
|
+
_DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _redirect_location(query):
|
|
309
|
+
"""Map the retired ``?project=``/``?task=`` query onto a hash route.
|
|
310
|
+
|
|
311
|
+
The desktop renderer owns navigation through its hash router; the old
|
|
312
|
+
server-rendered board encoded the same filters straight into the query. Only
|
|
313
|
+
``/`` speaks this dialect -- a bare ``/`` (no filters) returns ``None`` so it
|
|
314
|
+
serves ``index.html`` unchanged. Returns the ``Location`` value to use.
|
|
315
|
+
"""
|
|
316
|
+
projects = [pid for pid in query.get("project", []) if pid]
|
|
317
|
+
task = next((tid for tid in query.get("task", []) if tid), None)
|
|
318
|
+
if not projects and task is None:
|
|
319
|
+
return None
|
|
320
|
+
route = "/#/matrix"
|
|
321
|
+
if task is not None:
|
|
322
|
+
route += "/" + quote(task, safe="")
|
|
323
|
+
if projects:
|
|
324
|
+
route += "?projects=" + quote(",".join(projects), safe=",")
|
|
325
|
+
return route
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
329
|
+
"""One request handler per server; ``workspace`` is set per instance class."""
|
|
330
|
+
|
|
331
|
+
server_version = "taskproof/" + __version__
|
|
332
|
+
workspace = None
|
|
333
|
+
#: When set, non-``/api/`` GETs are served from this directory (the desktop
|
|
334
|
+
#: renderer's web build). ``None`` keeps the long-standing API-only shape:
|
|
335
|
+
#: every non-``/api/`` path is a 404 JSON, byte for byte.
|
|
336
|
+
static_root = None
|
|
337
|
+
|
|
338
|
+
# -- verbs ------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def do_GET(self): # noqa: N802 (stdlib naming)
|
|
341
|
+
if self.static_root is not None and not self._is_api_path():
|
|
342
|
+
self._serve_static()
|
|
343
|
+
return
|
|
344
|
+
try:
|
|
345
|
+
status, payload = self._route()
|
|
346
|
+
except Exception:
|
|
347
|
+
# Never leak a traceback (or internals) to the client.
|
|
348
|
+
status, payload = 500, {"error": "internal server error"}
|
|
349
|
+
self._send(status, payload)
|
|
350
|
+
|
|
351
|
+
def _is_api_path(self):
|
|
352
|
+
path = urlsplit(self.path).path
|
|
353
|
+
return path == "/api" or path.startswith("/api/")
|
|
354
|
+
|
|
355
|
+
def do_POST(self): # noqa: N802
|
|
356
|
+
# The write surface is gone, so these requests no longer correspond to
|
|
357
|
+
# any route: that is a plain 404 (an unrouted method/path pair), never a
|
|
358
|
+
# permission problem -- there is no gate left to forbid anything.
|
|
359
|
+
self._reject_write()
|
|
360
|
+
|
|
361
|
+
def do_PATCH(self): # noqa: N802
|
|
362
|
+
self._reject_write()
|
|
363
|
+
|
|
364
|
+
def do_DELETE(self): # noqa: N802
|
|
365
|
+
self._reject_write()
|
|
366
|
+
|
|
367
|
+
def do_PUT(self): # noqa: N802
|
|
368
|
+
self._reject_write()
|
|
369
|
+
|
|
370
|
+
def do_HEAD(self): # noqa: N802
|
|
371
|
+
# HEAD is not a routed read; keep the long-standing "method not allowed"
|
|
372
|
+
# rejection for it.
|
|
373
|
+
self._send(405, {"error": "method not allowed"})
|
|
374
|
+
|
|
375
|
+
def _reject_write(self):
|
|
376
|
+
# Every old write verb lands here: the method/path pair is simply not
|
|
377
|
+
# routed any more.
|
|
378
|
+
parsed = urlsplit(self.path)
|
|
379
|
+
self._send(404, {"error": "not found", "path": parsed.path})
|
|
380
|
+
|
|
381
|
+
def do_OPTIONS(self): # noqa: N802
|
|
382
|
+
# CORS preflight. A browser sends it before a cross-origin request that
|
|
383
|
+
# is not "simple"; the reads this API routes are simple GETs, so this
|
|
384
|
+
# exists for completeness and echoes the GET-only capability list.
|
|
385
|
+
#
|
|
386
|
+
# An allowed origin gets the capability list; anyone else gets a bare
|
|
387
|
+
# 2xx with no ``Access-Control-Allow-Origin`` and the browser blocks the
|
|
388
|
+
# actual request. We do not 4xx a foreign origin: the browser owns that
|
|
389
|
+
# decision, and a different status would leak nothing useful anyway.
|
|
390
|
+
self.send_response(204)
|
|
391
|
+
self._send_cors_headers()
|
|
392
|
+
if _allowed_origin(self.headers.get("Origin")):
|
|
393
|
+
self.send_header("Access-Control-Allow-Methods", _CORS_METHODS)
|
|
394
|
+
self.send_header("Access-Control-Allow-Headers", _CORS_HEADERS)
|
|
395
|
+
self.send_header("Access-Control-Max-Age", _CORS_MAX_AGE)
|
|
396
|
+
self.send_header("Content-Length", "0")
|
|
397
|
+
self.end_headers()
|
|
398
|
+
|
|
399
|
+
# -- routing ----------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def _route(self):
|
|
402
|
+
parsed = urlsplit(self.path)
|
|
403
|
+
path = parsed.path.rstrip("/") or "/"
|
|
404
|
+
|
|
405
|
+
if path == "/api/health":
|
|
406
|
+
return 200, {
|
|
407
|
+
"ok": True,
|
|
408
|
+
"version": __version__,
|
|
409
|
+
# Read-only effective cap + source (card 42). Card 39's desktop
|
|
410
|
+
# picks this up; the shape is `concurrency.CapSetting.as_dict()`.
|
|
411
|
+
"concurrency": self._concurrency(),
|
|
412
|
+
}
|
|
413
|
+
if path == "/api/summary":
|
|
414
|
+
return 200, {
|
|
415
|
+
"summary": self._summary(),
|
|
416
|
+
"concurrency": self._concurrency(),
|
|
417
|
+
}
|
|
418
|
+
if path == "/api/registry":
|
|
419
|
+
return self._registry()
|
|
420
|
+
if path == "/api/projects":
|
|
421
|
+
if _first(parse_qs(parsed.query), "by") == "project":
|
|
422
|
+
return 200, {"projects": self._projects_by_project()}
|
|
423
|
+
return 200, {"projects": self._projects()}
|
|
424
|
+
if path == "/api/tasks":
|
|
425
|
+
return self._tasks(parse_qs(parsed.query))
|
|
426
|
+
|
|
427
|
+
prefix = "/api/tasks/"
|
|
428
|
+
if path.startswith(prefix):
|
|
429
|
+
rest = path[len(prefix):]
|
|
430
|
+
if rest.endswith("/events"):
|
|
431
|
+
return self._events(rest[: -len("/events")])
|
|
432
|
+
# `GET /api/tasks/<id>/log` is a read-only log window: no offset ->
|
|
433
|
+
# the tail (last LOG_TAIL_BYTES); with an offset -> forward from it,
|
|
434
|
+
# capped at LOG_CHUNK_BYTES per response. There is intentionally NO
|
|
435
|
+
# "page backwards" affordance -- the client owns what it has read and
|
|
436
|
+
# resumes from the `next` byte offset.
|
|
437
|
+
if rest.endswith("/log"):
|
|
438
|
+
return self._log(rest[: -len("/log")], parse_qs(parsed.query))
|
|
439
|
+
if "/" in rest:
|
|
440
|
+
return 404, {"error": "not found", "path": parsed.path}
|
|
441
|
+
return self._detail(rest)
|
|
442
|
+
|
|
443
|
+
return 404, {"error": "not found", "path": parsed.path}
|
|
444
|
+
|
|
445
|
+
# -- endpoints --------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def _concurrency(self):
|
|
448
|
+
"""The effective global cap + its source, from the registry (never writes).
|
|
449
|
+
|
|
450
|
+
This is the one new *field* on an existing read-only endpoint — no new
|
|
451
|
+
write surface. Shape: ``{value: int, source: "auto"|"toml"|"cli",
|
|
452
|
+
detail: str}`` (see ``concurrency.CapSetting``).
|
|
453
|
+
"""
|
|
454
|
+
try:
|
|
455
|
+
reg = registry.load(registry.workspace_registry_path(self.workspace))
|
|
456
|
+
defaults = reg.defaults
|
|
457
|
+
except RegistryError:
|
|
458
|
+
defaults = {}
|
|
459
|
+
return concurrency.resolve(defaults).as_dict()
|
|
460
|
+
|
|
461
|
+
def _summary(self):
|
|
462
|
+
conn = storage.connect(storage.db_path(self.workspace))
|
|
463
|
+
try:
|
|
464
|
+
storage.migrate(conn)
|
|
465
|
+
return dispatch.summary_counts(self.workspace, conn)
|
|
466
|
+
finally:
|
|
467
|
+
conn.close()
|
|
468
|
+
|
|
469
|
+
def _projects(self):
|
|
470
|
+
reg_path = registry.workspace_registry_path(self.workspace)
|
|
471
|
+
reg = registry.load(reg_path)
|
|
472
|
+
probes = registry.read_probe_flags(reg_path)
|
|
473
|
+
conn = storage.connect(storage.db_path(self.workspace))
|
|
474
|
+
try:
|
|
475
|
+
storage.migrate(conn)
|
|
476
|
+
overview = {row["project"]: row for row in storage.project_overview(conn)}
|
|
477
|
+
finally:
|
|
478
|
+
conn.close()
|
|
479
|
+
projects = []
|
|
480
|
+
# One row per lane (taskgroup), unchanged shape: the dashboard keys its
|
|
481
|
+
# `?project=` filter off the row id. The only addition is the owning
|
|
482
|
+
# `project` field inside `taskgroup_record`.
|
|
483
|
+
for taskgroup in reg.taskgroups:
|
|
484
|
+
record = registry.taskgroup_record(taskgroup, probes)
|
|
485
|
+
stat = overview.get(taskgroup.id) or {}
|
|
486
|
+
record.update(
|
|
487
|
+
{
|
|
488
|
+
# Overview numbers, derived from the same aggregate SQL the
|
|
489
|
+
# `projects` command uses.
|
|
490
|
+
"tasks": int(stat.get("total", 0)),
|
|
491
|
+
"in_progress": int(stat.get("in_progress", 0)),
|
|
492
|
+
"failed": int(stat.get("failed", 0)),
|
|
493
|
+
"last_activity": stat.get("last_activity"),
|
|
494
|
+
}
|
|
495
|
+
)
|
|
496
|
+
projects.append(record)
|
|
497
|
+
return projects
|
|
498
|
+
|
|
499
|
+
def _projects_by_project(self):
|
|
500
|
+
"""`GET /api/projects?by=project` -- one row per owning project.
|
|
501
|
+
|
|
502
|
+
This is deliberately a separate view: the historical `/api/projects`
|
|
503
|
+
payload stays one row per lane for the desktop client. The grouped view
|
|
504
|
+
nests each project's lane records and sums their lifecycle statuses.
|
|
505
|
+
"""
|
|
506
|
+
reg_path = registry.workspace_registry_path(self.workspace)
|
|
507
|
+
reg = registry.load(reg_path)
|
|
508
|
+
probes = registry.read_probe_flags(reg_path)
|
|
509
|
+
conn = storage.connect(storage.db_path(self.workspace))
|
|
510
|
+
try:
|
|
511
|
+
storage.migrate(conn)
|
|
512
|
+
lane_counts = {}
|
|
513
|
+
for row in conn.execute(
|
|
514
|
+
"SELECT project, status, COUNT(*) AS count "
|
|
515
|
+
"FROM tasks GROUP BY project, status"
|
|
516
|
+
):
|
|
517
|
+
lane_counts.setdefault(row["project"], {})[row["status"]] = int(
|
|
518
|
+
row["count"]
|
|
519
|
+
)
|
|
520
|
+
finally:
|
|
521
|
+
conn.close()
|
|
522
|
+
|
|
523
|
+
projects = []
|
|
524
|
+
for project in reg.projects:
|
|
525
|
+
lanes = reg.lanes_for(project.id)
|
|
526
|
+
summary = {status: 0 for status in _PROJECT_SUMMARY_STATUSES}
|
|
527
|
+
for lane in lanes:
|
|
528
|
+
for status, count in lane_counts.get(lane.id, {}).items():
|
|
529
|
+
if status in summary:
|
|
530
|
+
summary[status] += count
|
|
531
|
+
projects.append(
|
|
532
|
+
{
|
|
533
|
+
"id": project.id,
|
|
534
|
+
"path": project.path,
|
|
535
|
+
"aliases": list(project.aliases),
|
|
536
|
+
"taskgroups": [
|
|
537
|
+
registry.taskgroup_record(lane, probes) for lane in lanes
|
|
538
|
+
],
|
|
539
|
+
"summary": summary,
|
|
540
|
+
}
|
|
541
|
+
)
|
|
542
|
+
return projects
|
|
543
|
+
|
|
544
|
+
def _registry(self):
|
|
545
|
+
reg_path = registry.workspace_registry_path(self.workspace)
|
|
546
|
+
try:
|
|
547
|
+
return 200, registry.registry_metadata(reg_path)
|
|
548
|
+
except RegistryError as exc:
|
|
549
|
+
return 404, {"error": str(exc), "path": reg_path}
|
|
550
|
+
|
|
551
|
+
def _tasks(self, query):
|
|
552
|
+
limit = _DEFAULT_LIMIT
|
|
553
|
+
raw_limit = _first(query, "limit")
|
|
554
|
+
if raw_limit is not None:
|
|
555
|
+
try:
|
|
556
|
+
limit = max(1, int(raw_limit))
|
|
557
|
+
except (TypeError, ValueError):
|
|
558
|
+
limit = _DEFAULT_LIMIT
|
|
559
|
+
|
|
560
|
+
project_key = _first(query, "project")
|
|
561
|
+
project_ids = None
|
|
562
|
+
if project_key is not None:
|
|
563
|
+
project_ids = self._project_filter_ids(project_key)
|
|
564
|
+
|
|
565
|
+
conn = storage.connect(storage.db_path(self.workspace))
|
|
566
|
+
try:
|
|
567
|
+
storage.migrate(conn)
|
|
568
|
+
status = _first(query, "status")
|
|
569
|
+
if project_ids is None:
|
|
570
|
+
rows = storage.list_tasks(conn, status=status, limit=limit)
|
|
571
|
+
else:
|
|
572
|
+
rows = []
|
|
573
|
+
for lane_id in project_ids:
|
|
574
|
+
rows.extend(
|
|
575
|
+
storage.list_tasks(
|
|
576
|
+
conn, status=status, project=lane_id, limit=limit
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
rows.sort(
|
|
580
|
+
key=lambda row: row["created_at"] or "",
|
|
581
|
+
reverse=True,
|
|
582
|
+
)
|
|
583
|
+
rows = rows[:limit]
|
|
584
|
+
tasks = _with_live_files_many(rows)
|
|
585
|
+
finally:
|
|
586
|
+
conn.close()
|
|
587
|
+
return 200, {"tasks": tasks, "count": len(tasks)}
|
|
588
|
+
|
|
589
|
+
def _project_filter_ids(self, key):
|
|
590
|
+
"""Resolve `?project=` to lane ids while preserving lane-only fallback.
|
|
591
|
+
|
|
592
|
+
A taskgroup id wins over a project id (the old filter contract). A
|
|
593
|
+
project id expands to every lane it owns. If the registry is missing or
|
|
594
|
+
malformed, the key is treated as a lane id exactly as it was before this
|
|
595
|
+
endpoint learned about projects. Unknown keys return no rows, not 500.
|
|
596
|
+
"""
|
|
597
|
+
if not key:
|
|
598
|
+
return []
|
|
599
|
+
try:
|
|
600
|
+
reg = registry.load(registry.workspace_registry_path(self.workspace))
|
|
601
|
+
except RegistryError:
|
|
602
|
+
return [key]
|
|
603
|
+
if any(lane.id == key for lane in reg.taskgroups):
|
|
604
|
+
return [key]
|
|
605
|
+
if any(project.id == key for project in reg.projects):
|
|
606
|
+
return [lane.id for lane in reg.lanes_for(key)]
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
def _detail(self, task_id):
|
|
610
|
+
detail = dispatch.task_detail(self.workspace, task_id)
|
|
611
|
+
if detail.get("task") is None:
|
|
612
|
+
return 404, {"error": "no such task", "task_id": task_id}
|
|
613
|
+
# Same helper as `_tasks`, so the card and the drawer cannot disagree.
|
|
614
|
+
detail["task"] = _with_live_files(detail["task"])
|
|
615
|
+
return 200, detail
|
|
616
|
+
|
|
617
|
+
def _log(self, task_id, query):
|
|
618
|
+
"""`GET /api/tasks/<id>/log` -- tail by default, forward with `offset`."""
|
|
619
|
+
task_id = unquote(task_id)
|
|
620
|
+
# Log paths are only ever `workspace/logs/<id>.log`; reject any id that
|
|
621
|
+
# could steer that join (path separators or ``..``) before touching disk.
|
|
622
|
+
if not task_id or "/" in task_id or "\\" in task_id or ".." in task_id:
|
|
623
|
+
return 404, {"error": "not found", "path": self.path}
|
|
624
|
+
|
|
625
|
+
# A missing task is a 404; a missing *log file* on a real task is a
|
|
626
|
+
# normal empty tail (a task that never produced output).
|
|
627
|
+
detail = dispatch.task_detail(self.workspace, task_id)
|
|
628
|
+
if detail.get("task") is None:
|
|
629
|
+
return 404, {"error": "no such task", "task_id": task_id}
|
|
630
|
+
|
|
631
|
+
raw_offset = _first(query, "offset")
|
|
632
|
+
offset = None
|
|
633
|
+
if raw_offset is not None:
|
|
634
|
+
try:
|
|
635
|
+
offset = int(raw_offset)
|
|
636
|
+
except (TypeError, ValueError):
|
|
637
|
+
return 400, {"error": "offset must be an integer", "offset": raw_offset}
|
|
638
|
+
if offset < 0:
|
|
639
|
+
return 400, {"error": "offset must be >= 0", "offset": raw_offset}
|
|
640
|
+
|
|
641
|
+
log_path = os.path.join(self.workspace, "logs", task_id + ".log")
|
|
642
|
+
return 200, _read_log(log_path, task_id, offset)
|
|
643
|
+
|
|
644
|
+
def _events(self, task_id):
|
|
645
|
+
detail = dispatch.task_detail(self.workspace, task_id)
|
|
646
|
+
if detail.get("task") is None:
|
|
647
|
+
return 404, {"error": "no such task", "task_id": task_id}
|
|
648
|
+
return 200, {"task_id": task_id, "events": detail["events"]}
|
|
649
|
+
|
|
650
|
+
# -- plumbing ---------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
def _serve_static(self):
|
|
653
|
+
"""Serve the desktop web build for any non-``/api/`` GET.
|
|
654
|
+
|
|
655
|
+
Only reached when ``static_root`` is set. The decoded path is resolved
|
|
656
|
+
with ``os.path.realpath`` and must stay inside the root: ``..``, an
|
|
657
|
+
encoded ``%2e%2e``, an absolute path, or a symlink that leaves the tree
|
|
658
|
+
is refused. A genuine miss with a file extension is a 404 -- never the
|
|
659
|
+
SPA ``index.html`` -- so a missing script cannot be fed HTML. Everything
|
|
660
|
+
else (``/``, extension-less paths, directories) falls back to
|
|
661
|
+
``index.html``.
|
|
662
|
+
"""
|
|
663
|
+
parsed = urlsplit(self.path)
|
|
664
|
+
decoded = unquote(parsed.path)
|
|
665
|
+
|
|
666
|
+
if decoded == "/":
|
|
667
|
+
location = _redirect_location(parse_qs(parsed.query))
|
|
668
|
+
if location is not None:
|
|
669
|
+
self._send_redirect(location)
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
root = os.path.realpath(self.static_root)
|
|
673
|
+
rel = decoded.lstrip("/")
|
|
674
|
+
target = os.path.realpath(os.path.join(root, rel))
|
|
675
|
+
if target != root and not target.startswith(root + os.sep):
|
|
676
|
+
self._send_static_error(403, "forbidden")
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
if os.path.isfile(target):
|
|
680
|
+
self._send_file(target)
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
if os.path.splitext(rel)[1]:
|
|
684
|
+
self._send_static_error(404, "not found")
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
index = os.path.join(root, "index.html")
|
|
688
|
+
if not os.path.isfile(index):
|
|
689
|
+
self._send_static_error(500, "the board's index.html is missing")
|
|
690
|
+
return
|
|
691
|
+
self._send_file(index)
|
|
692
|
+
|
|
693
|
+
def _send_file(self, path):
|
|
694
|
+
"""Send one file, preferring a sibling ``<file>.gz`` when offered."""
|
|
695
|
+
content_type = _CONTENT_TYPES.get(
|
|
696
|
+
os.path.splitext(path)[1].lower(), _DEFAULT_CONTENT_TYPE
|
|
697
|
+
)
|
|
698
|
+
body_path = path
|
|
699
|
+
gzipped = False
|
|
700
|
+
if "gzip" in (self.headers.get("Accept-Encoding") or "").lower():
|
|
701
|
+
candidate = path + ".gz"
|
|
702
|
+
if os.path.isfile(candidate):
|
|
703
|
+
body_path = candidate
|
|
704
|
+
gzipped = True
|
|
705
|
+
with open(body_path, "rb") as handle:
|
|
706
|
+
body = handle.read()
|
|
707
|
+
self.send_response(200)
|
|
708
|
+
self.send_header("Content-Type", content_type)
|
|
709
|
+
self.send_header("Cache-Control", "no-cache")
|
|
710
|
+
if gzipped:
|
|
711
|
+
self.send_header("Content-Encoding", "gzip")
|
|
712
|
+
self.send_header("Vary", "Accept-Encoding")
|
|
713
|
+
self.send_header("Content-Length", str(len(body)))
|
|
714
|
+
self.end_headers()
|
|
715
|
+
if self.command != "HEAD":
|
|
716
|
+
self.wfile.write(body)
|
|
717
|
+
|
|
718
|
+
def _send_static_error(self, status, message):
|
|
719
|
+
body = json.dumps({"error": message}).encode("utf-8")
|
|
720
|
+
self.send_response(status)
|
|
721
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
722
|
+
self.send_header("Cache-Control", "no-cache")
|
|
723
|
+
self.send_header("Content-Length", str(len(body)))
|
|
724
|
+
self.end_headers()
|
|
725
|
+
if self.command != "HEAD":
|
|
726
|
+
self.wfile.write(body)
|
|
727
|
+
|
|
728
|
+
def _send_redirect(self, location):
|
|
729
|
+
self.send_response(302)
|
|
730
|
+
self.send_header("Location", location)
|
|
731
|
+
self.send_header("Content-Length", "0")
|
|
732
|
+
self.end_headers()
|
|
733
|
+
|
|
734
|
+
def _send_cors_headers(self):
|
|
735
|
+
"""Emit the CORS headers, echoing the caller's origin only if allowed.
|
|
736
|
+
|
|
737
|
+
``Vary: Origin`` is always sent: the response depends on the request's
|
|
738
|
+
``Origin``, so a shared cache must not serve one origin's answer (with
|
|
739
|
+
its ``Access-Control-Allow-Origin``) to a caller with a different one.
|
|
740
|
+
``Access-Control-Allow-Origin`` carries the request's own origin, never
|
|
741
|
+
``*`` -- see ``_allowed_origin``.
|
|
742
|
+
"""
|
|
743
|
+
self.send_header("Vary", "Origin")
|
|
744
|
+
origin = self.headers.get("Origin")
|
|
745
|
+
if _allowed_origin(origin):
|
|
746
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
747
|
+
|
|
748
|
+
def _send(self, status, payload):
|
|
749
|
+
body = json.dumps(
|
|
750
|
+
payload, ensure_ascii=False, default=_json_default, indent=2
|
|
751
|
+
).encode("utf-8")
|
|
752
|
+
self.send_response(status)
|
|
753
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
754
|
+
self.send_header("Content-Length", str(len(body)))
|
|
755
|
+
self._send_cors_headers()
|
|
756
|
+
self.end_headers()
|
|
757
|
+
if self.command != "HEAD":
|
|
758
|
+
self.wfile.write(body)
|
|
759
|
+
|
|
760
|
+
def log_message(self, *args): # keep the test/CI output clean
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def build_handler(workspace: str, static_root=None):
|
|
765
|
+
"""Build the request-handler class bound to ``workspace`` and ``static_root``.
|
|
766
|
+
|
|
767
|
+
Shared by :func:`make_server` (``taskproof api``) and the CLI's
|
|
768
|
+
``_board_serve``, which needs the class so a test can inject its own
|
|
769
|
+
``httpd_factory`` around the bind.
|
|
770
|
+
"""
|
|
771
|
+
return type(
|
|
772
|
+
"TaskproofAPIHandler",
|
|
773
|
+
(_Handler,),
|
|
774
|
+
{"workspace": workspace, "static_root": static_root},
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def make_server(
|
|
779
|
+
workspace: str,
|
|
780
|
+
port: int = DEFAULT_PORT,
|
|
781
|
+
host: str = HOST,
|
|
782
|
+
static_root=None,
|
|
783
|
+
):
|
|
784
|
+
"""Build (but do not start) the API server. ``port=0`` picks a free port.
|
|
785
|
+
|
|
786
|
+
``static_root`` is passed straight through to the handler: when set, the
|
|
787
|
+
same server also serves the desktop renderer's web build for non-``/api/``
|
|
788
|
+
paths.
|
|
789
|
+
"""
|
|
790
|
+
handler = build_handler(workspace, static_root)
|
|
791
|
+
try:
|
|
792
|
+
httpd = ThreadingHTTPServer((host, port), handler)
|
|
793
|
+
except OSError as exc:
|
|
794
|
+
raise UsageError(
|
|
795
|
+
f"cannot bind the API to {host}:{port}: {exc}",
|
|
796
|
+
hint="the port is already in use; choose another with --port",
|
|
797
|
+
)
|
|
798
|
+
return httpd
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def listening_line(httpd) -> str:
|
|
802
|
+
"""The line ``serve`` prints so a parent process can learn the port.
|
|
803
|
+
|
|
804
|
+
A caller that passes ``port=0`` cannot know the port in advance; the desktop
|
|
805
|
+
app spawns the API that way and reads the bound port back from this line.
|
|
806
|
+
"""
|
|
807
|
+
host, port = httpd.server_address[0], httpd.server_address[1]
|
|
808
|
+
return f"taskproof api listening on http://{host}:{port}"
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def serve(workspace: str, port: int = DEFAULT_PORT):
|
|
812
|
+
"""Serve until interrupted. Blocks; returns 0 on a clean shutdown."""
|
|
813
|
+
httpd = make_server(workspace, port)
|
|
814
|
+
# Flush before serve_forever blocks, or the parent never sees the line.
|
|
815
|
+
print(listening_line(httpd), flush=True)
|
|
816
|
+
try:
|
|
817
|
+
httpd.serve_forever()
|
|
818
|
+
except KeyboardInterrupt:
|
|
819
|
+
pass
|
|
820
|
+
finally:
|
|
821
|
+
httpd.server_close()
|
|
822
|
+
return 0
|