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.
Files changed (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. taskproof-0.3.0.dist-info/top_level.txt +1 -0
@@ -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