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.
- taskswarm/__init__.py +67 -0
- taskswarm/adapters/__init__.py +11 -0
- taskswarm/adapters/claude_code_adapter.py +226 -0
- taskswarm/adapters/generic_adapter.py +43 -0
- taskswarm/adapters/types.py +23 -0
- taskswarm/cli.py +323 -0
- taskswarm/client/__init__.py +0 -0
- taskswarm/client/api_client.py +61 -0
- taskswarm/client/tasks_registry.py +119 -0
- taskswarm/notifications/__init__.py +3 -0
- taskswarm/notifications/dispatch.py +88 -0
- taskswarm/notifications/ntfy.py +61 -0
- taskswarm/notifications/os_notify.py +61 -0
- taskswarm/py.typed +0 -0
- taskswarm/schema/__init__.py +23 -0
- taskswarm/schema/events.py +221 -0
- taskswarm/server/__init__.py +33 -0
- taskswarm/server/auth.py +25 -0
- taskswarm/server/config.py +154 -0
- taskswarm/server/event_store.py +139 -0
- taskswarm/server/http_server.py +255 -0
- taskswarm/server/server.py +72 -0
- taskswarm/ui/index.html +241 -0
- taskswarm/util/__init__.py +0 -0
- taskswarm/util/sync_sleep.py +15 -0
- taskswarm_cli-0.1.0.dist-info/METADATA +217 -0
- taskswarm_cli-0.1.0.dist-info/RECORD +30 -0
- taskswarm_cli-0.1.0.dist-info/WHEEL +4 -0
- taskswarm_cli-0.1.0.dist-info/entry_points.txt +3 -0
- taskswarm_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
taskswarm/ui/index.html
ADDED
|
@@ -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…</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)
|