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,61 @@
|
|
|
1
|
+
"""Fires a local OS notification. macOS uses `osascript -e 'display
|
|
2
|
+
notification'` (no extra dependency, ships with the OS). Any other platform
|
|
3
|
+
falls back to a console message plus a terminal bell -- always self-hosted,
|
|
4
|
+
always on by default, no third-party relay involved. Ported from
|
|
5
|
+
src/notify/os-notify.ts."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
_BEL = chr(7)
|
|
14
|
+
|
|
15
|
+
# Strips ANSI/terminal control sequences (ESC-prefixed CSI/OSC sequences and
|
|
16
|
+
# raw control characters other than tab/newline) before writing
|
|
17
|
+
# event-derived text to a real terminal. Event fields (session_id, repo,
|
|
18
|
+
# blocked_reason) can originate from an authenticated but otherwise
|
|
19
|
+
# untrusted /events caller, so this fallback -- unlike the escaped
|
|
20
|
+
# AppleScript path -- must not pass them through raw.
|
|
21
|
+
_CONTROL_SEQUENCE_PATTERN = re.compile(
|
|
22
|
+
r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*(\x07|\x1b\\)|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sanitize_for_terminal(value: str) -> str:
|
|
27
|
+
return _CONTROL_SEQUENCE_PATTERN.sub("", value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def send_os_notification(title: str, message: str) -> None:
|
|
31
|
+
if platform.system() == "Darwin":
|
|
32
|
+
script = (
|
|
33
|
+
f"display notification {_quote_applescript_string(message)} "
|
|
34
|
+
f"with title {_quote_applescript_string(title)}"
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
subprocess.run(
|
|
38
|
+
["osascript", "-e", script],
|
|
39
|
+
stdin=subprocess.DEVNULL,
|
|
40
|
+
stdout=subprocess.DEVNULL,
|
|
41
|
+
stderr=subprocess.DEVNULL,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
except OSError:
|
|
45
|
+
# osascript missing/unavailable (e.g. sandboxed CI) -- fall back
|
|
46
|
+
# so a notification failure never crashes the server process.
|
|
47
|
+
_fallback_notification(title, message)
|
|
48
|
+
return
|
|
49
|
+
_fallback_notification(title, message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fallback_notification(title: str, message: str) -> None:
|
|
53
|
+
# BEL is the terminal bell character -- audible/visible cue in most
|
|
54
|
+
# terminal emulators even without a graphical OS notification.
|
|
55
|
+
sys.stdout.write(_BEL)
|
|
56
|
+
print(f"[taskswarm] {_sanitize_for_terminal(title)}: {_sanitize_for_terminal(message)}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _quote_applescript_string(value: str) -> str:
|
|
60
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
61
|
+
return f'"{escaped}"'
|
taskswarm/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .events import (
|
|
2
|
+
AGENT_STATUSES,
|
|
3
|
+
AGENT_TYPES,
|
|
4
|
+
CURRENT_SCHEMA_VERSION,
|
|
5
|
+
NOTIFY_ON_STATUSES,
|
|
6
|
+
AgentEvent,
|
|
7
|
+
EventValidationError,
|
|
8
|
+
parse_agent_event,
|
|
9
|
+
parse_agent_event_input,
|
|
10
|
+
to_agent_event,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AGENT_STATUSES",
|
|
15
|
+
"AGENT_TYPES",
|
|
16
|
+
"CURRENT_SCHEMA_VERSION",
|
|
17
|
+
"NOTIFY_ON_STATUSES",
|
|
18
|
+
"AgentEvent",
|
|
19
|
+
"EventValidationError",
|
|
20
|
+
"parse_agent_event",
|
|
21
|
+
"parse_agent_event_input",
|
|
22
|
+
"to_agent_event",
|
|
23
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The event envelope every part of TaskSwarm agrees on.
|
|
3
|
+
|
|
4
|
+
Ported from src/schema/events.ts, which uses `zod` for runtime validation.
|
|
5
|
+
This port has zero runtime dependencies, so validation is hand-written here
|
|
6
|
+
instead of pulled in from a schema library -- the field set, limits, and
|
|
7
|
+
error shape are kept equivalent to the TypeScript version's
|
|
8
|
+
`agentEventSchema` / `agentEventInputSchema`.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
# The current envelope version. Bump this whenever the shape of AgentEvent
|
|
19
|
+
# changes in a way that is not purely additive-and-optional. Consumers can
|
|
20
|
+
# branch on `schema_version` to stay forward compatible.
|
|
21
|
+
CURRENT_SCHEMA_VERSION = 1
|
|
22
|
+
|
|
23
|
+
# Agent integrations TaskSwarm ships an adapter for in v0.1.
|
|
24
|
+
AGENT_TYPES = ("claude-code", "codex", "cursor", "generic")
|
|
25
|
+
|
|
26
|
+
# Lifecycle states a tracked session can be in. TaskSwarm's notification
|
|
27
|
+
# layer fires on a transition into 'blocked' | 'needs-review' | 'failed' |
|
|
28
|
+
# 'done' -- the four states that mean "a human should look at this now."
|
|
29
|
+
AGENT_STATUSES = ("queued", "running", "blocked", "needs-review", "done", "failed")
|
|
30
|
+
|
|
31
|
+
# The subset of statuses that should trigger a notification on transition.
|
|
32
|
+
NOTIFY_ON_STATUSES = frozenset({"blocked", "needs-review", "failed", "done"})
|
|
33
|
+
|
|
34
|
+
_SESSION_ID_MAX = 256
|
|
35
|
+
_REPO_MAX = 1024
|
|
36
|
+
_BLOCKED_REASON_MAX = 4096
|
|
37
|
+
|
|
38
|
+
_UUID_RE = re.compile(
|
|
39
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EventValidationError(ValueError):
|
|
44
|
+
"""Raised when a raw dict does not satisfy the AgentEvent(Input) contract.
|
|
45
|
+
|
|
46
|
+
`details` mirrors zod's `.flatten()` shape closely enough to be useful
|
|
47
|
+
for a caller building a `400` response body: a dict of field name to a
|
|
48
|
+
list of human-readable problem messages.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, details: Optional[Dict[str, List[str]]] = None) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.details: Dict[str, List[str]] = details or {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AgentEvent:
|
|
58
|
+
event_id: str
|
|
59
|
+
session_id: str
|
|
60
|
+
repo: str
|
|
61
|
+
agent_type: str
|
|
62
|
+
status: str
|
|
63
|
+
timestamp: str
|
|
64
|
+
schema_version: int
|
|
65
|
+
blocked_reason: Optional[str] = field(default=None)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
+
data: Dict[str, Any] = {
|
|
69
|
+
"event_id": self.event_id,
|
|
70
|
+
"session_id": self.session_id,
|
|
71
|
+
"repo": self.repo,
|
|
72
|
+
"agent_type": self.agent_type,
|
|
73
|
+
"status": self.status,
|
|
74
|
+
"timestamp": self.timestamp,
|
|
75
|
+
"schema_version": self.schema_version,
|
|
76
|
+
}
|
|
77
|
+
if self.blocked_reason is not None:
|
|
78
|
+
data["blocked_reason"] = self.blocked_reason
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_uuid(value: Any) -> bool:
|
|
83
|
+
return isinstance(value, str) and bool(_UUID_RE.match(value))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_offset_datetime(value: Any) -> bool:
|
|
87
|
+
"""True for an ISO-8601 datetime string carrying an explicit UTC offset
|
|
88
|
+
(including a bare "Z"), matching zod's `z.string().datetime({offset:
|
|
89
|
+
true})`."""
|
|
90
|
+
if not isinstance(value, str) or len(value) == 0:
|
|
91
|
+
return False
|
|
92
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
93
|
+
try:
|
|
94
|
+
parsed = datetime.fromisoformat(normalized)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return False
|
|
97
|
+
return parsed.tzinfo is not None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add_error(errors: Dict[str, List[str]], field_name: str, message: str) -> None:
|
|
101
|
+
errors.setdefault(field_name, []).append(message)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _validate_common(data: Dict[str, Any], errors: Dict[str, List[str]]) -> None:
|
|
105
|
+
session_id = data.get("session_id")
|
|
106
|
+
if not isinstance(session_id, str) or not (1 <= len(session_id) <= _SESSION_ID_MAX):
|
|
107
|
+
_add_error(errors, "session_id", f"must be a string of 1-{_SESSION_ID_MAX} characters")
|
|
108
|
+
|
|
109
|
+
repo = data.get("repo")
|
|
110
|
+
if not isinstance(repo, str) or not (1 <= len(repo) <= _REPO_MAX):
|
|
111
|
+
_add_error(errors, "repo", f"must be a string of 1-{_REPO_MAX} characters")
|
|
112
|
+
|
|
113
|
+
agent_type = data.get("agent_type")
|
|
114
|
+
if agent_type not in AGENT_TYPES:
|
|
115
|
+
_add_error(errors, "agent_type", f"must be one of: {', '.join(AGENT_TYPES)}")
|
|
116
|
+
|
|
117
|
+
status = data.get("status")
|
|
118
|
+
if status not in AGENT_STATUSES:
|
|
119
|
+
_add_error(errors, "status", f"must be one of: {', '.join(AGENT_STATUSES)}")
|
|
120
|
+
|
|
121
|
+
blocked_reason = data.get("blocked_reason")
|
|
122
|
+
if blocked_reason is not None and (
|
|
123
|
+
not isinstance(blocked_reason, str) or len(blocked_reason) > _BLOCKED_REASON_MAX
|
|
124
|
+
):
|
|
125
|
+
_add_error(errors, "blocked_reason", f"must be a string of at most {_BLOCKED_REASON_MAX} characters")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_agent_event_input(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
129
|
+
"""Validates the input shape accepted from the CLI/adapters/HTTP layer:
|
|
130
|
+
event_id, timestamp, and schema_version are all optional (filled in by
|
|
131
|
+
`to_agent_event`); everything else is required. Raises
|
|
132
|
+
EventValidationError with field-level details on failure."""
|
|
133
|
+
if not isinstance(data, dict):
|
|
134
|
+
raise EventValidationError("event input must be a JSON object", {"_root": ["must be an object"]})
|
|
135
|
+
|
|
136
|
+
errors: Dict[str, List[str]] = {}
|
|
137
|
+
_validate_common(data, errors)
|
|
138
|
+
|
|
139
|
+
event_id = data.get("event_id")
|
|
140
|
+
if event_id is not None and not _is_uuid(event_id):
|
|
141
|
+
_add_error(errors, "event_id", "must be a valid UUID")
|
|
142
|
+
|
|
143
|
+
timestamp = data.get("timestamp")
|
|
144
|
+
if timestamp is not None and not _is_offset_datetime(timestamp):
|
|
145
|
+
_add_error(errors, "timestamp", "must be an ISO-8601 datetime with a UTC offset")
|
|
146
|
+
|
|
147
|
+
schema_version = data.get("schema_version")
|
|
148
|
+
if schema_version is not None and not (isinstance(schema_version, int) and schema_version > 0):
|
|
149
|
+
_add_error(errors, "schema_version", "must be a positive integer")
|
|
150
|
+
|
|
151
|
+
if errors:
|
|
152
|
+
raise EventValidationError("invalid event", errors)
|
|
153
|
+
|
|
154
|
+
result: Dict[str, Any] = {
|
|
155
|
+
"session_id": data["session_id"],
|
|
156
|
+
"repo": data["repo"],
|
|
157
|
+
"agent_type": data["agent_type"],
|
|
158
|
+
"status": data["status"],
|
|
159
|
+
}
|
|
160
|
+
if data.get("blocked_reason") is not None:
|
|
161
|
+
result["blocked_reason"] = data["blocked_reason"]
|
|
162
|
+
if event_id is not None:
|
|
163
|
+
result["event_id"] = event_id
|
|
164
|
+
if timestamp is not None:
|
|
165
|
+
result["timestamp"] = timestamp
|
|
166
|
+
if schema_version is not None:
|
|
167
|
+
result["schema_version"] = schema_version
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_agent_event(data: Dict[str, Any]) -> AgentEvent:
|
|
172
|
+
"""Validates a fully-stamped event (all fields required) -- used when
|
|
173
|
+
replaying persisted JSONL log lines, where every event must already
|
|
174
|
+
carry event_id/timestamp/schema_version."""
|
|
175
|
+
if not isinstance(data, dict):
|
|
176
|
+
raise EventValidationError("event must be a JSON object")
|
|
177
|
+
|
|
178
|
+
errors: Dict[str, List[str]] = {}
|
|
179
|
+
_validate_common(data, errors)
|
|
180
|
+
|
|
181
|
+
if not _is_uuid(data.get("event_id")):
|
|
182
|
+
_add_error(errors, "event_id", "must be a valid UUID")
|
|
183
|
+
if not _is_offset_datetime(data.get("timestamp")):
|
|
184
|
+
_add_error(errors, "timestamp", "must be an ISO-8601 datetime with a UTC offset")
|
|
185
|
+
schema_version = data.get("schema_version")
|
|
186
|
+
if not (isinstance(schema_version, int) and schema_version > 0):
|
|
187
|
+
_add_error(errors, "schema_version", "must be a positive integer")
|
|
188
|
+
|
|
189
|
+
if errors:
|
|
190
|
+
raise EventValidationError("invalid event", errors)
|
|
191
|
+
|
|
192
|
+
return AgentEvent(
|
|
193
|
+
event_id=data["event_id"],
|
|
194
|
+
session_id=data["session_id"],
|
|
195
|
+
repo=data["repo"],
|
|
196
|
+
agent_type=data["agent_type"],
|
|
197
|
+
status=data["status"],
|
|
198
|
+
timestamp=data["timestamp"],
|
|
199
|
+
schema_version=data["schema_version"],
|
|
200
|
+
blocked_reason=data.get("blocked_reason"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def to_agent_event(input_data: Dict[str, Any]) -> AgentEvent:
|
|
205
|
+
"""Fills in event_id/timestamp/schema_version defaults for a partial,
|
|
206
|
+
already-validated input (the output of parse_agent_event_input)."""
|
|
207
|
+
event_id = input_data.get("event_id") or str(uuid.uuid4())
|
|
208
|
+
timestamp = input_data.get("timestamp") or datetime.now(timezone.utc).isoformat().replace(
|
|
209
|
+
"+00:00", "Z"
|
|
210
|
+
)
|
|
211
|
+
schema_version = input_data.get("schema_version") or CURRENT_SCHEMA_VERSION
|
|
212
|
+
return AgentEvent(
|
|
213
|
+
event_id=event_id,
|
|
214
|
+
session_id=input_data["session_id"],
|
|
215
|
+
repo=input_data["repo"],
|
|
216
|
+
agent_type=input_data["agent_type"],
|
|
217
|
+
status=input_data["status"],
|
|
218
|
+
timestamp=timestamp,
|
|
219
|
+
schema_version=schema_version,
|
|
220
|
+
blocked_reason=input_data.get("blocked_reason"),
|
|
221
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .config import (
|
|
2
|
+
DEFAULT_HOST,
|
|
3
|
+
DEFAULT_PORT,
|
|
4
|
+
TaskSwarmConfig,
|
|
5
|
+
generate_token,
|
|
6
|
+
get_config_path,
|
|
7
|
+
get_event_log_path,
|
|
8
|
+
get_taskswarm_home,
|
|
9
|
+
load_or_create_config,
|
|
10
|
+
rotate_token,
|
|
11
|
+
save_config,
|
|
12
|
+
try_create_config_exclusive,
|
|
13
|
+
)
|
|
14
|
+
from .event_store import EventStore, SessionState
|
|
15
|
+
from .server import RunningServer, start_server
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_HOST",
|
|
19
|
+
"DEFAULT_PORT",
|
|
20
|
+
"TaskSwarmConfig",
|
|
21
|
+
"generate_token",
|
|
22
|
+
"get_config_path",
|
|
23
|
+
"get_event_log_path",
|
|
24
|
+
"get_taskswarm_home",
|
|
25
|
+
"load_or_create_config",
|
|
26
|
+
"rotate_token",
|
|
27
|
+
"save_config",
|
|
28
|
+
"try_create_config_exclusive",
|
|
29
|
+
"EventStore",
|
|
30
|
+
"SessionState",
|
|
31
|
+
"RunningServer",
|
|
32
|
+
"start_server",
|
|
33
|
+
]
|
taskswarm/server/auth.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Bearer-token comparison and extraction. Ported from src/server/auth.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hmac
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
_BEARER_RE = re.compile(r"^Bearer\s+(.+)$", re.IGNORECASE)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tokens_match(provided: str, expected: str) -> bool:
|
|
12
|
+
"""Constant-time comparison of two bearer tokens (avoids timing
|
|
13
|
+
side-channels). `hmac.compare_digest` is the stdlib's constant-time
|
|
14
|
+
comparator -- the direct Python equivalent of Node's
|
|
15
|
+
`crypto.timingSafeEqual`, and (like the TS version) it tolerates
|
|
16
|
+
differing lengths without leaking length via early-exit timing."""
|
|
17
|
+
return hmac.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_bearer_token(authorization_header: Optional[str]) -> Optional[str]:
|
|
21
|
+
"""Extracts a bearer token from an Authorization header value, if present."""
|
|
22
|
+
if not authorization_header:
|
|
23
|
+
return None
|
|
24
|
+
match = _BEARER_RE.match(authorization_header)
|
|
25
|
+
return match.group(1) if match else None
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Local config: bearer token, host/port, and the opt-in ntfy.sh channel.
|
|
2
|
+
Ported from src/server/config.ts."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from ..util.sync_sleep import sleep_sync_ms
|
|
14
|
+
|
|
15
|
+
DEFAULT_PORT = 4173
|
|
16
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TaskSwarmConfig:
|
|
21
|
+
token: str
|
|
22
|
+
port: int = DEFAULT_PORT
|
|
23
|
+
host: str = DEFAULT_HOST
|
|
24
|
+
ntfy: Dict[str, Any] = field(default_factory=lambda: {"enabled": False})
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
27
|
+
return {"token": self.token, "port": self.port, "host": self.host, "ntfy": self.ntfy}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_taskswarm_home() -> str:
|
|
31
|
+
"""Root directory for all TaskSwarm local state. Overridable for tests
|
|
32
|
+
via TASKSWARM_HOME."""
|
|
33
|
+
return os.environ.get("TASKSWARM_HOME") or str(Path.home() / ".taskswarm")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config_path() -> str:
|
|
37
|
+
return str(Path(get_taskswarm_home()) / "config.json")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_event_log_path() -> str:
|
|
41
|
+
return str(Path(get_taskswarm_home()) / "events.jsonl")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_token() -> str:
|
|
45
|
+
"""Generates a cryptographically random bearer token (256 bits, URL-safe)."""
|
|
46
|
+
return secrets.token_urlsafe(32)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_home_dir() -> None:
|
|
50
|
+
home = Path(get_taskswarm_home())
|
|
51
|
+
home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(home, 0o700)
|
|
54
|
+
except OSError:
|
|
55
|
+
# best-effort on platforms/filesystems that don't support POSIX perms
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _default_config() -> TaskSwarmConfig:
|
|
60
|
+
return TaskSwarmConfig(token=generate_token(), port=DEFAULT_PORT, host=DEFAULT_HOST, ntfy={"enabled": False})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_config(config: TaskSwarmConfig) -> None:
|
|
64
|
+
_ensure_home_dir()
|
|
65
|
+
path = get_config_path()
|
|
66
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
67
|
+
handle.write(json.dumps(config.to_dict(), indent=2) + "\n")
|
|
68
|
+
try:
|
|
69
|
+
os.chmod(path, 0o600)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def try_create_config_exclusive(config: TaskSwarmConfig) -> bool:
|
|
75
|
+
"""Attempts to create the config file exclusively (fails if another
|
|
76
|
+
process already created it). Returns True if this call won the race and
|
|
77
|
+
wrote the file, False if another process got there first. Exported so
|
|
78
|
+
the TOCTOU-loss path can be exercised directly and deterministically in
|
|
79
|
+
tests, without needing to fake real multi-process OS scheduling."""
|
|
80
|
+
_ensure_home_dir()
|
|
81
|
+
path = get_config_path()
|
|
82
|
+
payload = json.dumps(config.to_dict(), indent=2) + "\n"
|
|
83
|
+
try:
|
|
84
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
85
|
+
except FileExistsError:
|
|
86
|
+
return False
|
|
87
|
+
try:
|
|
88
|
+
os.write(fd, payload.encode("utf-8"))
|
|
89
|
+
finally:
|
|
90
|
+
os.close(fd)
|
|
91
|
+
try:
|
|
92
|
+
os.chmod(path, 0o600)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_config_file_with_retry(path: str) -> Dict[str, Any]:
|
|
99
|
+
"""Reads and parses the config file, retrying briefly on a parse
|
|
100
|
+
failure. Guards the narrow window where we lost the exclusive-create
|
|
101
|
+
race and the winning process has created the file but not yet finished
|
|
102
|
+
flushing its contents."""
|
|
103
|
+
max_attempts = 25
|
|
104
|
+
retry_delay_ms = 4
|
|
105
|
+
last_error: Optional[Exception] = None
|
|
106
|
+
for _ in range(max_attempts):
|
|
107
|
+
try:
|
|
108
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
109
|
+
return json.load(handle)
|
|
110
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
111
|
+
last_error = error
|
|
112
|
+
sleep_sync_ms(retry_delay_ms)
|
|
113
|
+
assert last_error is not None
|
|
114
|
+
raise last_error
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_or_create_config() -> TaskSwarmConfig:
|
|
118
|
+
"""Loads the config, creating it (with a freshly generated token) on
|
|
119
|
+
first run. First-boot creation is race-safe: if two processes both see
|
|
120
|
+
no config file and race to create one, exactly one of them wins the
|
|
121
|
+
exclusive create and the loser re-reads the winner's file instead of
|
|
122
|
+
generating and persisting a second, different token."""
|
|
123
|
+
_ensure_home_dir()
|
|
124
|
+
path = get_config_path()
|
|
125
|
+
if not os.path.exists(path):
|
|
126
|
+
config = _default_config()
|
|
127
|
+
if try_create_config_exclusive(config):
|
|
128
|
+
return config
|
|
129
|
+
# Lost the race: another process already created the file first.
|
|
130
|
+
# Fall through and read what it wrote rather than clobbering it.
|
|
131
|
+
|
|
132
|
+
parsed = _read_config_file_with_retry(path)
|
|
133
|
+
ntfy = parsed.get("ntfy") or {}
|
|
134
|
+
merged = TaskSwarmConfig(
|
|
135
|
+
token=parsed.get("token") or generate_token(),
|
|
136
|
+
port=parsed.get("port") or DEFAULT_PORT,
|
|
137
|
+
host=parsed.get("host") or DEFAULT_HOST,
|
|
138
|
+
ntfy={"enabled": bool(ntfy.get("enabled", False)), **({"topicUrl": ntfy["topicUrl"]} if ntfy.get("topicUrl") else {})},
|
|
139
|
+
)
|
|
140
|
+
if not parsed.get("token"):
|
|
141
|
+
_write_config(merged)
|
|
142
|
+
return merged
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def save_config(config: TaskSwarmConfig) -> None:
|
|
146
|
+
_write_config(config)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def rotate_token() -> str:
|
|
150
|
+
"""Regenerates the bearer token and persists it. Returns the new token."""
|
|
151
|
+
config = load_or_create_config()
|
|
152
|
+
config.token = generate_token()
|
|
153
|
+
_write_config(config)
|
|
154
|
+
return config.token
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""In-memory event store keyed by session_id, backed by an append-only JSONL
|
|
2
|
+
log on disk for durability across restarts. No embedded database: this is a
|
|
3
|
+
deliberate choice to keep the tool lightweight, dependency-free, and
|
|
4
|
+
ARM-friendly (no native binary compile step). Ported from
|
|
5
|
+
src/server/event-store.ts.
|
|
6
|
+
|
|
7
|
+
Thread safety: the HTTP server (server/http_server.py) runs each connection
|
|
8
|
+
on its own thread (`http.server.ThreadingHTTPServer`), so every method here
|
|
9
|
+
that touches `_sessions` or the on-disk log is guarded by a single lock.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import threading
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, List, Optional
|
|
20
|
+
|
|
21
|
+
from ..schema.events import AgentEvent, parse_agent_event
|
|
22
|
+
|
|
23
|
+
# Longest raw line content ever echoed back in a corrupt-line warning.
|
|
24
|
+
_CORRUPT_LINE_PREVIEW_LIMIT = 200
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SessionState:
|
|
29
|
+
session_id: str
|
|
30
|
+
latest: AgentEvent
|
|
31
|
+
history: List[AgentEvent] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EventStore:
|
|
35
|
+
"""Emits to registered listeners whenever a new event lands, so the HTTP
|
|
36
|
+
layer can fan it out to connected /live clients."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, log_path: Optional[str] = None, warn: Optional[Callable[[str], None]] = None) -> None:
|
|
39
|
+
self._sessions: "dict[str, SessionState]" = {}
|
|
40
|
+
self._log_path = log_path
|
|
41
|
+
self._warn = warn or (lambda message: print(message, flush=True))
|
|
42
|
+
self._listeners: List[Callable[[AgentEvent], None]] = []
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
if self._log_path:
|
|
45
|
+
self._ensure_log_file(self._log_path)
|
|
46
|
+
self._replay(self._log_path)
|
|
47
|
+
|
|
48
|
+
def _ensure_log_file(self, log_path: str) -> None:
|
|
49
|
+
directory = os.path.dirname(log_path)
|
|
50
|
+
if directory and not os.path.exists(directory):
|
|
51
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
52
|
+
if not os.path.exists(log_path):
|
|
53
|
+
fd = os.open(log_path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
|
|
54
|
+
os.close(fd)
|
|
55
|
+
|
|
56
|
+
def _replay(self, log_path: str) -> None:
|
|
57
|
+
"""Rebuilds in-memory state by replaying every line of the JSONL
|
|
58
|
+
log. Corrupt/partial lines (e.g. a torn write from a crash
|
|
59
|
+
mid-append) are skipped rather than failing startup entirely --
|
|
60
|
+
durability is best-effort, not a hard guarantee for the last
|
|
61
|
+
unflushed line -- but each skip is logged so a torn write or
|
|
62
|
+
bit-rot is visible to the operator instead of silently erasing
|
|
63
|
+
history."""
|
|
64
|
+
with open(log_path, "r", encoding="utf-8") as handle:
|
|
65
|
+
raw = handle.read()
|
|
66
|
+
for line_number, line in enumerate(raw.split("\n"), start=1):
|
|
67
|
+
if line.strip() == "":
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
parsed = parse_agent_event(json.loads(line))
|
|
71
|
+
self._apply_to_memory(parsed)
|
|
72
|
+
except Exception:
|
|
73
|
+
self._warn_corrupt_line(log_path, line_number, line)
|
|
74
|
+
|
|
75
|
+
def _warn_corrupt_line(self, log_path: str, line_number: int, line: str) -> None:
|
|
76
|
+
preview = (
|
|
77
|
+
f"{line[:_CORRUPT_LINE_PREVIEW_LIMIT]}... (truncated, {len(line)} chars total)"
|
|
78
|
+
if len(line) > _CORRUPT_LINE_PREVIEW_LIMIT
|
|
79
|
+
else line
|
|
80
|
+
)
|
|
81
|
+
self._warn(f"taskswarm: skipping unparseable event on line {line_number} of {log_path}: {preview}")
|
|
82
|
+
|
|
83
|
+
def _apply_to_memory(self, event: AgentEvent) -> None:
|
|
84
|
+
existing = self._sessions.get(event.session_id)
|
|
85
|
+
if existing:
|
|
86
|
+
existing.history.append(event)
|
|
87
|
+
existing.latest = event
|
|
88
|
+
else:
|
|
89
|
+
self._sessions[event.session_id] = SessionState(
|
|
90
|
+
session_id=event.session_id, latest=event, history=[event]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def append(self, event: AgentEvent) -> "tuple[Optional[str], Optional[str]]":
|
|
94
|
+
"""Appends a validated event to the log (if persistence is enabled)
|
|
95
|
+
and updates in-memory state. Returns (previous_status,
|
|
96
|
+
previous_blocked_reason) for the session (both None if this is the
|
|
97
|
+
session's first event) so callers can decide whether a state
|
|
98
|
+
transition warrants a notification -- notification dedup keys on
|
|
99
|
+
the (status, blocked_reason) pair together, not status alone, so
|
|
100
|
+
both are needed."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
previous = self._sessions.get(event.session_id)
|
|
103
|
+
previous_status = previous.latest.status if previous else None
|
|
104
|
+
previous_blocked_reason = previous.latest.blocked_reason if previous else None
|
|
105
|
+
if self._log_path:
|
|
106
|
+
with open(self._log_path, "a", encoding="utf-8") as handle:
|
|
107
|
+
handle.write(json.dumps(event.to_dict()) + "\n")
|
|
108
|
+
try:
|
|
109
|
+
os.chmod(self._log_path, 0o600)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
self._apply_to_memory(event)
|
|
113
|
+
listeners = list(self._listeners)
|
|
114
|
+
for listener in listeners:
|
|
115
|
+
listener(event)
|
|
116
|
+
return previous_status, previous_blocked_reason
|
|
117
|
+
|
|
118
|
+
def get_session(self, session_id: str) -> Optional[SessionState]:
|
|
119
|
+
with self._lock:
|
|
120
|
+
return self._sessions.get(session_id)
|
|
121
|
+
|
|
122
|
+
def list_sessions(self) -> List[SessionState]:
|
|
123
|
+
"""All tracked sessions' latest state, sorted most-recently-updated first."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
sessions = list(self._sessions.values())
|
|
126
|
+
return sorted(sessions, key=lambda s: s.latest.timestamp, reverse=True)
|
|
127
|
+
|
|
128
|
+
def size(self) -> int:
|
|
129
|
+
with self._lock:
|
|
130
|
+
return len(self._sessions)
|
|
131
|
+
|
|
132
|
+
def add_listener(self, listener: Callable[[AgentEvent], None]) -> None:
|
|
133
|
+
with self._lock:
|
|
134
|
+
self._listeners.append(listener)
|
|
135
|
+
|
|
136
|
+
def remove_listener(self, listener: Callable[[AgentEvent], None]) -> None:
|
|
137
|
+
with self._lock:
|
|
138
|
+
if listener in self._listeners:
|
|
139
|
+
self._listeners.remove(listener)
|