taskswarm-cli 0.1.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.
@@ -0,0 +1,255 @@
1
+ """The TaskSwarm HTTP+SSE server: POST /events ingests a status transition,
2
+ GET /events lists current session state, GET /live streams new events over
3
+ Server-Sent Events, and GET / serves the live status page. Ported from
4
+ src/server/http-server.ts.
5
+
6
+ Uses only the standard library (`http.server.ThreadingHTTPServer`) -- one
7
+ thread per connection, which is what makes a long-lived GET /live SSE
8
+ connection sit in its own thread without blocking any other request. This
9
+ mirrors Node's single-process-many-connections model closely enough for a
10
+ tool meant to run on a developer's own machine, not at scale.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import queue
17
+ import threading
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from typing import Any, Dict, Optional
20
+ from urllib.parse import parse_qs, urlparse
21
+
22
+ from ..notifications.dispatch import NotifyOptions, notify
23
+ from ..schema.events import EventValidationError, parse_agent_event_input, to_agent_event
24
+ from .auth import extract_bearer_token, tokens_match
25
+ from .event_store import EventStore
26
+
27
+ # 64KiB is generous for a single event envelope.
28
+ MAX_BODY_BYTES = 64 * 1024
29
+
30
+ _UI_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ui")
31
+
32
+ # SSE clients that get a heartbeat comment (never real data) if nothing
33
+ # arrives for this long, so a dead TCP connection is noticed and cleaned up
34
+ # instead of the serving thread blocking on it forever.
35
+ _SSE_HEARTBEAT_SECONDS = 15.0
36
+
37
+ # Caps concurrent /live SSE connections (each holds its own OS thread here,
38
+ # since ThreadingHTTPServer is one-thread-per-connection) so a client
39
+ # (malicious or just leaked-token) can't exhaust server threads/FDs by
40
+ # opening unbounded long-lived connections.
41
+ _MAX_SSE_CLIENTS = 64
42
+
43
+
44
+ class TaskSwarmHTTPServer(ThreadingHTTPServer):
45
+ daemon_threads = True
46
+ allow_reuse_address = True
47
+
48
+ store: EventStore
49
+ token: str
50
+ notify_options: NotifyOptions
51
+ sse_clients: "set[queue.Queue]"
52
+ sse_lock: threading.Lock
53
+
54
+
55
+ def _send_json(handler: BaseHTTPRequestHandler, status: int, body: Any) -> None:
56
+ payload = json.dumps(body).encode("utf-8")
57
+ handler.send_response(status)
58
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
59
+ handler.send_header("Content-Length", str(len(payload)))
60
+ handler.end_headers()
61
+ try:
62
+ handler.wfile.write(payload)
63
+ except (BrokenPipeError, ConnectionResetError):
64
+ pass
65
+
66
+
67
+ def _is_authorized(
68
+ handler: BaseHTTPRequestHandler, token: str, query: Dict[str, list], allow_query_token: bool
69
+ ) -> bool:
70
+ header_token = extract_bearer_token(handler.headers.get("Authorization"))
71
+ if header_token and tokens_match(header_token, token):
72
+ return True
73
+ # SSE clients (EventSource) cannot set custom headers, so /live -- and
74
+ # only /live -- also accepts the token as a query parameter. Every other
75
+ # route requires the Authorization header, since a query-string token
76
+ # lands in local access logs / shell history / browser history and there
77
+ # is no EventSource-style constraint forcing it there for POST/GET /events.
78
+ if allow_query_token:
79
+ query_token = (query.get("token") or [None])[0]
80
+ if query_token and tokens_match(query_token, token):
81
+ return True
82
+ return False
83
+
84
+
85
+ class TaskSwarmRequestHandler(BaseHTTPRequestHandler):
86
+ server: TaskSwarmHTTPServer
87
+ protocol_version = "HTTP/1.1"
88
+
89
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature
90
+ # Silence the default access log to stderr; this is a local
91
+ # developer tool, not a production service that needs request logs.
92
+ pass
93
+
94
+ def do_GET(self) -> None: # noqa: N802 - stdlib method name
95
+ parsed = urlparse(self.path)
96
+ query = parse_qs(parsed.query)
97
+
98
+ if parsed.path in ("/", "/index.html"):
99
+ self._serve_index()
100
+ return
101
+
102
+ if parsed.path == "/events":
103
+ if not _is_authorized(self, self.server.token, query, allow_query_token=False):
104
+ _send_json(self, 401, {"error": "unauthorized"})
105
+ return
106
+ self._handle_get_events()
107
+ return
108
+
109
+ if parsed.path == "/live":
110
+ if not _is_authorized(self, self.server.token, query, allow_query_token=True):
111
+ _send_json(self, 401, {"error": "unauthorized"})
112
+ return
113
+ self._handle_live()
114
+ return
115
+
116
+ _send_json(self, 404, {"error": "not found"})
117
+
118
+ def do_POST(self) -> None: # noqa: N802 - stdlib method name
119
+ parsed = urlparse(self.path)
120
+ query = parse_qs(parsed.query)
121
+
122
+ if parsed.path == "/events":
123
+ if not _is_authorized(self, self.server.token, query, allow_query_token=False):
124
+ _send_json(self, 401, {"error": "unauthorized"})
125
+ return
126
+ self._handle_post_event()
127
+ return
128
+
129
+ _send_json(self, 404, {"error": "not found"})
130
+
131
+ def _serve_index(self) -> None:
132
+ try:
133
+ with open(os.path.join(_UI_DIR, "index.html"), "r", encoding="utf-8") as handle:
134
+ html = handle.read()
135
+ except OSError:
136
+ _send_json(self, 500, {"error": "ui assets not found"})
137
+ return
138
+ payload = html.encode("utf-8")
139
+ self.send_response(200)
140
+ self.send_header("Content-Type", "text/html; charset=utf-8")
141
+ self.send_header("Content-Length", str(len(payload)))
142
+ self.end_headers()
143
+ self.wfile.write(payload)
144
+
145
+ def _read_body(self) -> Optional[bytes]:
146
+ content_length = self.headers.get("Content-Length")
147
+ if content_length is None:
148
+ return b""
149
+ try:
150
+ length = int(content_length)
151
+ except ValueError:
152
+ return b""
153
+ if length > MAX_BODY_BYTES:
154
+ return None
155
+ return self.rfile.read(length)
156
+
157
+ def _handle_post_event(self) -> None:
158
+ raw = self._read_body()
159
+ if raw is None:
160
+ _send_json(self, 413, {"error": "request body too large"})
161
+ return
162
+
163
+ try:
164
+ parsed_json = json.loads(raw) if len(raw) > 0 else {}
165
+ except json.JSONDecodeError:
166
+ _send_json(self, 400, {"error": "invalid JSON body"})
167
+ return
168
+
169
+ try:
170
+ validated = parse_agent_event_input(parsed_json)
171
+ except EventValidationError as error:
172
+ _send_json(self, 400, {"error": "invalid event", "details": error.details})
173
+ return
174
+
175
+ event = to_agent_event(validated)
176
+ previous_status, previous_blocked_reason = self.server.store.append(event)
177
+ notify(event, previous_status, previous_blocked_reason, self.server.notify_options)
178
+
179
+ _send_json(self, 201, event.to_dict())
180
+
181
+ def _handle_get_events(self) -> None:
182
+ sessions = self.server.store.list_sessions()
183
+ _send_json(
184
+ self,
185
+ 200,
186
+ {
187
+ "sessions": [
188
+ {
189
+ "session_id": s.session_id,
190
+ "latest": s.latest.to_dict(),
191
+ "history": [e.to_dict() for e in s.history],
192
+ }
193
+ for s in sessions
194
+ ]
195
+ },
196
+ )
197
+
198
+ def _handle_live(self) -> None:
199
+ with self.server.sse_lock:
200
+ at_capacity = len(self.server.sse_clients) >= _MAX_SSE_CLIENTS
201
+ if at_capacity:
202
+ _send_json(self, 503, {"error": "too many concurrent /live connections"})
203
+ return
204
+ self.send_response(200)
205
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
206
+ self.send_header("Cache-Control", "no-cache")
207
+ self.send_header("Connection", "keep-alive")
208
+ self.end_headers()
209
+ try:
210
+ self.wfile.write(b": connected\n\n")
211
+ self.wfile.flush()
212
+ except (BrokenPipeError, ConnectionResetError):
213
+ return
214
+
215
+ client_queue: "queue.Queue[str]" = queue.Queue()
216
+ with self.server.sse_lock:
217
+ self.server.sse_clients.add(client_queue)
218
+ try:
219
+ while True:
220
+ try:
221
+ frame = client_queue.get(timeout=_SSE_HEARTBEAT_SECONDS)
222
+ except queue.Empty:
223
+ self.wfile.write(b": keep-alive\n\n")
224
+ self.wfile.flush()
225
+ continue
226
+ self.wfile.write(frame.encode("utf-8"))
227
+ self.wfile.flush()
228
+ except (BrokenPipeError, ConnectionResetError, OSError):
229
+ pass
230
+ finally:
231
+ with self.server.sse_lock:
232
+ self.server.sse_clients.discard(client_queue)
233
+
234
+
235
+ def create_http_server(store: EventStore, token: str, notify_options: Optional[NotifyOptions] = None) -> TaskSwarmHTTPServer:
236
+ """Creates (but does not start listening on) the TaskSwarm HTTP+SSE
237
+ server, bound to a placeholder address. Call `server.server_bind()` and
238
+ `server.server_activate()` (or use `server/index.py`'s `start_server`)
239
+ to actually listen."""
240
+ server = TaskSwarmHTTPServer(("127.0.0.1", 0), TaskSwarmRequestHandler, bind_and_activate=False)
241
+ server.store = store
242
+ server.token = token
243
+ server.notify_options = notify_options or NotifyOptions()
244
+ server.sse_clients = set()
245
+ server.sse_lock = threading.Lock()
246
+
247
+ def _on_event(event: Any) -> None:
248
+ frame = f"data: {json.dumps(event.to_dict())}\n\n"
249
+ with server.sse_lock:
250
+ clients = list(server.sse_clients)
251
+ for client_queue in clients:
252
+ client_queue.put(frame)
253
+
254
+ store.add_listener(_on_event)
255
+ return server
@@ -0,0 +1,72 @@
1
+ """Boots the event store + HTTP/SSE server and starts listening. Ported
2
+ from src/server/index.ts."""
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable, Optional
9
+ from urllib.parse import quote
10
+
11
+ from ..notifications.dispatch import NotifyOptions
12
+ from .config import TaskSwarmConfig, get_event_log_path, load_or_create_config
13
+ from .event_store import EventStore
14
+ from .http_server import TaskSwarmHTTPServer, create_http_server
15
+
16
+ # Sentinel distinguishing "no log_path argument given" (use the default
17
+ # path) from "log_path=None passed explicitly" (disable persistence).
18
+ _UNSET = object()
19
+
20
+
21
+ @dataclass
22
+ class RunningServer:
23
+ server: TaskSwarmHTTPServer
24
+ store: EventStore
25
+ config: TaskSwarmConfig
26
+ url: str
27
+ close: Callable[[], None]
28
+
29
+
30
+ def start_server(
31
+ config: Optional[TaskSwarmConfig] = None,
32
+ log_path: Any = _UNSET,
33
+ notify_options: Optional[NotifyOptions] = None,
34
+ ) -> RunningServer:
35
+ resolved_config = config or load_or_create_config()
36
+ resolved_log_path = None if log_path is None else (get_event_log_path() if log_path is _UNSET else log_path)
37
+
38
+ resolved_notify_options = notify_options
39
+ if resolved_notify_options is None:
40
+ ntfy = resolved_config.ntfy or {}
41
+ resolved_notify_options = NotifyOptions(ntfy=ntfy if ntfy.get("enabled") else None)
42
+
43
+ store = EventStore(resolved_log_path)
44
+ server = create_http_server(store, resolved_config.token, resolved_notify_options)
45
+ server.server_address = (resolved_config.host, resolved_config.port)
46
+ server.server_bind()
47
+ server.server_activate()
48
+ # The actual bound port (relevant when port=0 was requested, e.g. in tests).
49
+ resolved_config.port = server.server_address[1]
50
+
51
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
52
+ thread.start()
53
+
54
+ if resolved_config.host not in ("127.0.0.1", "localhost", "::1"):
55
+ # Two things get worse off loopback: the bearer token travels in
56
+ # plaintext http:// (no TLS path exists), and it's accepted as a
57
+ # ?token= query parameter for /live, which lands in local access logs.
58
+ print(
59
+ f'[taskswarm] warning: binding to "{resolved_config.host}" instead of loopback -- '
60
+ "the API token is sent over plaintext http:// with no TLS, and is accepted as a URL "
61
+ "query parameter for /live (visible in local access logs). Only do this on a "
62
+ "network you trust.",
63
+ file=sys.stderr,
64
+ )
65
+
66
+ url = f"http://{resolved_config.host}:{resolved_config.port}/?token={quote(resolved_config.token)}"
67
+
68
+ def close() -> None:
69
+ server.shutdown()
70
+ server.server_close()
71
+
72
+ return RunningServer(server=server, store=store, config=resolved_config, url=url, close=close)
@@ -0,0 +1,241 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>TaskSwarm</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --bg: #0b0d12;
11
+ --fg: #e6e8ee;
12
+ --muted: #8b93a7;
13
+ --border: #232733;
14
+ --row: #12151d;
15
+ --accent: #5b8cff;
16
+ }
17
+ @media (prefers-color-scheme: light) {
18
+ :root {
19
+ --bg: #f7f8fa;
20
+ --fg: #14171f;
21
+ --muted: #5b6272;
22
+ --border: #e1e4ea;
23
+ --row: #ffffff;
24
+ --accent: #2f5fe0;
25
+ }
26
+ }
27
+ * {
28
+ box-sizing: border-box;
29
+ }
30
+ body {
31
+ margin: 0;
32
+ font-family:
33
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
34
+ background: var(--bg);
35
+ color: var(--fg);
36
+ padding: 2rem 1.5rem 4rem;
37
+ }
38
+ header {
39
+ display: flex;
40
+ align-items: baseline;
41
+ justify-content: space-between;
42
+ max-width: 1100px;
43
+ margin: 0 auto 1.5rem;
44
+ }
45
+ h1 {
46
+ font-size: 1.25rem;
47
+ margin: 0;
48
+ letter-spacing: -0.01em;
49
+ }
50
+ #conn-status {
51
+ font-size: 0.8rem;
52
+ color: var(--muted);
53
+ }
54
+ #conn-status.live {
55
+ color: #35c26a;
56
+ }
57
+ #conn-status.down {
58
+ color: #e0553f;
59
+ }
60
+ table {
61
+ width: 100%;
62
+ max-width: 1100px;
63
+ margin: 0 auto;
64
+ border-collapse: collapse;
65
+ border: 1px solid var(--border);
66
+ border-radius: 8px;
67
+ overflow: hidden;
68
+ }
69
+ thead th {
70
+ text-align: left;
71
+ font-size: 0.75rem;
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.04em;
74
+ color: var(--muted);
75
+ padding: 0.6rem 0.9rem;
76
+ border-bottom: 1px solid var(--border);
77
+ }
78
+ tbody td {
79
+ padding: 0.7rem 0.9rem;
80
+ border-bottom: 1px solid var(--border);
81
+ font-size: 0.9rem;
82
+ background: var(--row);
83
+ }
84
+ tbody tr:last-child td {
85
+ border-bottom: none;
86
+ }
87
+ .status-pill {
88
+ display: inline-block;
89
+ padding: 0.15rem 0.55rem;
90
+ border-radius: 999px;
91
+ font-size: 0.75rem;
92
+ font-weight: 600;
93
+ }
94
+ .status-queued {
95
+ background: #3a3f4d;
96
+ color: #c7cbd6;
97
+ }
98
+ .status-running {
99
+ background: #1f3a63;
100
+ color: #8fb8ff;
101
+ }
102
+ .status-blocked {
103
+ background: #5a3a17;
104
+ color: #ffbd6b;
105
+ }
106
+ .status-needs-review {
107
+ background: #5a3a17;
108
+ color: #ffd76b;
109
+ }
110
+ .status-done {
111
+ background: #17402a;
112
+ color: #6ee6a2;
113
+ }
114
+ .status-failed {
115
+ background: #4a1c1c;
116
+ color: #ff8f8f;
117
+ }
118
+ #empty {
119
+ max-width: 1100px;
120
+ margin: 3rem auto;
121
+ text-align: center;
122
+ color: var(--muted);
123
+ }
124
+ code {
125
+ font-size: 0.85em;
126
+ color: var(--muted);
127
+ }
128
+ </style>
129
+ </head>
130
+ <body>
131
+ <header>
132
+ <h1>TaskSwarm</h1>
133
+ <span id="conn-status">connecting&hellip;</span>
134
+ </header>
135
+ <table id="sessions-table">
136
+ <thead>
137
+ <tr>
138
+ <th>Session</th>
139
+ <th>Repo</th>
140
+ <th>Agent</th>
141
+ <th>Status</th>
142
+ <th>Last event</th>
143
+ </tr>
144
+ </thead>
145
+ <tbody id="sessions-body"></tbody>
146
+ </table>
147
+ <p id="empty" hidden>
148
+ No sessions tracked yet. Report one with <code>taskswarm agent report-status</code>.
149
+ </p>
150
+
151
+ <script>
152
+ (function () {
153
+ var params = new URLSearchParams(window.location.search);
154
+ var token = params.get('token') || '';
155
+ var tbody = document.getElementById('sessions-body');
156
+ var empty = document.getElementById('empty');
157
+ var connStatus = document.getElementById('conn-status');
158
+ var sessions = new Map();
159
+
160
+ function statusClass(status) {
161
+ return 'status-pill status-' + status;
162
+ }
163
+
164
+ function render() {
165
+ var rows = Array.from(sessions.values()).sort(function (a, b) {
166
+ return Date.parse(b.timestamp) - Date.parse(a.timestamp);
167
+ });
168
+ empty.hidden = rows.length > 0;
169
+ tbody.innerHTML = '';
170
+ rows.forEach(function (row) {
171
+ var tr = document.createElement('tr');
172
+ var cells = [
173
+ row.session_id,
174
+ row.repo,
175
+ row.agent_type,
176
+ '<span class="' + statusClass(row.status) + '">' + row.status + '</span>',
177
+ new Date(row.timestamp).toLocaleString(),
178
+ ];
179
+ cells.forEach(function (cell, i) {
180
+ var td = document.createElement('td');
181
+ if (i === 3) {
182
+ td.innerHTML = cell;
183
+ } else {
184
+ td.textContent = cell;
185
+ }
186
+ tr.appendChild(td);
187
+ });
188
+ tbody.appendChild(tr);
189
+ });
190
+ }
191
+
192
+ function applyEvent(event) {
193
+ sessions.set(event.session_id, event);
194
+ render();
195
+ }
196
+
197
+ function loadInitial() {
198
+ fetch('/events', { headers: { Authorization: 'Bearer ' + token } })
199
+ .then(function (res) {
200
+ if (!res.ok) throw new Error('failed to load /events: ' + res.status);
201
+ return res.json();
202
+ })
203
+ .then(function (data) {
204
+ (data.sessions || []).forEach(function (s) {
205
+ sessions.set(s.session_id, s.latest);
206
+ });
207
+ render();
208
+ })
209
+ .catch(function () {
210
+ connStatus.textContent = 'failed to load sessions';
211
+ connStatus.className = 'down';
212
+ });
213
+ }
214
+
215
+ function connectLive() {
216
+ var url = '/live?token=' + encodeURIComponent(token);
217
+ var source = new EventSource(url);
218
+ source.addEventListener('open', function () {
219
+ connStatus.textContent = 'live';
220
+ connStatus.className = 'live';
221
+ });
222
+ source.addEventListener('message', function (evt) {
223
+ try {
224
+ var event = JSON.parse(evt.data);
225
+ applyEvent(event);
226
+ } catch (e) {
227
+ /* ignore malformed frame */
228
+ }
229
+ });
230
+ source.addEventListener('error', function () {
231
+ connStatus.textContent = 'reconnecting…';
232
+ connStatus.className = 'down';
233
+ });
234
+ }
235
+
236
+ loadInitial();
237
+ connectLive();
238
+ })();
239
+ </script>
240
+ </body>
241
+ </html>
File without changes
@@ -0,0 +1,15 @@
1
+ """Ported from src/util/sync-sleep.ts. The TS version needs a genuinely
2
+ blocking sleep (a plain `await sleep()` would yield to Node's event loop and
3
+ let other async work interleave with the retry loops in config.py /
4
+ tasks_registry.py that must run atomically with respect to a single
5
+ process). Python's `time.sleep` already blocks the calling thread without
6
+ yielding to anything else, so this wrapper exists only to keep the module
7
+ layout parallel to the TypeScript source and give the retry loops a single,
8
+ clearly-named call site."""
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+
14
+ def sleep_sync_ms(milliseconds: float) -> None:
15
+ time.sleep(milliseconds / 1000.0)