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.
- taskops/__init__.py +39 -0
- taskops/_clock.py +32 -0
- taskops/_errors.py +160 -0
- taskops/_ids.py +63 -0
- taskops/_types.py +106 -0
- taskops/_version.py +7 -0
- taskops/assets/GUIDE.md +220 -0
- taskops/contracts/__init__.py +96 -0
- taskops/contracts/_fields.py +113 -0
- taskops/contracts/actor.py +30 -0
- taskops/contracts/board.py +142 -0
- taskops/contracts/commit.py +36 -0
- taskops/contracts/day.py +150 -0
- taskops/contracts/dep.py +23 -0
- taskops/contracts/event.py +58 -0
- taskops/contracts/gitstate.py +45 -0
- taskops/contracts/index.py +37 -0
- taskops/contracts/lease.py +43 -0
- taskops/contracts/log.py +59 -0
- taskops/contracts/remote.py +48 -0
- taskops/contracts/results.py +84 -0
- taskops/contracts/task.py +94 -0
- taskops/contracts/tools.py +110 -0
- taskops/contracts/wire.py +60 -0
- taskops/engine/__init__.py +34 -0
- taskops/engine/_blocks.py +48 -0
- taskops/engine/_briefs.py +92 -0
- taskops/engine/_chunks.py +66 -0
- taskops/engine/_closed.py +65 -0
- taskops/engine/_entries.py +126 -0
- taskops/engine/_events.py +55 -0
- taskops/engine/_opened.py +51 -0
- taskops/engine/_process.py +80 -0
- taskops/engine/_prompts.py +98 -0
- taskops/engine/_stream.py +129 -0
- taskops/engine/activity.py +94 -0
- taskops/engine/bus.py +42 -0
- taskops/engine/commitline.py +110 -0
- taskops/engine/day.py +142 -0
- taskops/engine/diffstat.py +63 -0
- taskops/engine/gitio.py +108 -0
- taskops/engine/gitstate.py +96 -0
- taskops/engine/history.py +76 -0
- taskops/engine/identity.py +84 -0
- taskops/engine/log.py +68 -0
- taskops/engine/machine.py +160 -0
- taskops/engine/narrate.py +91 -0
- taskops/engine/project.py +57 -0
- taskops/engine/replay.py +142 -0
- taskops/engine/reports.py +67 -0
- taskops/engine/scheduler.py +135 -0
- taskops/engine/transcript.py +127 -0
- taskops/engine/wire.py +92 -0
- taskops/engine/worker.py +115 -0
- taskops/py.typed +0 -0
- taskops/render/__init__.py +40 -0
- taskops/render/_closed_days.py +64 -0
- taskops/render/_dossier.py +68 -0
- taskops/render/_opened.py +64 -0
- taskops/render/_sections.py +51 -0
- taskops/render/_tasklist.py +72 -0
- taskops/render/_text.py +74 -0
- taskops/render/_verbatim.py +65 -0
- taskops/render/ansi.py +92 -0
- taskops/render/board.py +55 -0
- taskops/render/day.py +102 -0
- taskops/render/dispatch.py +104 -0
- taskops/render/inbox.py +27 -0
- taskops/render/log.py +47 -0
- taskops/render/recover.py +64 -0
- taskops/render/report.py +51 -0
- taskops/render/reports.py +57 -0
- taskops/render/results.py +96 -0
- taskops/render/session.py +75 -0
- taskops/render/task.py +88 -0
- taskops/render/tasklist.py +65 -0
- taskops/storage/__init__.py +36 -0
- taskops/storage/_ddl.py +78 -0
- taskops/storage/_delivered.py +51 -0
- taskops/storage/_deps.py +69 -0
- taskops/storage/_events.py +127 -0
- taskops/storage/_leases.py +108 -0
- taskops/storage/_rows.py +90 -0
- taskops/storage/_tasks.py +124 -0
- taskops/storage/locate.py +76 -0
- taskops/storage/schema.py +66 -0
- taskops/storage/store.py +120 -0
- taskops/storage/sync.py +139 -0
- taskops/transports/__init__.py +6 -0
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +3 -0
- taskops/transports/cli/commands/_digest.py +64 -0
- taskops/transports/cli/commands/_serve_init.py +65 -0
- taskops/transports/cli/commands/_shared.py +50 -0
- taskops/transports/cli/commands/_tasks_args.py +112 -0
- taskops/transports/cli/commands/_window.py +45 -0
- taskops/transports/cli/commands/ask.py +27 -0
- taskops/transports/cli/commands/dispatch.py +43 -0
- taskops/transports/cli/commands/init.py +48 -0
- taskops/transports/cli/commands/log.py +22 -0
- taskops/transports/cli/commands/plan.py +43 -0
- taskops/transports/cli/commands/pushpull.py +53 -0
- taskops/transports/cli/commands/recover.py +30 -0
- taskops/transports/cli/commands/remote.py +56 -0
- taskops/transports/cli/commands/report.py +82 -0
- taskops/transports/cli/commands/run_.py +60 -0
- taskops/transports/cli/commands/serve.py +74 -0
- taskops/transports/cli/commands/sync.py +22 -0
- taskops/transports/cli/commands/tasks.py +79 -0
- taskops/transports/cli/commands/ui.py +72 -0
- taskops/transports/cli/commands/update.py +25 -0
- taskops/transports/cli/main.py +85 -0
- taskops/transports/hooks/__init__.py +15 -0
- taskops/transports/hooks/__main__.py +63 -0
- taskops/transports/hooks/_args.py +28 -0
- taskops/transports/hooks/claude.py +78 -0
- taskops/transports/hooks/commit.py +61 -0
- taskops/transports/hooks/events.py +107 -0
- taskops/transports/hooks/record.py +55 -0
- taskops/transports/http/__init__.py +21 -0
- taskops/transports/http/_handler.py +132 -0
- taskops/transports/http/_wire.py +79 -0
- taskops/transports/http/_wsframes.py +116 -0
- taskops/transports/http/agentapi.py +69 -0
- taskops/transports/http/api.py +122 -0
- taskops/transports/http/exchange.py +80 -0
- taskops/transports/http/live.py +160 -0
- taskops/transports/http/policy.py +110 -0
- taskops/transports/http/projects.py +116 -0
- taskops/transports/http/reports.py +75 -0
- taskops/transports/http/router.py +90 -0
- taskops/transports/http/server.py +50 -0
- taskops/transports/http/static.py +86 -0
- taskops/transports/http/ui/app.js +59 -0
- taskops/transports/http/ui/index.html +23 -0
- taskops/transports/http/ui/style.css +1 -0
- taskops/transports/http/websocket.py +60 -0
- taskops/transports/mcp/__init__.py +19 -0
- taskops/transports/mcp/__main__.py +7 -0
- taskops/transports/mcp/_descriptions.py +102 -0
- taskops/transports/mcp/_reads.py +82 -0
- taskops/transports/mcp/_writes.py +72 -0
- taskops/transports/mcp/answers.py +44 -0
- taskops/transports/mcp/arguments.py +104 -0
- taskops/transports/mcp/dispatch.py +47 -0
- taskops/transports/mcp/protocol.py +83 -0
- taskops/transports/mcp/schema.py +50 -0
- taskops/transports/mcp/server.py +49 -0
- taskops/transports/mcp/tools.py +66 -0
- taskops/usecases/__init__.py +56 -0
- taskops/usecases/_entry.py +84 -0
- taskops/usecases/_facts.py +49 -0
- taskops/usecases/_freeing.py +93 -0
- taskops/usecases/_gitignore.py +94 -0
- taskops/usecases/_mirroring.py +57 -0
- taskops/usecases/_narrating.py +77 -0
- taskops/usecases/_project.py +67 -0
- taskops/usecases/_range.py +102 -0
- taskops/usecases/_reasons.py +54 -0
- taskops/usecases/_remotefile.py +62 -0
- taskops/usecases/_reportsync.py +108 -0
- taskops/usecases/_routing.py +91 -0
- taskops/usecases/_wireclient.py +141 -0
- taskops/usecases/ask.py +41 -0
- taskops/usecases/claim.py +104 -0
- taskops/usecases/dispatch.py +160 -0
- taskops/usecases/dossier.py +155 -0
- taskops/usecases/edit.py +71 -0
- taskops/usecases/exchange.py +86 -0
- taskops/usecases/feed.py +138 -0
- taskops/usecases/guard.py +122 -0
- taskops/usecases/hooks.py +127 -0
- taskops/usecases/index.py +62 -0
- taskops/usecases/ingest.py +60 -0
- taskops/usecases/log.py +134 -0
- taskops/usecases/narration.py +91 -0
- taskops/usecases/plan.py +128 -0
- taskops/usecases/pushpull.py +119 -0
- taskops/usecases/recover.py +84 -0
- taskops/usecases/remote.py +78 -0
- taskops/usecases/report.py +87 -0
- taskops/usecases/reportfile.py +106 -0
- taskops/usecases/session.py +134 -0
- taskops/usecases/setup.py +92 -0
- taskops/usecases/sync.py +78 -0
- taskops/usecases/update.py +123 -0
- taskops/usecases/view.py +112 -0
- taskops_cli-0.2.0.dist-info/METADATA +291 -0
- taskops_cli-0.2.0.dist-info/RECORD +193 -0
- taskops_cli-0.2.0.dist-info/WHEEL +5 -0
- taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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])
|