taskops-cli 0.2.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 (193) hide show
  1. taskops/__init__.py +39 -0
  2. taskops/_clock.py +32 -0
  3. taskops/_errors.py +160 -0
  4. taskops/_ids.py +63 -0
  5. taskops/_types.py +106 -0
  6. taskops/_version.py +7 -0
  7. taskops/assets/GUIDE.md +220 -0
  8. taskops/contracts/__init__.py +96 -0
  9. taskops/contracts/_fields.py +113 -0
  10. taskops/contracts/actor.py +30 -0
  11. taskops/contracts/board.py +142 -0
  12. taskops/contracts/commit.py +36 -0
  13. taskops/contracts/day.py +150 -0
  14. taskops/contracts/dep.py +23 -0
  15. taskops/contracts/event.py +58 -0
  16. taskops/contracts/gitstate.py +45 -0
  17. taskops/contracts/index.py +37 -0
  18. taskops/contracts/lease.py +43 -0
  19. taskops/contracts/log.py +59 -0
  20. taskops/contracts/remote.py +48 -0
  21. taskops/contracts/results.py +84 -0
  22. taskops/contracts/task.py +94 -0
  23. taskops/contracts/tools.py +110 -0
  24. taskops/contracts/wire.py +60 -0
  25. taskops/engine/__init__.py +34 -0
  26. taskops/engine/_blocks.py +48 -0
  27. taskops/engine/_briefs.py +92 -0
  28. taskops/engine/_chunks.py +66 -0
  29. taskops/engine/_closed.py +65 -0
  30. taskops/engine/_entries.py +126 -0
  31. taskops/engine/_events.py +55 -0
  32. taskops/engine/_opened.py +51 -0
  33. taskops/engine/_process.py +80 -0
  34. taskops/engine/_prompts.py +98 -0
  35. taskops/engine/_stream.py +129 -0
  36. taskops/engine/activity.py +94 -0
  37. taskops/engine/bus.py +42 -0
  38. taskops/engine/commitline.py +110 -0
  39. taskops/engine/day.py +142 -0
  40. taskops/engine/diffstat.py +63 -0
  41. taskops/engine/gitio.py +108 -0
  42. taskops/engine/gitstate.py +96 -0
  43. taskops/engine/history.py +76 -0
  44. taskops/engine/identity.py +84 -0
  45. taskops/engine/log.py +68 -0
  46. taskops/engine/machine.py +160 -0
  47. taskops/engine/narrate.py +91 -0
  48. taskops/engine/project.py +57 -0
  49. taskops/engine/replay.py +142 -0
  50. taskops/engine/reports.py +67 -0
  51. taskops/engine/scheduler.py +135 -0
  52. taskops/engine/transcript.py +127 -0
  53. taskops/engine/wire.py +92 -0
  54. taskops/engine/worker.py +115 -0
  55. taskops/py.typed +0 -0
  56. taskops/render/__init__.py +40 -0
  57. taskops/render/_closed_days.py +64 -0
  58. taskops/render/_dossier.py +68 -0
  59. taskops/render/_opened.py +64 -0
  60. taskops/render/_sections.py +51 -0
  61. taskops/render/_tasklist.py +72 -0
  62. taskops/render/_text.py +74 -0
  63. taskops/render/_verbatim.py +65 -0
  64. taskops/render/ansi.py +92 -0
  65. taskops/render/board.py +55 -0
  66. taskops/render/day.py +102 -0
  67. taskops/render/dispatch.py +104 -0
  68. taskops/render/inbox.py +27 -0
  69. taskops/render/log.py +47 -0
  70. taskops/render/recover.py +64 -0
  71. taskops/render/report.py +51 -0
  72. taskops/render/reports.py +57 -0
  73. taskops/render/results.py +96 -0
  74. taskops/render/session.py +75 -0
  75. taskops/render/task.py +88 -0
  76. taskops/render/tasklist.py +65 -0
  77. taskops/storage/__init__.py +36 -0
  78. taskops/storage/_ddl.py +78 -0
  79. taskops/storage/_delivered.py +51 -0
  80. taskops/storage/_deps.py +69 -0
  81. taskops/storage/_events.py +127 -0
  82. taskops/storage/_leases.py +108 -0
  83. taskops/storage/_rows.py +90 -0
  84. taskops/storage/_tasks.py +124 -0
  85. taskops/storage/locate.py +76 -0
  86. taskops/storage/schema.py +66 -0
  87. taskops/storage/store.py +120 -0
  88. taskops/storage/sync.py +139 -0
  89. taskops/transports/__init__.py +6 -0
  90. taskops/transports/cli/__init__.py +0 -0
  91. taskops/transports/cli/commands/__init__.py +3 -0
  92. taskops/transports/cli/commands/_digest.py +64 -0
  93. taskops/transports/cli/commands/_serve_init.py +65 -0
  94. taskops/transports/cli/commands/_shared.py +50 -0
  95. taskops/transports/cli/commands/_tasks_args.py +112 -0
  96. taskops/transports/cli/commands/_window.py +45 -0
  97. taskops/transports/cli/commands/ask.py +27 -0
  98. taskops/transports/cli/commands/dispatch.py +43 -0
  99. taskops/transports/cli/commands/init.py +48 -0
  100. taskops/transports/cli/commands/log.py +22 -0
  101. taskops/transports/cli/commands/plan.py +43 -0
  102. taskops/transports/cli/commands/pushpull.py +53 -0
  103. taskops/transports/cli/commands/recover.py +30 -0
  104. taskops/transports/cli/commands/remote.py +56 -0
  105. taskops/transports/cli/commands/report.py +82 -0
  106. taskops/transports/cli/commands/run_.py +60 -0
  107. taskops/transports/cli/commands/serve.py +74 -0
  108. taskops/transports/cli/commands/sync.py +22 -0
  109. taskops/transports/cli/commands/tasks.py +79 -0
  110. taskops/transports/cli/commands/ui.py +72 -0
  111. taskops/transports/cli/commands/update.py +25 -0
  112. taskops/transports/cli/main.py +85 -0
  113. taskops/transports/hooks/__init__.py +15 -0
  114. taskops/transports/hooks/__main__.py +63 -0
  115. taskops/transports/hooks/_args.py +28 -0
  116. taskops/transports/hooks/claude.py +78 -0
  117. taskops/transports/hooks/commit.py +61 -0
  118. taskops/transports/hooks/events.py +107 -0
  119. taskops/transports/hooks/record.py +55 -0
  120. taskops/transports/http/__init__.py +21 -0
  121. taskops/transports/http/_handler.py +132 -0
  122. taskops/transports/http/_wire.py +79 -0
  123. taskops/transports/http/_wsframes.py +116 -0
  124. taskops/transports/http/agentapi.py +69 -0
  125. taskops/transports/http/api.py +122 -0
  126. taskops/transports/http/exchange.py +80 -0
  127. taskops/transports/http/live.py +160 -0
  128. taskops/transports/http/policy.py +110 -0
  129. taskops/transports/http/projects.py +116 -0
  130. taskops/transports/http/reports.py +75 -0
  131. taskops/transports/http/router.py +90 -0
  132. taskops/transports/http/server.py +50 -0
  133. taskops/transports/http/static.py +86 -0
  134. taskops/transports/http/ui/app.js +59 -0
  135. taskops/transports/http/ui/index.html +23 -0
  136. taskops/transports/http/ui/style.css +1 -0
  137. taskops/transports/http/websocket.py +60 -0
  138. taskops/transports/mcp/__init__.py +19 -0
  139. taskops/transports/mcp/__main__.py +7 -0
  140. taskops/transports/mcp/_descriptions.py +102 -0
  141. taskops/transports/mcp/_reads.py +82 -0
  142. taskops/transports/mcp/_writes.py +72 -0
  143. taskops/transports/mcp/answers.py +44 -0
  144. taskops/transports/mcp/arguments.py +104 -0
  145. taskops/transports/mcp/dispatch.py +47 -0
  146. taskops/transports/mcp/protocol.py +83 -0
  147. taskops/transports/mcp/schema.py +50 -0
  148. taskops/transports/mcp/server.py +49 -0
  149. taskops/transports/mcp/tools.py +66 -0
  150. taskops/usecases/__init__.py +56 -0
  151. taskops/usecases/_entry.py +84 -0
  152. taskops/usecases/_facts.py +49 -0
  153. taskops/usecases/_freeing.py +93 -0
  154. taskops/usecases/_gitignore.py +94 -0
  155. taskops/usecases/_mirroring.py +57 -0
  156. taskops/usecases/_narrating.py +77 -0
  157. taskops/usecases/_project.py +67 -0
  158. taskops/usecases/_range.py +102 -0
  159. taskops/usecases/_reasons.py +54 -0
  160. taskops/usecases/_remotefile.py +62 -0
  161. taskops/usecases/_reportsync.py +108 -0
  162. taskops/usecases/_routing.py +91 -0
  163. taskops/usecases/_wireclient.py +141 -0
  164. taskops/usecases/ask.py +41 -0
  165. taskops/usecases/claim.py +104 -0
  166. taskops/usecases/dispatch.py +160 -0
  167. taskops/usecases/dossier.py +155 -0
  168. taskops/usecases/edit.py +71 -0
  169. taskops/usecases/exchange.py +86 -0
  170. taskops/usecases/feed.py +138 -0
  171. taskops/usecases/guard.py +122 -0
  172. taskops/usecases/hooks.py +127 -0
  173. taskops/usecases/index.py +62 -0
  174. taskops/usecases/ingest.py +60 -0
  175. taskops/usecases/log.py +134 -0
  176. taskops/usecases/narration.py +91 -0
  177. taskops/usecases/plan.py +128 -0
  178. taskops/usecases/pushpull.py +119 -0
  179. taskops/usecases/recover.py +84 -0
  180. taskops/usecases/remote.py +78 -0
  181. taskops/usecases/report.py +87 -0
  182. taskops/usecases/reportfile.py +106 -0
  183. taskops/usecases/session.py +134 -0
  184. taskops/usecases/setup.py +92 -0
  185. taskops/usecases/sync.py +78 -0
  186. taskops/usecases/update.py +123 -0
  187. taskops/usecases/view.py +112 -0
  188. taskops_cli-0.2.0.dist-info/METADATA +291 -0
  189. taskops_cli-0.2.0.dist-info/RECORD +193 -0
  190. taskops_cli-0.2.0.dist-info/WHEEL +5 -0
  191. taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
  192. taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  193. taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,160 @@
