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,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