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
taskswarm/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Argument-parsing entry point. Ported from src/cli.ts (which uses
|
|
4
|
+
`commander`); this port uses the stdlib `argparse` to avoid a CLI-framework
|
|
5
|
+
dependency. Subcommands, flags, and `--json` output shapes are kept
|
|
6
|
+
equivalent to the npm CLI's `--help` output and behavior. Console entry
|
|
7
|
+
points: `taskswarm` / `taskswarm-cli`, both installed via
|
|
8
|
+
python/pyproject.toml's `[project.scripts]`.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from .adapters.claude_code_adapter import install_claude_code_hooks
|
|
21
|
+
from .adapters.generic_adapter import GenericAdapter
|
|
22
|
+
from .adapters.types import AdapterValidationError
|
|
23
|
+
from .client.api_client import ApiClientError, get_sessions, post_event
|
|
24
|
+
from .client.tasks_registry import TaskRecord, add_task, list_tasks
|
|
25
|
+
from .schema.events import AGENT_STATUSES, AGENT_TYPES
|
|
26
|
+
from .server.config import load_or_create_config, rotate_token
|
|
27
|
+
from .server.server import start_server
|
|
28
|
+
|
|
29
|
+
PACKAGE_VERSION = "0.1.0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print_json(data: Any) -> None:
|
|
33
|
+
print(json.dumps(data, indent=2))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fail(message: str, as_json: bool = False) -> None:
|
|
37
|
+
"""Reports a command failure and sets a non-zero exit code. Honors the
|
|
38
|
+
CLI's `--json` contract: when the invocation requested `--json`, the
|
|
39
|
+
error is printed as parseable `{"error": "<message>"}` on stdout (never
|
|
40
|
+
stderr, so a caller piping/parsing stdout still gets valid JSON) instead
|
|
41
|
+
of the plain-text `Error: ...` message."""
|
|
42
|
+
if as_json:
|
|
43
|
+
_print_json({"error": message})
|
|
44
|
+
else:
|
|
45
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cmd_start(args: argparse.Namespace) -> int:
|
|
49
|
+
try:
|
|
50
|
+
running = start_server()
|
|
51
|
+
except OSError as error:
|
|
52
|
+
_fail(f"failed to start server: {error}", args.json)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
if args.json:
|
|
56
|
+
_print_json({"url": running.url, "host": running.config.host, "port": running.config.port})
|
|
57
|
+
else:
|
|
58
|
+
print(f"TaskSwarm server listening on http://{running.config.host}:{running.config.port}")
|
|
59
|
+
print(f"Live status page: {running.url}")
|
|
60
|
+
print("Press Ctrl+C to stop.")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
while True:
|
|
64
|
+
import time
|
|
65
|
+
|
|
66
|
+
time.sleep(3600)
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
running.close()
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_task_add(args: argparse.Namespace) -> int:
|
|
73
|
+
record = TaskRecord(
|
|
74
|
+
id=str(uuid.uuid4()),
|
|
75
|
+
title=args.title,
|
|
76
|
+
repo=args.repo,
|
|
77
|
+
created_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
add_task(record)
|
|
81
|
+
except OSError as error:
|
|
82
|
+
_fail(f"failed to save task: {error}", args.json)
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
if args.json:
|
|
86
|
+
_print_json(record.to_dict())
|
|
87
|
+
else:
|
|
88
|
+
print(f"Task created: {record.id}")
|
|
89
|
+
print(f" title: {record.title}")
|
|
90
|
+
print(f" repo: {record.repo}")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cmd_task_list(args: argparse.Namespace) -> int:
|
|
95
|
+
tasks = list_tasks()
|
|
96
|
+
status_by_id: Dict[str, str] = {}
|
|
97
|
+
try:
|
|
98
|
+
config = load_or_create_config()
|
|
99
|
+
sessions = get_sessions(config.to_dict())
|
|
100
|
+
for session in sessions:
|
|
101
|
+
status_by_id[session["session_id"]] = session["latest"]["status"]
|
|
102
|
+
except ApiClientError:
|
|
103
|
+
# Server not running (or unreachable) -- task list still works, just
|
|
104
|
+
# without live status enrichment.
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
rows = [{**task, "status": status_by_id.get(task["id"], "unknown")} for task in tasks]
|
|
108
|
+
|
|
109
|
+
if args.json:
|
|
110
|
+
_print_json(rows)
|
|
111
|
+
return 0
|
|
112
|
+
if len(rows) == 0:
|
|
113
|
+
print("No tasks yet. Create one with `taskswarm task add --title <t> --repo <path>`.")
|
|
114
|
+
return 0
|
|
115
|
+
for row in rows:
|
|
116
|
+
print(f"{row['id']} [{row['status']}] {row['title']} ({row['repo']})")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_agent_report_status(args: argparse.Namespace) -> int:
|
|
121
|
+
adapter = GenericAdapter()
|
|
122
|
+
raw_input: Dict[str, Any] = {
|
|
123
|
+
"session_id": args.task,
|
|
124
|
+
"repo": args.repo,
|
|
125
|
+
"status": args.state,
|
|
126
|
+
"agent_type": args.agent_type,
|
|
127
|
+
}
|
|
128
|
+
if args.blocked_reason:
|
|
129
|
+
raw_input["blocked_reason"] = args.blocked_reason
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
input_data = adapter.to_event_input(raw_input)
|
|
133
|
+
config = load_or_create_config()
|
|
134
|
+
event = post_event(config.to_dict(), input_data)
|
|
135
|
+
except (AdapterValidationError, ApiClientError) as error:
|
|
136
|
+
_fail(str(error), args.json)
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
if args.json:
|
|
140
|
+
_print_json(event)
|
|
141
|
+
else:
|
|
142
|
+
print(f"Reported {event['session_id']} -> {event['status']}")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _cmd_token_rotate(args: argparse.Namespace) -> int:
|
|
147
|
+
try:
|
|
148
|
+
new_token = rotate_token()
|
|
149
|
+
except OSError as error:
|
|
150
|
+
_fail(f"failed to rotate token: {error}", args.json)
|
|
151
|
+
return 1
|
|
152
|
+
|
|
153
|
+
if args.json:
|
|
154
|
+
_print_json({"token": new_token})
|
|
155
|
+
else:
|
|
156
|
+
print("Bearer token rotated. Update any configured clients/hooks with the new value:")
|
|
157
|
+
print(new_token)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cmd_hooks_install(args: argparse.Namespace) -> int:
|
|
162
|
+
if args.adapter != "claude-code":
|
|
163
|
+
_fail(f'unknown adapter "{args.adapter}". Supported adapters: claude-code', args.json)
|
|
164
|
+
return 1
|
|
165
|
+
try:
|
|
166
|
+
result = install_claude_code_hooks(
|
|
167
|
+
scope=args.scope,
|
|
168
|
+
project_dir=args.project_dir,
|
|
169
|
+
home_dir=os.path.expanduser("~"),
|
|
170
|
+
)
|
|
171
|
+
except (AdapterValidationError, OSError) as error:
|
|
172
|
+
_fail(f"failed to install hooks: {error}", args.json)
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
if args.json:
|
|
176
|
+
_print_json(result.to_dict())
|
|
177
|
+
elif result.changed:
|
|
178
|
+
print(f"Installed Claude Code Stop/Notification hooks -> {result.settings_path}")
|
|
179
|
+
else:
|
|
180
|
+
print(f"Claude Code hooks already installed at {result.settings_path}")
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cmd_hooks_claude_code_relay(args: argparse.Namespace) -> int:
|
|
185
|
+
"""Reads a Claude Code hook payload from stdin and relays it to the
|
|
186
|
+
local TaskSwarm server. Installed automatically by `taskswarm hooks
|
|
187
|
+
install claude-code`; not meant to be run by hand. This command must
|
|
188
|
+
never fail the hook (which could interrupt the coding session), so
|
|
189
|
+
every error path logs to stderr and still exits 0."""
|
|
190
|
+
from .adapters.claude_code_adapter import ClaudeCodeAdapter
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
raw = sys.stdin.read()
|
|
194
|
+
payload = json.loads(raw)
|
|
195
|
+
adapter = ClaudeCodeAdapter()
|
|
196
|
+
input_data = adapter.to_event_input(payload)
|
|
197
|
+
config = load_or_create_config()
|
|
198
|
+
post_event(config.to_dict(), input_data)
|
|
199
|
+
except Exception as error: # noqa: BLE001 -- must never propagate a nonzero exit into the hook
|
|
200
|
+
sys.stderr.write(f"taskswarm hook relay: {error}\n")
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
205
|
+
parser = argparse.ArgumentParser(
|
|
206
|
+
prog="taskswarm",
|
|
207
|
+
description=(
|
|
208
|
+
"Self-hosted, event-driven coordination for parallel coding-agent sessions "
|
|
209
|
+
"(Claude Code, Codex, Cursor)."
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument("--version", action="version", version=f"taskswarm {PACKAGE_VERSION}")
|
|
213
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
214
|
+
|
|
215
|
+
start_parser = subparsers.add_parser(
|
|
216
|
+
"start", help="Start the TaskSwarm server and print the live status page URL"
|
|
217
|
+
)
|
|
218
|
+
start_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
|
|
219
|
+
start_parser.set_defaults(func=_cmd_start)
|
|
220
|
+
|
|
221
|
+
task_parser = subparsers.add_parser("task", help="Manage locally tracked tasks")
|
|
222
|
+
task_subparsers = task_parser.add_subparsers(dest="task_command")
|
|
223
|
+
|
|
224
|
+
task_add_parser = task_subparsers.add_parser("add", help="Register a new task")
|
|
225
|
+
task_add_parser.add_argument("--title", required=True, help="human-readable task title")
|
|
226
|
+
task_add_parser.add_argument("--repo", required=True, help="path to the repository the task operates on")
|
|
227
|
+
task_add_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
|
|
228
|
+
task_add_parser.set_defaults(func=_cmd_task_add)
|
|
229
|
+
|
|
230
|
+
task_list_parser = task_subparsers.add_parser(
|
|
231
|
+
"list", help="List tracked tasks, enriched with live status when the server is reachable"
|
|
232
|
+
)
|
|
233
|
+
task_list_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
|
|
234
|
+
task_list_parser.set_defaults(func=_cmd_task_list)
|
|
235
|
+
|
|
236
|
+
agent_parser = subparsers.add_parser("agent", help="Report agent session status")
|
|
237
|
+
agent_subparsers = agent_parser.add_subparsers(dest="agent_command")
|
|
238
|
+
|
|
239
|
+
report_status_parser = agent_subparsers.add_parser(
|
|
240
|
+
"report-status",
|
|
241
|
+
help="Report a status transition for a task/session to the local TaskSwarm server",
|
|
242
|
+
)
|
|
243
|
+
report_status_parser.add_argument("--task", required=True, help="task/session id (the id returned by `task add`)")
|
|
244
|
+
report_status_parser.add_argument("--repo", required=True, help="path to the repository the session operates on")
|
|
245
|
+
report_status_parser.add_argument("--state", required=True, choices=AGENT_STATUSES, help="new status")
|
|
246
|
+
report_status_parser.add_argument(
|
|
247
|
+
"--blocked-reason", dest="blocked_reason", default=None,
|
|
248
|
+
help="reason, shown when status is blocked/needs-review/failed",
|
|
249
|
+
)
|
|
250
|
+
report_status_parser.add_argument(
|
|
251
|
+
"--agent-type", dest="agent_type", choices=AGENT_TYPES, default="generic", help="reporting agent"
|
|
252
|
+
)
|
|
253
|
+
report_status_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
|
|
254
|
+
report_status_parser.set_defaults(func=_cmd_agent_report_status)
|
|
255
|
+
|
|
256
|
+
token_parser = subparsers.add_parser("token", help="Manage the API bearer token")
|
|
257
|
+
token_subparsers = token_parser.add_subparsers(dest="token_command")
|
|
258
|
+
|
|
259
|
+
token_rotate_parser = token_subparsers.add_parser(
|
|
260
|
+
"rotate", help="Generate a new bearer token, invalidating the old one"
|
|
261
|
+
)
|
|
262
|
+
token_rotate_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
|
|
263
|
+
token_rotate_parser.set_defaults(func=_cmd_token_rotate)
|
|
264
|
+
|
|
265
|
+
hooks_parser = subparsers.add_parser("hooks", help="Manage agent hook integrations")
|
|
266
|
+
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_command")
|
|
267
|
+
|
|
268
|
+
hooks_install_parser = hooks_subparsers.add_parser(
|
|
269
|
+
"install", help="Install TaskSwarm hooks for an agent integration (currently: claude-code)"
|
|
270
|
+
)
|
|
271
|
+
hooks_install_parser.add_argument("adapter", help="adapter name, e.g. claude-code")
|
|
272
|
+
hooks_install_parser.add_argument(
|
|
273
|
+
"--scope", choices=["project", "local", "user"], default="project",
|
|
274
|
+
help="settings.json scope to write hooks into",
|
|
275
|
+
)
|
|
276
|
+
hooks_install_parser.add_argument(
|
|
277
|
+
"--project-dir", dest="project_dir", default=os.getcwd(),
|
|
278
|
+
help="project directory (for project/local scope)",
|
|
279
|
+
)
|
|
280
|
+
hooks_install_parser.add_argument("--json", action="store_true", help="output machine-readable JSON")
|
|
281
|
+
hooks_install_parser.set_defaults(func=_cmd_hooks_install)
|
|
282
|
+
|
|
283
|
+
hooks_relay_parser = hooks_subparsers.add_parser(
|
|
284
|
+
"claude-code-relay",
|
|
285
|
+
help=(
|
|
286
|
+
"Internal: reads a Claude Code hook payload from stdin and relays it to the "
|
|
287
|
+
"local TaskSwarm server. Installed automatically by `taskswarm hooks install "
|
|
288
|
+
"claude-code`; not meant to be run by hand."
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
hooks_relay_parser.set_defaults(func=_cmd_hooks_claude_code_relay)
|
|
292
|
+
|
|
293
|
+
return parser
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_cli(argv: List[str]) -> int:
|
|
297
|
+
"""`argv` follows the sys.argv convention: argv[0] is the program name,
|
|
298
|
+
the real arguments start at argv[1]. Returns the process exit code."""
|
|
299
|
+
parser = build_parser()
|
|
300
|
+
args = parser.parse_args(argv[1:])
|
|
301
|
+
|
|
302
|
+
if not hasattr(args, "func"):
|
|
303
|
+
parser.print_help()
|
|
304
|
+
return 0
|
|
305
|
+
return args.func(args)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def main() -> None:
|
|
309
|
+
try:
|
|
310
|
+
code = run_cli(sys.argv)
|
|
311
|
+
except SystemExit:
|
|
312
|
+
raise
|
|
313
|
+
except KeyboardInterrupt:
|
|
314
|
+
sys.exit(130)
|
|
315
|
+
except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
|
|
316
|
+
print(str(error), file=sys.stderr)
|
|
317
|
+
sys.exit(1)
|
|
318
|
+
else:
|
|
319
|
+
sys.exit(code)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Talks to the local TaskSwarm server over HTTP. Ported from
|
|
2
|
+
src/cli/api-client.ts. Uses only `urllib.request` -- no HTTP client
|
|
3
|
+
dependency, matching the zero-runtime-dependency goal of this package."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Dict, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiClientError(Exception):
|
|
13
|
+
def __init__(self, message: str, context: Any = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.context = context
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _base_url(host: str, port: int) -> str:
|
|
19
|
+
return f"http://{host}:{port}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def post_event(config: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
"""POSTs an event to the local TaskSwarm server. Raises ApiClientError on any failure."""
|
|
24
|
+
base_url = _base_url(config["host"], config["port"])
|
|
25
|
+
request = urllib.request.Request(
|
|
26
|
+
f"{base_url}/events",
|
|
27
|
+
data=json.dumps(input_data).encode("utf-8"),
|
|
28
|
+
method="POST",
|
|
29
|
+
headers={
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"Authorization": f"Bearer {config['token']}",
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
36
|
+
return json.loads(response.read().decode("utf-8"))
|
|
37
|
+
except urllib.error.HTTPError as error:
|
|
38
|
+
body = error.read().decode("utf-8", errors="replace")
|
|
39
|
+
raise ApiClientError(f"server rejected event ({error.code}): {body}") from error
|
|
40
|
+
except urllib.error.URLError as error:
|
|
41
|
+
raise ApiClientError(
|
|
42
|
+
f"could not reach TaskSwarm server at {base_url} -- is it running? (`taskswarm start`)",
|
|
43
|
+
error,
|
|
44
|
+
) from error
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_sessions(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
48
|
+
"""GETs current session states from the local TaskSwarm server."""
|
|
49
|
+
base_url = _base_url(config["host"], config["port"])
|
|
50
|
+
request = urllib.request.Request(
|
|
51
|
+
f"{base_url}/events",
|
|
52
|
+
headers={"Authorization": f"Bearer {config['token']}"},
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
56
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
57
|
+
except urllib.error.HTTPError as error:
|
|
58
|
+
raise ApiClientError(f"server returned {error.code}") from error
|
|
59
|
+
except urllib.error.URLError as error:
|
|
60
|
+
raise ApiClientError(f"could not reach TaskSwarm server at {base_url}", error) from error
|
|
61
|
+
return data.get("sessions", [])
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Local, server-independent registry of tasks the user has created via
|
|
2
|
+
`taskswarm task add`. TaskSwarm's wire schema (AgentEvent) is intentionally
|
|
3
|
+
generic and has no `title` field, so human-friendly titles live here rather
|
|
4
|
+
than on the server -- `task add` works even before the server has ever been
|
|
5
|
+
started. Ported from src/cli/tasks-registry.ts."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from ..server.config import get_taskswarm_home
|
|
16
|
+
from ..util.sync_sleep import sleep_sync_ms
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TaskRecord:
|
|
21
|
+
id: str
|
|
22
|
+
title: str
|
|
23
|
+
repo: str
|
|
24
|
+
created_at: str
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_tasks_registry_path() -> str:
|
|
31
|
+
return str(Path(get_taskswarm_home()) / "tasks.json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ensure_home_dir(path: str) -> None:
|
|
35
|
+
directory = os.path.dirname(path)
|
|
36
|
+
if directory and not os.path.exists(directory):
|
|
37
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_tasks() -> List[Dict[str, Any]]:
|
|
41
|
+
path = get_tasks_registry_path()
|
|
42
|
+
if not os.path.exists(path):
|
|
43
|
+
return []
|
|
44
|
+
raw = Path(path).read_text(encoding="utf-8").strip()
|
|
45
|
+
if len(raw) == 0:
|
|
46
|
+
return []
|
|
47
|
+
return json.loads(raw)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_LOCK_RETRY_DELAY_MS = 10
|
|
51
|
+
_LOCK_TIMEOUT_MS = 5000
|
|
52
|
+
# A lock older than this is assumed to be left behind by a crashed process.
|
|
53
|
+
_STALE_LOCK_MS = 10000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _lock_path_for(registry_path: str) -> str:
|
|
57
|
+
return f"{registry_path}.lock"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _acquire_lock(lock_path: str) -> None:
|
|
61
|
+
"""Acquires an exclusive lock by atomically creating `lock_path`
|
|
62
|
+
(O_CREAT|O_EXCL, so exactly one concurrent caller can win). Retries with
|
|
63
|
+
backoff while the lock is held by someone else, reclaiming it if it
|
|
64
|
+
looks abandoned (e.g. the holder crashed before releasing it) so a dead
|
|
65
|
+
lock file can never wedge the CLI forever."""
|
|
66
|
+
deadline = time.monotonic() + (_LOCK_TIMEOUT_MS / 1000.0)
|
|
67
|
+
while True:
|
|
68
|
+
try:
|
|
69
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
70
|
+
os.close(fd)
|
|
71
|
+
return
|
|
72
|
+
except FileExistsError:
|
|
73
|
+
try:
|
|
74
|
+
age_ms = (time.time() - os.stat(lock_path).st_mtime) * 1000
|
|
75
|
+
if age_ms > _STALE_LOCK_MS:
|
|
76
|
+
os.unlink(lock_path)
|
|
77
|
+
continue
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
# Lock vanished between our EEXIST and this stat (the holder
|
|
80
|
+
# just finished) -- loop around and try to acquire it again.
|
|
81
|
+
continue
|
|
82
|
+
if time.monotonic() > deadline:
|
|
83
|
+
raise TimeoutError(
|
|
84
|
+
f"timed out waiting for the tasks registry lock at {lock_path} "
|
|
85
|
+
"(another taskswarm process may be stuck)"
|
|
86
|
+
)
|
|
87
|
+
sleep_sync_ms(_LOCK_RETRY_DELAY_MS)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _release_lock(lock_path: str) -> None:
|
|
91
|
+
try:
|
|
92
|
+
os.unlink(lock_path)
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
# best-effort: if it's already gone (e.g. reclaimed as stale by
|
|
95
|
+
# another waiter) there's nothing left to release.
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def add_task(record: TaskRecord) -> None:
|
|
100
|
+
"""Registers a new task. Guarded by a lockfile around the
|
|
101
|
+
read-modify-write cycle so concurrent add_task() calls (e.g. several
|
|
102
|
+
agent sessions starting at once) never race: without it, two callers
|
|
103
|
+
can both read the same on-disk list, each append their own record in
|
|
104
|
+
memory, and the second write silently overwrites the first caller's
|
|
105
|
+
write, dropping a task with no error."""
|
|
106
|
+
path = get_tasks_registry_path()
|
|
107
|
+
_ensure_home_dir(path)
|
|
108
|
+
lock_path = _lock_path_for(path)
|
|
109
|
+
_acquire_lock(lock_path)
|
|
110
|
+
try:
|
|
111
|
+
tasks = list_tasks()
|
|
112
|
+
tasks.append(record.to_dict())
|
|
113
|
+
Path(path).write_text(json.dumps(tasks, indent=2) + "\n", encoding="utf-8")
|
|
114
|
+
try:
|
|
115
|
+
os.chmod(path, 0o600)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
finally:
|
|
119
|
+
_release_lock(lock_path)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Decides whether a status transition warrants a notification, and fires
|
|
2
|
+
the enabled channels. Ported from src/notify/index.ts."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from ..schema.events import AgentEvent, NOTIFY_ON_STATUSES
|
|
10
|
+
from .ntfy import send_ntfy_notification
|
|
11
|
+
from .os_notify import send_os_notification
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class NotifyOptions:
|
|
16
|
+
ntfy: Optional[Dict[str, Any]] = None
|
|
17
|
+
# Injectable for tests; defaults to the real OS notifier.
|
|
18
|
+
os_notifier: Optional[Callable[[str, str], None]] = None
|
|
19
|
+
# Injectable for tests; defaults to the real ntfy.sh sender.
|
|
20
|
+
ntfy_sender: Optional[Callable[[str, str, str], None]] = None
|
|
21
|
+
# Called whenever the ntfy channel raises, so failures never crash the server.
|
|
22
|
+
on_ntfy_error: Optional[Callable[[BaseException], None]] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def should_notify(
|
|
26
|
+
status: str,
|
|
27
|
+
previous_status: Optional[str],
|
|
28
|
+
blocked_reason: Optional[str] = None,
|
|
29
|
+
previous_blocked_reason: Optional[str] = None,
|
|
30
|
+
) -> bool:
|
|
31
|
+
"""True if a transition from `previous_status`/`previous_blocked_reason`
|
|
32
|
+
to `status`/`blocked_reason` should notify.
|
|
33
|
+
|
|
34
|
+
Dedup keys on the (status, blocked_reason) pair, not status alone: two
|
|
35
|
+
different permission prompts in a row are both 'needs-review', and two
|
|
36
|
+
distinct idle nudges in a row are both 'blocked', but each carries a
|
|
37
|
+
different (or newly-present) blocked_reason and is a genuinely new event
|
|
38
|
+
a human should see -- not a duplicate to swallow. Only a same-status
|
|
39
|
+
event whose blocked_reason also didn't change is treated as a repeat."""
|
|
40
|
+
if status not in NOTIFY_ON_STATUSES:
|
|
41
|
+
return False
|
|
42
|
+
if status != previous_status:
|
|
43
|
+
return True
|
|
44
|
+
return blocked_reason != previous_blocked_reason
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_message(event: AgentEvent) -> "tuple[str, str]":
|
|
48
|
+
title = f"TaskSwarm: {event.session_id} {event.status}"
|
|
49
|
+
parts = [f"repo: {event.repo}", f"agent: {event.agent_type}"]
|
|
50
|
+
if event.blocked_reason:
|
|
51
|
+
parts.append(f"reason: {event.blocked_reason}")
|
|
52
|
+
return title, " | ".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def notify(
|
|
56
|
+
event: AgentEvent,
|
|
57
|
+
previous_status: Optional[str],
|
|
58
|
+
previous_blocked_reason: Optional[str],
|
|
59
|
+
options: Optional[NotifyOptions] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Evaluates an event against its session's previous status/
|
|
62
|
+
blocked_reason and fires the enabled notification channels if the
|
|
63
|
+
transition warrants it. Local OS notification is always-on; ntfy.sh
|
|
64
|
+
only fires when explicitly opted in."""
|
|
65
|
+
options = options or NotifyOptions()
|
|
66
|
+
if not should_notify(event.status, previous_status, event.blocked_reason, previous_blocked_reason):
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
title, message = _format_message(event)
|
|
70
|
+
os_notifier = options.os_notifier or send_os_notification
|
|
71
|
+
os_notifier(title, message)
|
|
72
|
+
|
|
73
|
+
ntfy_config = options.ntfy or {}
|
|
74
|
+
if ntfy_config.get("enabled") and ntfy_config.get("topicUrl"):
|
|
75
|
+
ntfy_sender = options.ntfy_sender or send_ntfy_notification
|
|
76
|
+
|
|
77
|
+
def _fire() -> None:
|
|
78
|
+
try:
|
|
79
|
+
ntfy_sender(ntfy_config["topicUrl"], title, message)
|
|
80
|
+
except Exception as error: # noqa: BLE001 -- notification channel must never raise into the caller
|
|
81
|
+
if options.on_ntfy_error:
|
|
82
|
+
options.on_ntfy_error(error)
|
|
83
|
+
|
|
84
|
+
# Fire-and-forget, same as the TS version's un-awaited
|
|
85
|
+
# `ntfySender(...).catch(...)`: the network call to ntfy.sh must
|
|
86
|
+
# never block the HTTP response path for the /events POST that
|
|
87
|
+
# triggered it.
|
|
88
|
+
threading.Thread(target=_fire, daemon=True).start()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""ntfy.sh channel -- explicitly opt-in only. Never called unless the caller
|
|
2
|
+
has an enabled ntfy config with a topic URL; TaskSwarm's "self-hosted by
|
|
3
|
+
default" claim depends on this never firing implicitly. Ported from
|
|
4
|
+
src/notify/ntfy.ts."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
_PRIVATE_OR_LINK_LOCAL_IPV4 = [
|
|
13
|
+
re.compile(r"^10\."),
|
|
14
|
+
re.compile(r"^172\.(1[6-9]|2\d|3[01])\."),
|
|
15
|
+
re.compile(r"^192\.168\."),
|
|
16
|
+
re.compile(r"^169\.254\."), # includes cloud metadata endpoints, e.g. 169.254.169.254
|
|
17
|
+
re.compile(r"^0\."),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_ntfy_url(topic_url: str):
|
|
22
|
+
"""Rejects a topic URL that isn't https (unless it targets loopback,
|
|
23
|
+
useful for a local self-hosted ntfy instance during testing) or that
|
|
24
|
+
targets a private/link-local address -- guards against a misconfigured
|
|
25
|
+
or tampered config.json silently sending event data in plaintext or to
|
|
26
|
+
an internal/metadata endpoint."""
|
|
27
|
+
parsed = urlparse(topic_url)
|
|
28
|
+
if not parsed.scheme or not parsed.hostname:
|
|
29
|
+
raise ValueError(f'invalid ntfy topic URL: "{topic_url}"')
|
|
30
|
+
hostname = parsed.hostname
|
|
31
|
+
is_loopback = hostname in ("localhost", "127.0.0.1", "::1")
|
|
32
|
+
if parsed.scheme != "https" and not is_loopback:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f'ntfy topic URL must use https:// (got "{parsed.scheme}") -- plaintext http:// '
|
|
35
|
+
"is only allowed for a loopback host, to avoid sending notification content "
|
|
36
|
+
"unencrypted over a network"
|
|
37
|
+
)
|
|
38
|
+
if not is_loopback and any(pattern.match(hostname) for pattern in _PRIVATE_OR_LINK_LOCAL_IPV4):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f'ntfy topic URL host "{hostname}" is a private/link-local address -- refusing to '
|
|
41
|
+
"send notifications there by default"
|
|
42
|
+
)
|
|
43
|
+
return parsed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def send_ntfy_notification(topic_url: str, title: str, message: str) -> None:
|
|
47
|
+
validate_ntfy_url(topic_url)
|
|
48
|
+
request = urllib.request.Request(
|
|
49
|
+
topic_url,
|
|
50
|
+
data=message.encode("utf-8"),
|
|
51
|
+
method="POST",
|
|
52
|
+
headers={"Title": title, "Priority": "default"},
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
56
|
+
if response.status >= 400:
|
|
57
|
+
raise RuntimeError(f"ntfy.sh notification failed: {response.status}")
|
|
58
|
+
except urllib.error.HTTPError as error:
|
|
59
|
+
raise RuntimeError(f"ntfy.sh notification failed: {error.code} {error.reason}") from error
|
|
60
|
+
except urllib.error.URLError as error:
|
|
61
|
+
raise RuntimeError(f"ntfy.sh notification failed: {error.reason}") from error
|