1
+ """The live feed: a WEBSOCKET for the browser, server-sent events as the fallback.
2
+
3
+ One route, two envelopes, one source. `usecases.follow` produces the events and the only difference
4
+ is the framing — so there is no second feed to keep in step.
5
+
6
+ **Why both.** The websocket is what a browser gets: its PING is the protocol's own liveness probe
7
+ (unlike an SSE comment, it gets an answer), and it is the channel that can carry a client→server
8
+ message the day the board needs one. SSE stays because it needs no handshake at all, which makes
9
+ `curl -N /api/live` a working debugging tool and gives a proxy that mangles upgrades a fallback.
10
+
11
+ **Why neither uses asyncio.** The engine is synchronous by an enforced invariant, so both are served
12
+ by the threaded `ThreadingHTTPServer`: one connection is one thread parked in a generator, and the
13
+ sqlite underneath never learns that a websocket exists. The RFC 6455 framing this needs is small
14
+ enough to write (`_wsframes`) and buys zero runtime dependencies — which matters in a package that
15
+ installs into every agent's environment on every machine.
16
+
17
+ The tailing itself is `usecases.feed`, because it needs the event bus and a cursor, and a transport
18
+ may not reach for either — including the filter that keeps one project's narration off another
19
+ project's board. This module only strips that routing field back off on the way out (`_public`).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Generator
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from ...usecases import follow, is_wire
30
+ from . import websocket
31
+ from ._wire import Reply, Request
32
+
33
+ __all__ = ["stream"]
34
+
35
+ NARRATION = "narration"
36
+ """The second thing this feed carries. An event is a stored fact and this is not — it is the prose
37
+ of a report arriving as a model writes it, published to `engine.WIRE`, never to the log. Same route and same socket on purpose: a browser holds one connection, and a second stream would
38
+ be a second subscription and a second lifetime to leak for what is one panel on one screen. The
39
+ envelope keeps them apart — a client that never heard of narration drops the frame."""
40
+
41
+ KEEPALIVE_TICKS = 4
42
+ """Quiet ticks before a comment frame goes out — 4 × 0.5s = 2 seconds.
43
+
44
+ Proxies and browsers close an idle response, so something has to be sent. It is also how a write
45
+ eventually fails on a socket whose client went away — but only *eventually*, which is why it is not
46
+ the mechanism this relies on (see `MAX_TICKS`)."""
47
+
48
+ MAX_TICKS = 600
49
+ """Ticks before the stream ends itself — 600 × 0.5s = 5 minutes. THE resource bound.
50
+
51
+ Measured, not assumed. The obvious design is a response that lives forever and ends when a write to
52
+ a departed client fails; that write does not reliably fail. Against a closed loopback socket on
53
+ macOS, writes kept succeeding for over seven seconds, so a closed tab held a bus subscription and an
54
+ open sqlite connection for an unbounded time — a real leak, found by the test below.
55
+
56
+ Ending the response on a schedule inverts the problem instead of fighting it: `EventSource`
57
+ reconnects on its own, by specification, and the UI refetches on every open — so a recycled stream
58
+ is invisible to the watcher, and resource use is bounded by DESIGN rather than by how promptly an
59
+ operating system reports a dead peer.
60
+ """
61
+
62
+
63
+ def stream(root: Path, request: Request) -> Reply:
64
+ """The live feed. A WEBSOCKET when the client asks to upgrade, SSE otherwise — one route
65
+ serving both, for the reasons in this module's header.
66
+
67
+ `X-Accel-Buffering: no` is not decoration on the SSE path: nginx buffers a proxied response by
68
+ default, and with it the whole feed arrives in one lump whenever the buffer happens to fill.
69
+ """
70
+ key = request.headers.get("sec-websocket-key", "")
71
+ if websocket.is_upgrade(request.headers):
72
+ return Reply(status=101, stream=lambda: _ws_frames(root),
73
+ headers=websocket.handshake_headers(key))
74
+ return Reply(status=200, stream=lambda: _frames(root),
75
+ headers={"Content-Type": "text/event-stream; charset=utf-8",
76
+ "Cache-Control": "no-cache, no-transform",
77
+ "Connection": "keep-alive",
78
+ "X-Accel-Buffering": "no"})
79
+
80
+
81
+ def _ws_frames(root: Path) -> Generator[bytes, None, None]:
82
+ """The same feed, in websocket frames.
83
+
84
+ JSON with a `type` rather than SSE's `event:` line, because a websocket has no per-message event
85
+ name — so the envelope carries it, and the browser switches on one field.
86
+
87
+ A quiet tick sends a PING, not a comment: the protocol's own liveness probe, and unlike the SSE
88
+ keepalive it gets an answer, so the browser can tell a live-but-idle board from a dead socket.
89
+ Both still end at `MAX_TICKS` — a parked generator cannot notice a departed client.
90
+ """
91
+ yield websocket.text_frame(json.dumps({"type": "hello"}))
92
+ ticks = 0
93
+ for event in follow(root):
94
+ if is_wire(event):
95
+ yield websocket.text_frame(json.dumps({"type": NARRATION, "message": _public(event)},
96
+ default=str))
97
+ continue
98
+ if event is not None:
99
+ yield websocket.text_frame(json.dumps({"type": "change", "event": event},
100
+ default=str))
101
+ continue
102
+ ticks += 1
103
+ if ticks >= MAX_TICKS:
104
+ yield websocket.close_frame()
105
+ return
106
+ if ticks % KEEPALIVE_TICKS == 0:
107
+ yield websocket.ping_frame()
108
+
109
+
110
+ def _frames(root: Path) -> Generator[bytes, None, None]:
111
+ """Turn the feed into SSE frames. Always from NOW, and no `id:`.
112
+
113
+ No resume cursor, deliberately. The `seq` a cursor would need is machine-local by design and is
114
+ not part of the `Event` contract, so a frame has nothing honest to put in `id:` — and the
115
+ recovery a board wants is not a replay anyway. The UI refetches the board and the fleet whenever
116
+ the stream opens, which closes any gap in one request; events are only the signal to refetch.
117
+ Replaying into a projection derived from the database is slower and less correct than reading it.
118
+ """
119
+ # Immediately, before the first tick. Two jobs: the UI's `onOpen` fires on it and refetches, and
120
+ # a connection that was already dead on arrival raises here instead of parking for two seconds.
121
+ yield b"event: hello\ndata: {}\n\n"
122
+ quiet = 0
123
+ ticks = 0
124
+ for event in follow(root):
125
+ if is_wire(event):
126
+ quiet = 0
127
+ yield _frame(_public(event), NARRATION)
128
+ continue
129
+ if event is not None:
130
+ quiet = 0
131
+ yield _frame(event)
132
+ continue
133
+ ticks += 1
134
+ quiet += 1
135
+ if ticks >= MAX_TICKS:
136
+ # Return, never raise. The generator's `finally` releases the store and the
137
+ # subscription, the server closes the response, and the browser reconnects on its own.
138
+ return
139
+ if quiet >= KEEPALIVE_TICKS:
140
+ quiet = 0
141
+ yield b": keepalive\n\n"
142
+
143
+
144
+ def _public(message: Any) -> dict[str, Any]:
145
+ """A wire message with its `root` STRIPPED — what a browser is allowed to see.
146
+
147
+ The field routes the message inside the server (`usecases.follow`); on the wire it is only an
148
+ absolute path on the server's filesystem, and on a multi-project server the name of a board
149
+ this caller may hold no token for. A COPY, never a mutation: the broadcast handed the same
150
+ dict to every subscriber and a message without a root is dropped, so popping the key in place
151
+ would silence the board next door. Typed loosely — `is_wire` already ran at the call site.
152
+ """
153
+ return {key: value for key, value in message.items() if key != "root"}
154
+
155
+
156
+ def _frame(event: object, name: str = "change") -> bytes:
157
+ """One SSE frame. The event NAME is what tells the two feeds apart — the websocket has no
158
+ such line, which is why its envelope carries a `type` instead."""
159
+ payload = json.dumps(event, default=str)
160
+ return f"event: {name}\ndata: {payload}\n\n".encode("utf-8")
@@ -0,0 +1,110 @@
1
+ """Who may do what over HTTP. Three settings, and each one has a deployment behind it.
2
+
3
+ The UI binds to loopback by default, so on a laptop none of this is load-bearing. It all is
4
+ the moment somebody puts it behind nginx to show a team the board — which is the intended use,
5
+ so the controls exist before the first person needs them rather than after.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from dataclasses import dataclass, field
12
+
13
+ from ..._clock import now
14
+ from ._wire import Reply, Request, error_reply
15
+
16
+ __all__ = ["Policy"]
17
+
18
+ _WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
19
+
20
+ _ASSETS = (".js", ".css", ".map", ".svg", ".png", ".ico", ".woff2")
21
+ """Extensions the bundle is made of. A closed list, not "anything not under /api/": the SPA
22
+ falls back to `index.html` for unknown paths, so a wildcard would hand the app shell — and any
23
+ future page — to anyone who guessed a path."""
24
+
25
+
26
+ def _is_asset(request: Request) -> bool:
27
+ """A GET for a file of the built UI. Never an API path, whatever it is named."""
28
+ return (request.method == "GET" and not request.path.startswith("/api/")
29
+ and request.path.endswith(_ASSETS))
30
+
31
+
32
+ @dataclass
33
+ class Policy:
34
+ token: str = ""
35
+ """Require `Authorization: Bearer <token>`. Empty means open, which is correct on
36
+ loopback and wrong the moment `--host 0.0.0.0` appears."""
37
+
38
+ readonly: bool = False
39
+ """Refuse every write. For a board on a screen in a room: a shared display should not be
40
+ able to close somebody's task, and a viewer should not be able to post as an agent."""
41
+
42
+ rate_limit: int = 0
43
+ """Requests per minute per process, 0 for none. Not per client: this is one small server
44
+ and the thing it protects against is a page with a broken retry loop, not an attacker —
45
+ who would be stopped by the token, not by a counter."""
46
+
47
+ _hits: deque[float] = field(default_factory=deque[float], repr=False)
48
+
49
+ def check(self, request: Request) -> Reply | None:
50
+ """None means allowed. Ordered cheapest-first, and auth before everything.
51
+
52
+ Auth first so a wrong token cannot consume the rate budget, which would otherwise let
53
+ an unauthenticated caller lock out an authenticated one.
54
+ """
55
+ if refusal := self._auth(request):
56
+ return refusal
57
+ if self.readonly and request.method in _WRITE_METHODS:
58
+ return error_reply(403, "this board is read-only — start it without "
59
+ "--readonly to comment or change a status", "readonly")
60
+ return self._rate()
61
+
62
+ def _auth(self, request: Request) -> Reply | None:
63
+ """The header, or `?token=` — and the query form is not laziness.
64
+
65
+ `EventSource` has no API for request headers, at all. So an SSE stream behind a token can
66
+ ONLY be authenticated by the URL, and refusing the query form would mean the live feed is
67
+ the one endpoint a token locks out. It is accepted everywhere rather than only on
68
+ `/api/live`, because a rule that holds for one path is a rule somebody will move.
69
+
70
+ The cost is a token in the browser's address bar and history. That is acceptable for a
71
+ loopback tool whose token is minted per run; it would not be for a public service, and a
72
+ deployment that needs more should terminate auth at the proxy.
73
+
74
+ THE BUNDLE IS EXEMPT, and that is what makes a token usable at all in a browser. A page
75
+ opened as `/axion/?token=…` asks for `/axion/app.js` with NO query string — browsers do
76
+ not propagate a parameter to subresources — so guarding the bundle 401s the page's own
77
+ script and leaves a blank screen behind a URL that looked right. Measured in production
78
+ before it was believed. What the exemption gives away is a JavaScript file identical in
79
+ every install: no task, no title, no event. Everything that IS this project — the API and
80
+ the live feed — stays behind the token.
81
+ """
82
+ if not self.token or _is_asset(request):
83
+ return None
84
+ expected = f"Bearer {self.token}"
85
+ if request.headers.get("authorization") == expected:
86
+ return None
87
+ if request.param("token") == self.token:
88
+ return None
89
+ return error_reply(401, "a bearer token is required — open the URL `taskops ui` "
90
+ "printed, which carries it", "unauthorized")
91
+
92
+ def _rate(self) -> Reply | None:
93
+ """A sliding window over a deque. No dependency, no background sweep.
94
+
95
+ Old timestamps are dropped on the way in, so the deque cannot grow past the limit and
96
+ nothing has to clean up after an idle hour.
97
+ """
98
+ if not self.rate_limit:
99
+ return None
100
+ # `_clock.now` and not `time.time`: an invariant test enforces the single clock, and the
101
+ # reason applies here too — a rate-limit test that had to sleep for a minute is a test
102
+ # that gets deleted.
103
+ at = now()
104
+ while self._hits and at - self._hits[0] > 60.0:
105
+ self._hits.popleft()
106
+ if len(self._hits) >= self.rate_limit:
107
+ return error_reply(429, f"more than {self.rate_limit} requests a minute — "
108
+ f"slow down", "rate_limited")
109
+ self._hits.append(at)
110
+ return None
@@ -0,0 +1,116 @@
1
+ """Many boards on one port: `/<project>/api/...` and `/<project>/` for each project's UI.
2
+
3
+ The whole design is one sentence: a project is the router that already exists, MOUNTED. This
4
+ module splits the first path segment off, finds the project it names, and hands the request to
5
+ `router.build` with the prefix trimmed — so every endpoint, the SSE feed and the websocket work
6
+ under a prefix without a single one of them learning that prefixes exist.
7
+
8
+ **Isolation is structural, not checked per endpoint.** A mounted router is bound to one root, so
9
+ a request that arrived under `/axion/` has no way to name another project's store: the only path
10
+ a route ever sees has already had its prefix removed. The name is validated against a strict
11
+ pattern BEFORE the filesystem is touched, so `..` and `/` are refused as syntax rather than
12
+ caught later by a resolve.
13
+
14
+ **A miss is a bare 404.** Naming the projects that do exist would hand an unauthenticated caller
15
+ the list of every board on the server, which is exactly the enumeration a per-project token is
16
+ there to prevent. The reply says nothing, including whether the name was wrong or the secret was.
17
+
18
+ **Narration is isolated too, and not by this file.** `WireMessage` (a narration delta) rides a
19
+ process-global broadcast, so for a while a browser watching one board could see the prose of a
20
+ report being written on another — the one leak this design had. It was closed on the contract:
21
+ a wire message carries the `root` that emitted it, and `usecases.feed.follow` yields only the
22
+ ones matching its own root (a message without a root is dropped). This module still knows
23
+ nothing about frames, which is exactly why the filter is not here — it never sees one.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import replace
30
+ from pathlib import Path
31
+ from urllib.parse import urlencode
32
+
33
+ from ..._errors import TaskopsError
34
+ from ...usecases import locate
35
+ from ._wire import Reply, Request, Route, error_reply
36
+ from .policy import Policy
37
+ from .router import build
38
+
39
+ __all__ = ["mount", "NAME", "TOKEN_FILE"]
40
+
41
+ NAME = re.compile(r"^[a-z0-9-]{1,40}$")
42
+ """What may name a project. Deliberately narrower than "a valid directory name": lowercase,
43
+ digits and dashes only, so a project name is also a URL segment nobody has to escape — and
44
+ `..`, `/`, an empty string and a leading dot are all refused by the pattern itself."""
45
+
46
+ TOKEN_FILE = "token"
47
+ """The project's secret, `0600`, minted by `taskops serve init`. A project without one is not
48
+ served AT ALL, rather than served open: this transport is meant to face the internet, and the
49
+ failure mode of the alternative is a board that is public because a file was missing."""
50
+
51
+
52
+ def mount(root: Path, *, readonly: bool = False, rate_limit: int = 0) -> Route:
53
+ """The root dispatcher for a directory of projects.
54
+
55
+ Routers are CACHED per project. Building one opens a store, so constructing it per request
56
+ would open and close sqlite on every poll of every board.
57
+ """
58
+ home = Path(root).expanduser().resolve()
59
+ cache: dict[str, tuple[Policy, Route]] = {}
60
+
61
+ def dispatch(request: Request) -> Reply:
62
+ name, rest = _split(request.path)
63
+ found = _project(home, name)
64
+ if found is None:
65
+ return error_reply(404, "no such project", "no_such_project")
66
+ if name not in cache:
67
+ policy = Policy(token=_token(found), readonly=readonly, rate_limit=rate_limit)
68
+ cache[name] = (policy, build(found, policy, base=f"/{name}/"))
69
+ policy, route = cache[name]
70
+ if not rest:
71
+ # The redirect is gated too. It is a reply about a project, so handing it to an
72
+ # unauthenticated caller would make `/name` a working existence oracle for every
73
+ # board on the server — the one thing the bare 404 above exists to deny.
74
+ return policy.check(request) or _redirect(f"/{name}/", request)
75
+ return route(replace(request, path=rest))
76
+
77
+ return dispatch
78
+
79
+
80
+ def _split(path: str) -> tuple[str, str]:
81
+ """`/axion/api/board` -> `axion`, `/api/board`. An empty rest means the prefix had no
82
+ trailing slash, which is a redirect rather than a route — the UI's relative URLs would
83
+ otherwise resolve one level too high."""
84
+ name, slash, rest = path.lstrip("/").partition("/")
85
+ return name, f"/{rest}" if slash else ""
86
+
87
+
88
+ def _project(home: Path, name: str) -> Path | None:
89
+ """The project directory, or None. The pattern is checked BEFORE any path is built.
90
+
91
+ `locate` walking UP is the subtlety: a plain directory under the root would resolve to some
92
+ ancestor project, so the answer only counts when it is the directory we asked about.
93
+ """
94
+ if not NAME.match(name):
95
+ return None
96
+ candidate = home / name
97
+ try:
98
+ found = locate(candidate)
99
+ except (TaskopsError, OSError):
100
+ return None
101
+ return candidate if found == candidate and _token(candidate) else None
102
+
103
+
104
+ def _token(project: Path) -> str:
105
+ try:
106
+ return (project / TOKEN_FILE).read_text(encoding="utf-8").strip()
107
+ except OSError:
108
+ return ""
109
+
110
+
111
+ def _redirect(location: str, request: Request) -> Reply:
112
+ """308 and not 302: the method must survive, or a POST to `/axion` would silently become a
113
+ GET. The query is carried across by hand — a redirect that dropped it would strip the
114
+ `?token=` out of the very link `serve init` printed, and the board would 401 on arrival."""
115
+ query = urlencode(request.query)
116
+ return Reply(status=308, headers={"Location": f"{location}?{query}" if query else location})
@@ -0,0 +1,75 @@
1
+ """The three report endpoints: the index, one report's markdown, and narrating one.
2
+
3
+ Their own module rather than three more functions in `api.py`, because they are the only
4
+ endpoints that touch a FILE — a report is written to disk and committed, unlike every other
5
+ projection here, which is regenerated on demand. Grouping them keeps that difference visible.
6
+
7
+ `POST /api/report/digest` is the one endpoint in taskops that costs money and takes MINUTES:
8
+ it shells out to `claude`. It is a POST for exactly that reason — a write is refused under
9
+ `--readonly` by the policy, by method, so a board on a screen in a room cannot spend an API key
10
+ by being looked at.
11
+
12
+ It does not WAIT for it, though. It starts the narration and answers immediately; the prose
13
+ arrives on `/api/live` as it is written. Holding the response open for a multi-minute model
14
+ call was the whole bug: the browser showed a mute spinner, the file said `_pendiente_`
15
+ throughout, and a connection that dropped took the only feedback there was with it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+
22
+ from ...usecases import narration, parse_date, read_report, report_index
23
+ from ._wire import Reply, Request, json_reply
24
+ from .api import guarded
25
+
26
+ __all__ = ["get_report", "get_reports", "post_digest"]
27
+
28
+
29
+ def get_reports(root: Path, request: Request) -> Reply:
30
+ """Every report on disk, newest first. Rows only — no bodies.
31
+
32
+ Thirty days of dossiers is a megabyte of markdown, and the list on the left of the screen
33
+ shows a label and two badges. The body arrives when a row is clicked.
34
+ """
35
+ return guarded(lambda: json_reply(report_index(root)))
36
+
37
+
38
+ def get_report(root: Path, request: Request) -> Reply:
39
+ """A day's written dossier, and whether the day outran it.
40
+
41
+ `?date=` is optional and defaults to today, like the CLI — the endpoint answers for a day
42
+ that was never written up as readily as for one that was, so a UI can show the report and
43
+ the "not written yet" state through one call instead of probing for a 404.
44
+ """
45
+ return guarded(lambda: json_reply(read_report(root, request.param("date"))))
46
+
47
+
48
+ def post_digest(root: Path, request: Request) -> Reply:
49
+ """START narrating the report. Answers `{"status": "narrating", "label"}` at once.
50
+
51
+ Not the finished file, deliberately. The narration is minutes of a model writing, and the
52
+ only honest thing a request can return in that time is "it began" — the prose itself
53
+ arrives on `/api/live`, frame by frame, on the socket the board already holds. The UI shows
54
+ it appearing and refetches the file when the terminal frame lands.
55
+
56
+ The day is parsed HERE, before the thread starts, so a label nobody can read is still a 400
57
+ with the parser's own sentence in it rather than a `narration.failed` frame two seconds
58
+ later on a stream the caller may not even be watching.
59
+
60
+ `force` is the Regenerate button; without it an existing narration is refused, since a
61
+ person may have edited that prose. A second request while one is running is refused too
62
+ (409) — two models rewriting one file is corruption, not contention.
63
+ """
64
+ payload = request.payload()
65
+ label = str(payload.get("date") or payload.get("label") or "")
66
+ force = bool(payload.get("force"))
67
+
68
+ def run() -> Reply:
69
+ # A day, named explicitly: the button lives on a day's row, and a window selector is
70
+ # something the UI has no way to express yet.
71
+ day = parse_date(label)
72
+ return json_reply({"status": "narrating",
73
+ "label": narration.start(root, day, force=force)})
74
+
75
+ return guarded(run)
@@ -0,0 +1,90 @@
1
+ """Path and method -> a route. A TABLE, built once with the root and the policy bound in.
2
+
3
+ Same shape as the MCP dispatch and for the same reason: the table is the surface, so adding an
4
+ endpoint is a row here rather than a branch in a handler, and nothing can be reachable without
5
+ appearing in a list somebody can read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from functools import partial
11
+ from pathlib import Path
12
+
13
+ from . import agentapi, api, exchange, live, reports, static
14
+ from ._wire import Reply, Request, Route, error_reply
15
+ from .policy import Policy
16
+
17
+ __all__ = ["build"]
18
+
19
+
20
+ def _table(root: Path, policy: Policy) -> dict[tuple[str, str], Route]:
21
+ """THE surface, as data. Its own function so `build` stays a dispatcher and the table can
22
+ grow a row without the closure around it growing with it."""
23
+ return {
24
+ ("GET", "/api/config"): partial(_config, root, policy),
25
+ ("GET", "/api/board"): partial(api.get_board, root),
26
+ ("GET", "/api/fleet"): partial(api.get_fleet, root),
27
+ ("GET", "/api/standup"): partial(api.get_standup, root),
28
+ ("GET", "/api/task"): partial(api.get_task, root),
29
+ ("GET", "/api/search"): partial(api.get_search, root),
30
+ ("GET", "/api/activity"): partial(api.get_activity, root),
31
+ ("GET", "/api/report"): partial(reports.get_report, root),
32
+ ("GET", "/api/reports"): partial(reports.get_reports, root),
33
+ ("POST", "/api/report/digest"): partial(reports.post_digest, root),
34
+ ("POST", "/api/comment"): partial(api.post_comment, root),
35
+ ("POST", "/api/status"): partial(api.post_status, root),
36
+ ("GET", "/api/live"): partial(live.stream, root),
37
+ ("POST", "/api/sync"): partial(exchange.post_sync, root),
38
+ ("GET", "/api/sync"): partial(exchange.get_sync, root),
39
+ ("GET", "/api/report/file"): partial(exchange.get_report_file, root),
40
+ ("PUT", "/api/report/file"): partial(exchange.put_report_file, root),
41
+ ("POST", "/api/next"): partial(agentapi.post_next, root),
42
+ ("POST", "/api/update"): partial(agentapi.post_update, root),
43
+ }
44
+
45
+
46
+ def build(root: Path, policy: Policy, base: str = "/") -> Route:
47
+ """The one function the server calls per request. Everything else is bound here.
48
+
49
+ `base` is the URL prefix this table was mounted under — `/` for `taskops ui`, `/<project>/`
50
+ for one board inside `taskops serve`. It never reaches a route: paths arrive already
51
+ trimmed, and the only thing that needs it is the `<base>` tag in `index.html`.
52
+ """
53
+ routes = _table(root, policy)
54
+
55
+ def dispatch(request: Request) -> Reply:
56
+ if refusal := policy.check(request):
57
+ return refusal
58
+ route = routes.get((request.method, request.path))
59
+ if route is not None:
60
+ return route(request)
61
+ if request.method == "GET" and not request.path.startswith("/api/"):
62
+ # Everything that is not the API is the single-page app, INCLUDING unknown paths:
63
+ # the UI routes in the browser, so a reload on /task/tk-1 must serve index.html
64
+ # rather than 404 — which is the classic broken-refresh bug in an SPA.
65
+ return static.serve(request.path, base)
66
+ return _no_route(request, routes)
67
+
68
+ return dispatch
69
+
70
+
71
+ def _config(root: Path, policy: Policy, request: Request) -> Reply:
72
+ """`readonly` reaches the UI through the request rather than through module state, so the
73
+ endpoint stays a pure function of what it was given."""
74
+ marked = Request(method=request.method, path=request.path,
75
+ query={**request.query, "_readonly": "1" if policy.readonly else "0"},
76
+ headers=request.headers, body=request.body)
77
+ return api.config(root, marked)
78
+
79
+
80
+ def _no_route(request: Request, routes: dict[tuple[str, str], Route]) -> Reply:
81
+ """405 when the path exists under another method, 404 otherwise.
82
+
83
+ The two send a caller to completely different places, and "not found" for a GET on a POST
84
+ route has cost everyone an afternoon at some point.
85
+ """
86
+ allowed = sorted({method for method, path in routes if path == request.path})
87
+ if allowed:
88
+ return error_reply(405, f"{request.path} accepts {', '.join(allowed)}",
89
+ "method_not_allowed")
90
+ return error_reply(404, f"no route {request.path}", "no_such_route")
@@ -0,0 +1,50 @@
1
+ """Constructing the server. The handler itself is `_handler`.
2
+
3
+ `ThreadingHTTPServer` and not asyncio: the engine is synchronous by an enforced invariant, and a
4
+ thread per connection is what lets a live websocket or SSE stream park in a generator without the
5
+ sqlite underneath ever learning about it. A handful of threads for a handful of tabs is the right
6
+ size — this is a local tool, not a service.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from http.server import ThreadingHTTPServer
12
+ from pathlib import Path
13
+
14
+ from ._handler import Handler
15
+ from ._wire import Route
16
+ from .policy import Policy
17
+ from .router import build
18
+
19
+ __all__ = ["build_server", "serve_route", "bound_port"]
20
+
21
+
22
+ def build_server(host: str, port: int, root: Path, policy: Policy) -> ThreadingHTTPServer:
23
+ """A server over ONE project — what `taskops ui` opens."""
24
+ return serve_route(host, port, build(root, policy))
25
+
26
+
27
+ def serve_route(host: str, port: int, route: Route) -> ThreadingHTTPServer:
28
+ """A server ready to `serve_forever`. Returned rather than run, so a test can bind port 0.
29
+
30
+ The route table is bound into a per-server handler SUBCLASS, because stdlib's server takes a
31
+ class and instantiates it per request — there is nowhere else to put the root and the policy
32
+ without making them module state, which two servers in one process (every test file here) would
33
+ then share.
34
+
35
+ Taking a `Route` rather than a root is what lets `taskops serve` reuse every line of this:
36
+ the multi-project dispatcher is just another `Request -> Reply`, so the socket half of the
37
+ transport did not have to learn that projects exist.
38
+ """
39
+
40
+ class Bound(Handler):
41
+ dispatch = staticmethod(route)
42
+
43
+ server = ThreadingHTTPServer((host, port), Bound)
44
+ server.daemon_threads = True
45
+ return server
46
+
47
+
48
+ def bound_port(server: ThreadingHTTPServer) -> int:
49
+ """The port actually bound. With `--port 0` the OS chooses, and this is how it gets said."""
50
+ return int(server.socket.getsockname()[1])