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,132 @@
|
|
|
1
|
+
"""The request handler: the ONLY file in the package that knows about HTTP.
|
|
2
|
+
|
|
3
|
+
Split from `server` by what each one does — that module constructs a server, this one answers a
|
|
4
|
+
request. Nothing here decides anything: it parses a `Request`, hands it to the route table, and
|
|
5
|
+
writes back a `Reply`.
|
|
6
|
+
|
|
7
|
+
Threaded and not async, which is what makes both live transports work: one connection is one thread
|
|
8
|
+
parked in a generator, so the synchronous engine underneath never learns that a stream exists.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import socket
|
|
14
|
+
from contextlib import closing
|
|
15
|
+
from http.server import BaseHTTPRequestHandler
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
18
|
+
|
|
19
|
+
from ._wire import Reply, Request, Route
|
|
20
|
+
|
|
21
|
+
__all__ = ["Handler", "MAX_BODY"]
|
|
22
|
+
|
|
23
|
+
MAX_BODY = 8 << 20
|
|
24
|
+
"""8 MB. Reading a declared length into memory before checking it is how a small server becomes a
|
|
25
|
+
target, so there is a ceiling at all; it is this high because replication pushes whole documents —
|
|
26
|
+
a batch of 500 events, or `all.md`, which is every day of a project in one file.
|
|
27
|
+
|
|
28
|
+
The cap TRUNCATES rather than refuses, which is survivable only because everything above it is
|
|
29
|
+
JSON: a truncated body fails to parse and the endpoint answers 400 for a body it cannot read."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Handler(BaseHTTPRequestHandler):
|
|
33
|
+
dispatch: Route
|
|
34
|
+
protocol_version = "HTTP/1.1"
|
|
35
|
+
"""1.1 for keep-alive, which SSE needs. It also obliges every reply to carry an accurate
|
|
36
|
+
Content-Length — `_send` does, and a streamed reply omits it deliberately."""
|
|
37
|
+
|
|
38
|
+
def do_GET(self) -> None: # noqa: N802 — stdlib name
|
|
39
|
+
self._handle()
|
|
40
|
+
|
|
41
|
+
def do_POST(self) -> None: # noqa: N802
|
|
42
|
+
self._handle()
|
|
43
|
+
|
|
44
|
+
def do_PUT(self) -> None: # noqa: N802
|
|
45
|
+
"""Replication pushes a report file. Without this the stdlib answers 501 and the route
|
|
46
|
+
table's own 405 — the one that tells a caller which methods a path takes — is never
|
|
47
|
+
reached, so the endpoint would look absent rather than unsupported."""
|
|
48
|
+
self._handle()
|
|
49
|
+
|
|
50
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
51
|
+
"""Silent. The default logs every request to stderr, which buries the one line the studio
|
|
52
|
+
prints — the URL a person needs — under a scrolling access log of its own polling."""
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
def handle_error(self, *args: Any) -> None:
|
|
56
|
+
"""A client that disconnects is NOT an error, and socketserver prints a traceback for it.
|
|
57
|
+
|
|
58
|
+
Closing a tab resets a keep-alive connection, so the default behaviour puts a ten-line
|
|
59
|
+
traceback on the console every time somebody closes the board. Everything that IS an error
|
|
60
|
+
is already caught in `_handle` and answered with a 500.
|
|
61
|
+
"""
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
def _handle(self) -> None:
|
|
65
|
+
try:
|
|
66
|
+
reply = type(self).dispatch(self._request())
|
|
67
|
+
except Exception as err: # noqa: BLE001 — a handler must not die
|
|
68
|
+
# A raised handler kills the thread and the browser sees a dropped connection with no
|
|
69
|
+
# explanation. One 500 with the exception text is worth far more than a clean stack in
|
|
70
|
+
# a log nobody is reading.
|
|
71
|
+
self._send(Reply(status=500, body=f"{type(err).__name__}: {err}".encode()))
|
|
72
|
+
return
|
|
73
|
+
self._send(reply)
|
|
74
|
+
|
|
75
|
+
def _request(self) -> Request:
|
|
76
|
+
parts = urlsplit(self.path)
|
|
77
|
+
length = min(int(self.headers.get("Content-Length") or 0), MAX_BODY)
|
|
78
|
+
return Request(method=self.command, path=parts.path,
|
|
79
|
+
query=dict(parse_qsl(parts.query)),
|
|
80
|
+
headers={k.lower(): v for k, v in self.headers.items()},
|
|
81
|
+
body=self.rfile.read(length) if length else b"")
|
|
82
|
+
|
|
83
|
+
def _send(self, reply: Reply) -> None:
|
|
84
|
+
"""Headers, then either a body or a stream."""
|
|
85
|
+
self._status(reply.status)
|
|
86
|
+
for name, value in reply.headers.items():
|
|
87
|
+
self.send_header(name, value)
|
|
88
|
+
if reply.stream is None:
|
|
89
|
+
self.send_header("Content-Length", str(len(reply.body)))
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(reply.body)
|
|
92
|
+
return
|
|
93
|
+
self.end_headers()
|
|
94
|
+
self._pump(reply)
|
|
95
|
+
|
|
96
|
+
def _status(self, status: int) -> None:
|
|
97
|
+
"""A 101 upgrade needs different treatment from every other status.
|
|
98
|
+
|
|
99
|
+
`send_response_only`, because `send_response` appends `Server` and `Date` — tolerated on a
|
|
100
|
+
handshake, but not what a websocket client is promised. And `close_connection`, which is the
|
|
101
|
+
part that matters: after an upgrade this socket is no longer HTTP, so the handler must not try
|
|
102
|
+
to parse another request off it when the stream ends.
|
|
103
|
+
"""
|
|
104
|
+
if status == 101:
|
|
105
|
+
self.send_response_only(101)
|
|
106
|
+
self.close_connection = True
|
|
107
|
+
return
|
|
108
|
+
self.send_response(status)
|
|
109
|
+
|
|
110
|
+
def _pump(self, reply: Reply) -> None:
|
|
111
|
+
"""Write frames until the client disconnects, then CLOSE the generator.
|
|
112
|
+
|
|
113
|
+
The broken-pipe catch is not defensive noise: closing a tab is the normal way an SSE
|
|
114
|
+
response ends, so it must be an ordinary return rather than a traceback per closed tab.
|
|
115
|
+
`flush` after every frame is what makes it live instead of buffered.
|
|
116
|
+
|
|
117
|
+
The explicit `close()` is the important line, and it was measured into existence. A streamed
|
|
118
|
+
body holds a sqlite connection and an event-bus subscription, released by the generator's
|
|
119
|
+
own `finally` — but on a broken pipe the exception's traceback forms a reference cycle
|
|
120
|
+
through this frame, so refcounting does not reclaim the generator and the cyclic collector
|
|
121
|
+
gets to it whenever it likes. Subscriptions were still alive seven seconds after the client
|
|
122
|
+
hung up. `closing()` runs that `finally` now, deterministically.
|
|
123
|
+
"""
|
|
124
|
+
if reply.stream is None:
|
|
125
|
+
return
|
|
126
|
+
with closing(reply.stream()) as frames:
|
|
127
|
+
try:
|
|
128
|
+
for chunk in frames:
|
|
129
|
+
self.wfile.write(chunk)
|
|
130
|
+
self.wfile.flush()
|
|
131
|
+
except (BrokenPipeError, ConnectionResetError, socket.timeout):
|
|
132
|
+
return
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""A request and a reply, as data. The whole point of this module is testability.
|
|
2
|
+
|
|
3
|
+
Every route is a pure function `Request -> Reply`, so the routing table, the policy and each
|
|
4
|
+
endpoint can be tested by calling them — no socket, no thread, no port. The stdlib handler in
|
|
5
|
+
`server` is then the only thing that knows about HTTP at all, and it is the one piece with no
|
|
6
|
+
decisions in it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Callable, cast
|
|
15
|
+
|
|
16
|
+
__all__ = ["Request", "Reply", "Route", "json_reply", "error_reply", "Streamer"]
|
|
17
|
+
|
|
18
|
+
Streamer = Callable[[], Generator[bytes, None, None]]
|
|
19
|
+
"""A reply body produced lazily, for the SSE stream. Called by the server AFTER the headers are
|
|
20
|
+
flushed, which is what lets one response stay open for an hour.
|
|
21
|
+
|
|
22
|
+
`Generator` and not `Iterator`, deliberately: the server must be able to `close()` it. A streamed
|
|
23
|
+
body holds a database connection and an event-bus subscription, and leaving those to the garbage
|
|
24
|
+
collector is a leak — the traceback of a broken pipe forms a reference cycle, so refcounting does
|
|
25
|
+
not reclaim it and the cyclic collector runs whenever it feels like it. That was measured, not
|
|
26
|
+
assumed: subscriptions survived seven seconds after the client hung up."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Request:
|
|
31
|
+
method: str
|
|
32
|
+
path: str
|
|
33
|
+
query: dict[str, str]
|
|
34
|
+
headers: dict[str, str]
|
|
35
|
+
body: bytes = b""
|
|
36
|
+
|
|
37
|
+
def param(self, name: str, default: str = "") -> str:
|
|
38
|
+
return self.query.get(name, default)
|
|
39
|
+
|
|
40
|
+
def payload(self) -> dict[str, Any]:
|
|
41
|
+
"""The JSON body, or {} — never an exception.
|
|
42
|
+
|
|
43
|
+
The caller is a browser we wrote, but also anything a curious person points at the
|
|
44
|
+
port, and a malformed body has to be a 400 from the endpoint's own validation rather
|
|
45
|
+
than a traceback from the parser.
|
|
46
|
+
"""
|
|
47
|
+
if not self.body:
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
parsed: object = json.loads(self.body)
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
return {}
|
|
53
|
+
return cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Reply:
|
|
58
|
+
status: int = 200
|
|
59
|
+
body: bytes = b""
|
|
60
|
+
headers: dict[str, str] = field(default_factory=dict[str, str])
|
|
61
|
+
stream: Streamer | None = None
|
|
62
|
+
"""Set instead of `body` for a response that never ends. The server checks this first."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Route = Callable[[Request], Reply]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def json_reply(payload: object, status: int = 200) -> Reply:
|
|
69
|
+
"""`default=str` on purpose: a contract is dicts, lists, strings and floats, and anything
|
|
70
|
+
else appearing here is a bug better shown as a string in the UI than a 500 with no clue."""
|
|
71
|
+
return Reply(status=status,
|
|
72
|
+
body=json.dumps(payload, default=str).encode("utf-8"),
|
|
73
|
+
headers={"Content-Type": "application/json; charset=utf-8"})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def error_reply(status: int, message: str, code: str = "error") -> Reply:
|
|
77
|
+
"""One error shape, everywhere. The UI reads `error` and `code`, so a failure renders as a
|
|
78
|
+
sentence a person can act on rather than a red box with a status number in it."""
|
|
79
|
+
return json_reply({"error": message, "code": code}, status)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""RFC 6455 framing: bytes in, bytes out. No HTTP, no sockets, no state.
|
|
2
|
+
|
|
3
|
+
Split from `websocket` by concern — that module turns an HTTP request into a socket, this one is the
|
|
4
|
+
binary protocol that flows afterwards. Pure functions over bytes, so every boundary condition below
|
|
5
|
+
is testable from a literal.
|
|
6
|
+
|
|
7
|
+
Hand-rolled rather than taking the `websockets` library, and the reason is architectural: that
|
|
8
|
+
library is asyncio, and this engine is synchronous by an enforced invariant. Adopting it would mean
|
|
9
|
+
an event loop, a threadpool bridge, and a second concurrency model inside a package whose whole
|
|
10
|
+
storage story is "sqlite, synchronously" — and it would be the first runtime dependency in a package
|
|
11
|
+
that installs into every agent's environment on every machine.
|
|
12
|
+
|
|
13
|
+
What is deliberately NOT here: outgoing fragmentation (taskops frames are a few hundred bytes),
|
|
14
|
+
extensions, and `permessage-deflate`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import struct
|
|
20
|
+
from typing import BinaryIO
|
|
21
|
+
|
|
22
|
+
__all__ = ["text_frame", "close_frame", "ping_frame", "read_frame",
|
|
23
|
+
"OPCODE_TEXT", "OPCODE_CLOSE", "OPCODE_PING", "OPCODE_PONG"]
|
|
24
|
+
|
|
25
|
+
OPCODE_TEXT = 0x1
|
|
26
|
+
OPCODE_CLOSE = 0x8
|
|
27
|
+
OPCODE_PING = 0x9
|
|
28
|
+
OPCODE_PONG = 0xA
|
|
29
|
+
|
|
30
|
+
_MAX_PAYLOAD = 1 << 20
|
|
31
|
+
"""1 MB per incoming frame. The client only ever sends pings and closes here, so anything large is a
|
|
32
|
+
mistake or a probe — and reading a declared length into memory before checking it is the classic way
|
|
33
|
+
a small server becomes a denial-of-service target."""
|
|
34
|
+
|
|
35
|
+
def text_frame(payload: str) -> bytes:
|
|
36
|
+
"""One unfragmented text frame, unmasked.
|
|
37
|
+
|
|
38
|
+
A server MUST NOT mask (RFC 6455 §5.1) — a masked server frame is a protocol error and browsers
|
|
39
|
+
close the connection on it, which reads as a mysteriously dropped socket.
|
|
40
|
+
"""
|
|
41
|
+
body = payload.encode("utf-8")
|
|
42
|
+
return _header(OPCODE_TEXT, len(body)) + body
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def close_frame(code: int = 1000) -> bytes:
|
|
46
|
+
"""A clean close. Sending one before hanging up is what makes the browser's `onclose` carry a
|
|
47
|
+
normal code instead of 1006, which is the code that means "we have no idea what happened"."""
|
|
48
|
+
return _header(OPCODE_CLOSE, 2) + struct.pack("!H", code)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ping_frame() -> bytes:
|
|
52
|
+
"""An empty ping. The protocol's OWN liveness probe, and unlike a comment on an SSE stream it
|
|
53
|
+
gets an answer — so a browser can tell a live-but-idle board from a dead socket."""
|
|
54
|
+
return _header(OPCODE_PING, 0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _header(opcode: int, length: int) -> bytes:
|
|
58
|
+
"""FIN set, no mask, and the three length encodings RFC 6455 defines.
|
|
59
|
+
|
|
60
|
+
The boundaries are exact and unforgiving: 125 inclusive for the short form, 126 as the marker for
|
|
61
|
+
a 16-bit length, 127 for 64-bit. An off-by-one here produces a frame a browser silently discards.
|
|
62
|
+
"""
|
|
63
|
+
first = 0x80 | opcode
|
|
64
|
+
if length < 126:
|
|
65
|
+
return struct.pack("!BB", first, length)
|
|
66
|
+
if length < (1 << 16):
|
|
67
|
+
return struct.pack("!BBH", first, 126, length)
|
|
68
|
+
return struct.pack("!BBQ", first, 127, length)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_frame(stream: BinaryIO) -> tuple[int, bytes] | None:
|
|
72
|
+
"""One incoming frame as `(opcode, payload)`, or None when the stream ends.
|
|
73
|
+
|
|
74
|
+
Client frames are always masked, and the mask is applied by XOR with a repeating 4-byte key.
|
|
75
|
+
Unmasking is not optional: without it a ping's payload comes back as noise and the pong we send
|
|
76
|
+
would not match, which some clients treat as a failed connection.
|
|
77
|
+
"""
|
|
78
|
+
head = _exactly(stream, 2)
|
|
79
|
+
if head is None:
|
|
80
|
+
return None
|
|
81
|
+
opcode = head[0] & 0x0F
|
|
82
|
+
masked = bool(head[1] & 0x80)
|
|
83
|
+
length = head[1] & 0x7F
|
|
84
|
+
if length == 126:
|
|
85
|
+
extended = _exactly(stream, 2)
|
|
86
|
+
length = struct.unpack("!H", extended)[0] if extended else 0
|
|
87
|
+
elif length == 127:
|
|
88
|
+
extended = _exactly(stream, 8)
|
|
89
|
+
length = struct.unpack("!Q", extended)[0] if extended else 0
|
|
90
|
+
if length > _MAX_PAYLOAD:
|
|
91
|
+
return OPCODE_CLOSE, b""
|
|
92
|
+
mask = _exactly(stream, 4) if masked else b""
|
|
93
|
+
payload = _exactly(stream, length) if length else b""
|
|
94
|
+
if payload is None or (masked and mask is None):
|
|
95
|
+
return None
|
|
96
|
+
return opcode, _unmask(payload, mask) if masked and mask else payload
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unmask(payload: bytes, mask: bytes) -> bytes:
|
|
100
|
+
return bytes(byte ^ mask[i % 4] for i, byte in enumerate(payload))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _exactly(stream: BinaryIO, count: int) -> bytes | None:
|
|
104
|
+
"""Read exactly `count` bytes, or None if the stream ended first.
|
|
105
|
+
|
|
106
|
+
A loop rather than one `read(count)`: a socket read is allowed to return fewer bytes than asked
|
|
107
|
+
for, and treating a short read as a complete frame is the bug that makes a websocket work
|
|
108
|
+
perfectly on localhost and corrupt frames over a real network.
|
|
109
|
+
"""
|
|
110
|
+
out = b""
|
|
111
|
+
while len(out) < count:
|
|
112
|
+
chunk = stream.read(count - len(out))
|
|
113
|
+
if not chunk:
|
|
114
|
+
return None
|
|
115
|
+
out += chunk
|
|
116
|
+
return out
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The two writes an AGENT makes, served for agents on other machines.
|
|
2
|
+
|
|
3
|
+
Its own module for the same reason `exchange.py` is one: the caller here is another taskops,
|
|
4
|
+
not the board's browser, so these shapes are a contract a client codes against
|
|
5
|
+
(`docs/exchange.md`) rather than something the UI and the server can rename together.
|
|
6
|
+
|
|
7
|
+
Why they exist: a claim is only atomic inside one database. Two agents on two machines each
|
|
8
|
+
claim in their own sqlite and discover the collision at the next sync, which is too late —
|
|
9
|
+
by then both have edited the same files. Routing the write here makes the two claims two
|
|
10
|
+
INSERTs on one primary key in one store, which is a race the engine already wins.
|
|
11
|
+
|
|
12
|
+
**`local=True` on every call, without exception.** These functions run the same use cases a
|
|
13
|
+
client runs, and those use cases route to the remote when the project has one. A server whose
|
|
14
|
+
store carried a `remote.json` would therefore POST to itself, and answer its own POST by
|
|
15
|
+
POSTing again. The flag is the cycle breaker and it is passed here, at the only place that
|
|
16
|
+
can know it is already the destination.
|
|
17
|
+
|
|
18
|
+
**The actor is TAKEN FROM THE BODY, and that is a real trust decision.** Everywhere else on
|
|
19
|
+
this server — `post_comment` — the actor is resolved server-side, because a browser naming
|
|
20
|
+
its own actor could post as somebody else's agent. It cannot work that way here: the server
|
|
21
|
+
has neither the remote machine's `$TASKOPS_ACTOR` nor its git config, so it has no way to
|
|
22
|
+
learn who is calling. The project TOKEN is therefore the trust boundary: whoever holds it may
|
|
23
|
+
act as any actor in the project. That is the same boundary git already draws — whoever can
|
|
24
|
+
push to the repository can author a commit under any name — and it is stated here rather than
|
|
25
|
+
buried, because a reader deciding where to put this server deserves to know it. What is still
|
|
26
|
+
enforced is the SHAPE: a malformed id is refused with a 400 by `identity.parse` inside the use
|
|
27
|
+
case, so a typo cannot conjure a ghost identity that half an agent's work then files under.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from ...usecases import next_task, update
|
|
35
|
+
from ._wire import Reply, Request, error_reply, json_reply
|
|
36
|
+
from .api import guarded, strings
|
|
37
|
+
|
|
38
|
+
__all__ = ["post_next", "post_update"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def post_next(root: Path, request: Request) -> Reply:
|
|
42
|
+
"""Claim, decided here. Returns the `NextResult` TypedDict, which is already JSON."""
|
|
43
|
+
payload = request.payload()
|
|
44
|
+
actor = str(payload.get("actor", "")).strip()
|
|
45
|
+
if not actor:
|
|
46
|
+
return error_reply(400, "`actor` is required — this server cannot infer the identity "
|
|
47
|
+
"of an agent on another machine", "bad_request")
|
|
48
|
+
return guarded(lambda: json_reply(next_task(
|
|
49
|
+
root, actor=actor, session=str(payload.get("session", "")),
|
|
50
|
+
labels=strings(payload, "labels"), task=str(payload.get("task", "")), local=True)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def post_update(root: Path, request: Request) -> Reply:
|
|
54
|
+
"""A transition, a comment and a notification — checked by THIS store's guards.
|
|
55
|
+
|
|
56
|
+
Which is the point: the lease that `done` requires, and the commits bound to the task, are
|
|
57
|
+
read from the database every machine writes to, so an agent cannot close a card whose
|
|
58
|
+
lease it lost to somebody else while its own board was stale.
|
|
59
|
+
"""
|
|
60
|
+
payload = request.payload()
|
|
61
|
+
task_id = str(payload.get("task", "")).strip()
|
|
62
|
+
actor = str(payload.get("actor", "")).strip()
|
|
63
|
+
if not task_id or not actor:
|
|
64
|
+
return error_reply(400, "`task` and `actor` are required", "bad_request")
|
|
65
|
+
return guarded(lambda: json_reply(update(
|
|
66
|
+
root, task_id, actor=actor, status=str(payload.get("status", "")),
|
|
67
|
+
comment=str(payload.get("comment", "")), mentions=strings(payload, "mentions"),
|
|
68
|
+
blocked_on=str(payload.get("blocked_on", "")),
|
|
69
|
+
no_code=bool(payload.get("no_code")), local=True)))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The JSON endpoints. Each one is a use case call and a serialisation, and nothing else.
|
|
2
|
+
|
|
3
|
+
The contracts ARE the wire format: they are TypedDicts, so at runtime they are already the dicts
|
|
4
|
+
the browser receives, and `studio/src/contracts.ts` mirrors them by hand. That is why there is
|
|
5
|
+
no schema layer here — there is nothing to translate.
|
|
6
|
+
|
|
7
|
+
Every endpoint takes the repository root as its first argument rather than reading module state.
|
|
8
|
+
The server binds it once (`router.build`), which keeps these functions callable from a test with
|
|
9
|
+
a `tmp_path` and no port open.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, cast
|
|
16
|
+
|
|
17
|
+
from ..._errors import TaskopsError
|
|
18
|
+
from ..._version import __version__
|
|
19
|
+
from ...usecases import activity, ask, board, fleet, search, standup, update
|
|
20
|
+
from ...usecases.report import HISTORY_WINDOW
|
|
21
|
+
from ._wire import Reply, Request, error_reply, json_reply
|
|
22
|
+
|
|
23
|
+
__all__ = ["config", "get_board", "get_fleet", "get_standup", "get_task", "get_search",
|
|
24
|
+
"get_activity", "post_comment", "post_status", "guarded", "strings"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def config(root: Path, request: Request) -> Reply:
|
|
28
|
+
"""What the UI needs before its first render. Sent once, so the board does not have to
|
|
29
|
+
discover it is read-only by being refused."""
|
|
30
|
+
return json_reply({"version": __version__, "repo": str(root),
|
|
31
|
+
"readonly": request.param("_readonly") == "1"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_board(root: Path, request: Request) -> Reply:
|
|
35
|
+
return guarded(lambda: json_reply(board(root)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_fleet(root: Path, request: Request) -> Reply:
|
|
39
|
+
return guarded(lambda: json_reply(fleet(root)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_standup(root: Path, request: Request) -> Reply:
|
|
43
|
+
since = request.param("since", "24h")
|
|
44
|
+
return guarded(lambda: json_reply(standup(root, since=since)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_task(root: Path, request: Request) -> Reply:
|
|
48
|
+
task_id = request.param("id")
|
|
49
|
+
if not task_id:
|
|
50
|
+
return error_reply(400, "?id=<task> is required", "bad_request")
|
|
51
|
+
return guarded(lambda: json_reply(ask(root, task_id)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_activity(root: Path, request: Request) -> Reply:
|
|
55
|
+
"""The history: the event log as a timeline, with a roll-up per actor.
|
|
56
|
+
|
|
57
|
+
One read for both, because they are one projection of one list — asking twice would mean two
|
|
58
|
+
scans of the same events, and a per-actor summary computed over a different window than the
|
|
59
|
+
timeline it sits next to would be a summary of something the reader cannot see.
|
|
60
|
+
"""
|
|
61
|
+
since = request.param("since", HISTORY_WINDOW)
|
|
62
|
+
return guarded(lambda: json_reply(activity(root, since=since)))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_search(root: Path, request: Request) -> Reply:
|
|
66
|
+
query = request.param("q")
|
|
67
|
+
if not query:
|
|
68
|
+
return json_reply([])
|
|
69
|
+
return guarded(lambda: json_reply(search(root, query)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def post_comment(root: Path, request: Request) -> Reply:
|
|
73
|
+
"""A human talking to the agents. The mentions reach their inboxes like any other message.
|
|
74
|
+
|
|
75
|
+
The actor is RESOLVED, never taken from the request: a browser that could name its own
|
|
76
|
+
actor could post as somebody else's agent, and this thread is a permanent record.
|
|
77
|
+
"""
|
|
78
|
+
payload = request.payload()
|
|
79
|
+
task_id, text = str(payload.get("task", "")), str(payload.get("text", "")).strip()
|
|
80
|
+
if not task_id or not text:
|
|
81
|
+
return error_reply(400, "`task` and `text` are required", "bad_request")
|
|
82
|
+
mentions = strings(payload, "mentions")
|
|
83
|
+
return guarded(lambda: json_reply(
|
|
84
|
+
update(root, task_id, comment=text, mentions=mentions)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def strings(payload: dict[str, Any], key: str) -> tuple[str, ...]:
|
|
88
|
+
"""A tuple field off a JSON body. Tolerates the two shapes a client can produce — a list,
|
|
89
|
+
or one comma-separated field — because a message that silently reached nobody is the worst
|
|
90
|
+
way for this to fail, and the same is true of a label filter that silently matched
|
|
91
|
+
everything. Shared by `mentions` here and by `labels` on the agent endpoints."""
|
|
92
|
+
raw: object = payload.get(key)
|
|
93
|
+
if isinstance(raw, str):
|
|
94
|
+
return tuple(part.strip() for part in raw.split(",") if part.strip())
|
|
95
|
+
if isinstance(raw, list):
|
|
96
|
+
items = cast("list[object]", raw)
|
|
97
|
+
return tuple(str(item).strip() for item in items if str(item).strip())
|
|
98
|
+
return ()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def post_status(root: Path, request: Request) -> Reply:
|
|
102
|
+
payload = request.payload()
|
|
103
|
+
task_id, status = str(payload.get("task", "")), str(payload.get("status", ""))
|
|
104
|
+
if not task_id or not status:
|
|
105
|
+
return error_reply(400, "`task` and `status` are required", "bad_request")
|
|
106
|
+
comment = str(payload.get("comment", ""))
|
|
107
|
+
return guarded(lambda: json_reply(
|
|
108
|
+
update(root, task_id, status=status, comment=comment)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def guarded(call: Callable[[], Reply]) -> Reply:
|
|
112
|
+
"""Run an endpoint, turning a typed failure into its own HTTP status.
|
|
113
|
+
|
|
114
|
+
The taxonomy already carries `http_status`, so a new error type gets the right code the day
|
|
115
|
+
it is added rather than the day somebody remembers to map it here.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
return call()
|
|
119
|
+
except TaskopsError as err:
|
|
120
|
+
return error_reply(err.http_status, str(err), err.code)
|
|
121
|
+
except OSError as err:
|
|
122
|
+
return error_reply(500, str(err), "io_error")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The four replication endpoints — the wire half of remote sync.
|
|
2
|
+
|
|
3
|
+
Its own module because these are the only endpoints whose caller is another taskops rather
|
|
4
|
+
than the board: the shapes here are a CONTRACT a client codes against (`docs/exchange.md`),
|
|
5
|
+
not something the UI and the server can rename together in one commit.
|
|
6
|
+
|
|
7
|
+
Every write is a POST or a PUT, so `--readonly` refuses all four writes by METHOD in `policy`
|
|
8
|
+
before any of this runs. A mirror on a screen cannot be pushed into.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
from ..._errors import ReportConflict
|
|
17
|
+
from ...usecases import MAX_PAGE, accept_events, pull_events, read_report_file, write_report_file
|
|
18
|
+
from ._wire import Reply, Request, error_reply, json_reply
|
|
19
|
+
from .api import guarded
|
|
20
|
+
|
|
21
|
+
__all__ = ["post_sync", "get_sync", "get_report_file", "put_report_file"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def post_sync(root: Path, request: Request) -> Reply:
|
|
25
|
+
"""Relay a batch. `{"accepted": <how many were new>, "max_seq": <this server's cursor>}`."""
|
|
26
|
+
raw: object = request.payload().get("events")
|
|
27
|
+
if not isinstance(raw, list):
|
|
28
|
+
return error_reply(400, "`events` must be a list of events", "bad_request")
|
|
29
|
+
batch = cast("list[Any]", raw)
|
|
30
|
+
return guarded(lambda: json_reply(accept_events(root, batch)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_sync(root: Path, request: Request) -> Reply:
|
|
34
|
+
"""One page after `?after=`, which is a cursor in THIS server's sequence and no other's."""
|
|
35
|
+
after = _int(request.param("after"), 0)
|
|
36
|
+
limit = _int(request.param("limit"), MAX_PAGE)
|
|
37
|
+
return guarded(lambda: json_reply(pull_events(root, after=after, limit=limit)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_report_file(root: Path, request: Request) -> Reply:
|
|
41
|
+
"""The bytes this server holds for a label, verbatim — it never regenerates one to answer."""
|
|
42
|
+
label = request.param("label")
|
|
43
|
+
if not label:
|
|
44
|
+
return error_reply(400, "?label=<report> is required", "bad_request")
|
|
45
|
+
|
|
46
|
+
def run() -> Reply:
|
|
47
|
+
found = read_report_file(root, label)
|
|
48
|
+
if found is None:
|
|
49
|
+
return error_reply(404, f"no report {label} on this server", "no_such_report")
|
|
50
|
+
return json_reply(found)
|
|
51
|
+
|
|
52
|
+
return guarded(run)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def put_report_file(root: Path, request: Request) -> Reply:
|
|
56
|
+
"""Push a report. 409 unless it is strictly newer than what is here — see `usecases.reportfile`.
|
|
57
|
+
|
|
58
|
+
The conflict is answered by hand rather than through `guarded`, because `ours` and `theirs`
|
|
59
|
+
have to reach the client as NUMBERS. Its whole next move is deciding which copy survives,
|
|
60
|
+
and a sentence it would have to parse to find out is not an answer.
|
|
61
|
+
"""
|
|
62
|
+
payload = request.payload()
|
|
63
|
+
label, content = str(payload.get("label", "")), str(payload.get("content", ""))
|
|
64
|
+
if not label or not content:
|
|
65
|
+
return error_reply(400, "`label` and `content` are required", "bad_request")
|
|
66
|
+
force = bool(payload.get("force"))
|
|
67
|
+
|
|
68
|
+
def run() -> Reply:
|
|
69
|
+
try:
|
|
70
|
+
return json_reply(write_report_file(root, label, content, force=force))
|
|
71
|
+
except ReportConflict as clash:
|
|
72
|
+
return json_reply({"error": str(clash), "code": clash.code,
|
|
73
|
+
"ours": clash.ours, "theirs": clash.theirs}, 409)
|
|
74
|
+
|
|
75
|
+
return guarded(run)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _int(text: str, fallback: int) -> int:
|
|
79
|
+
"""A query number, or the default. A cursor somebody typed by hand must not be a 500."""
|
|
80
|
+
return int(text) if text.lstrip("-").isdigit() else fallback
|