workstreams-cli 0.5.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.
- workstreams/__init__.py +71 -0
- workstreams/cli.py +499 -0
- workstreams/config.py +227 -0
- workstreams/dashboard.py +341 -0
- workstreams/event_log.py +240 -0
- workstreams/manager.py +573 -0
- workstreams/models.py +121 -0
- workstreams/multiplexer/__init__.py +32 -0
- workstreams/multiplexer/base.py +44 -0
- workstreams/multiplexer/tmux.py +202 -0
- workstreams/multiplexer/tmux_compatible.py +22 -0
- workstreams/multiplexer/zellij.py +109 -0
- workstreams/notifier.py +147 -0
- workstreams/py.typed +0 -0
- workstreams/subagent_client.py +125 -0
- workstreams_cli-0.5.0.dist-info/METADATA +622 -0
- workstreams_cli-0.5.0.dist-info/RECORD +20 -0
- workstreams_cli-0.5.0.dist-info/WHEEL +5 -0
- workstreams_cli-0.5.0.dist-info/entry_points.txt +2 -0
- workstreams_cli-0.5.0.dist-info/top_level.txt +1 -0
workstreams/event_log.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""File-based event log for cross-process subagent communication.
|
|
2
|
+
|
|
3
|
+
Events live in ~/.workstreams/<project>/events.jsonl. Multiple processes
|
|
4
|
+
(main agent, dispatched subagents in tmux panes, external scripts) append
|
|
5
|
+
concurrently, so writes use O_APPEND (atomic on POSIX for small writes)
|
|
6
|
+
plus an optional lock file for large payloads.
|
|
7
|
+
|
|
8
|
+
Large logs are rotated: when events.jsonl exceeds max_bytes, it is
|
|
9
|
+
renamed to events-<timestamp>.jsonl and a fresh file is started. Old
|
|
10
|
+
rotated files are kept until max_files is exceeded.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import glob
|
|
19
|
+
import re
|
|
20
|
+
from datetime import datetime, UTC
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
from .models import SubagentEvent
|
|
25
|
+
|
|
26
|
+
DEFAULT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
27
|
+
DEFAULT_MAX_FILES = 10
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_log_dir(project: str) -> Path:
|
|
31
|
+
override = os.environ.get("WORKSTREAMS_DATA_DIR")
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override) / project
|
|
34
|
+
return Path.home() / ".workstreams" / project
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rotate_file(filepath: Path, max_bytes: int, max_files: int) -> None:
|
|
38
|
+
"""Rotate a log file if it exceeds max_bytes.
|
|
39
|
+
|
|
40
|
+
Reads the current file, moves it to events-<timestamp>.jsonl, then
|
|
41
|
+
writes nothing back (a fresh empty file is created on next append).
|
|
42
|
+
Rotated files are pruned to keep only the newest max_files.
|
|
43
|
+
"""
|
|
44
|
+
if not filepath.exists():
|
|
45
|
+
return
|
|
46
|
+
try:
|
|
47
|
+
size = filepath.stat().st_size
|
|
48
|
+
except OSError:
|
|
49
|
+
return
|
|
50
|
+
if size <= max_bytes:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
ts = datetime.now(UTC).strftime("%Y%m%d%H%M%S%f")
|
|
54
|
+
rotated = filepath.with_name(f"{filepath.stem}-{ts}.jsonl")
|
|
55
|
+
try:
|
|
56
|
+
os.replace(filepath, rotated)
|
|
57
|
+
except OSError:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Prune old rotated files (oldest removed first, keep newest max_files)
|
|
61
|
+
pattern = str(filepath.with_name(f"{filepath.stem}-*.jsonl"))
|
|
62
|
+
old_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=False)
|
|
63
|
+
while len(old_files) > max_files:
|
|
64
|
+
oldest = old_files.pop(0)
|
|
65
|
+
try:
|
|
66
|
+
os.remove(oldest)
|
|
67
|
+
except OSError:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EventLog:
|
|
72
|
+
"""Append-only JSONL event log shared across processes."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
project: str,
|
|
77
|
+
log_dir: Optional[Path] = None,
|
|
78
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
79
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
80
|
+
):
|
|
81
|
+
self.project = project
|
|
82
|
+
self.log_dir = Path(log_dir) if log_dir else default_log_dir(project)
|
|
83
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
self.events_file = self.log_dir / "events.jsonl"
|
|
85
|
+
self.lock_file = self.log_dir / "events.lock"
|
|
86
|
+
self.max_bytes = max_bytes
|
|
87
|
+
self.max_files = max_files
|
|
88
|
+
|
|
89
|
+
# -- locking ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _acquire_lock(self, timeout: float = 2.0) -> bool:
|
|
92
|
+
start = time.time()
|
|
93
|
+
while time.time() - start < timeout:
|
|
94
|
+
try:
|
|
95
|
+
fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
96
|
+
os.close(fd)
|
|
97
|
+
return True
|
|
98
|
+
except FileExistsError:
|
|
99
|
+
# If the lock is older than 10s, consider it stale and steal it
|
|
100
|
+
try:
|
|
101
|
+
age = time.time() - self.lock_file.stat().st_mtime
|
|
102
|
+
if age > 10:
|
|
103
|
+
self.lock_file.unlink(missing_ok=True)
|
|
104
|
+
continue
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
time.sleep(0.01)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def _release_lock(self) -> None:
|
|
111
|
+
try:
|
|
112
|
+
self.lock_file.unlink()
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# -- append / read ----------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def append(self, event: SubagentEvent) -> bool:
|
|
119
|
+
"""Append an event. Returns True on success."""
|
|
120
|
+
line = json.dumps(event.to_dict()) + "\n"
|
|
121
|
+
acquired = self._acquire_lock()
|
|
122
|
+
try:
|
|
123
|
+
# O_APPEND makes concurrent small writes atomic on POSIX;
|
|
124
|
+
# the lock additionally guards against torn writes of large lines.
|
|
125
|
+
fd = os.open(str(self.events_file), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
126
|
+
try:
|
|
127
|
+
os.write(fd, line.encode("utf-8"))
|
|
128
|
+
finally:
|
|
129
|
+
os.close(fd)
|
|
130
|
+
# Rotate after append (only when lock is held to avoid races)
|
|
131
|
+
if acquired:
|
|
132
|
+
_rotate_file(self.events_file, self.max_bytes, self.max_files)
|
|
133
|
+
return True
|
|
134
|
+
except OSError:
|
|
135
|
+
return False
|
|
136
|
+
finally:
|
|
137
|
+
if acquired:
|
|
138
|
+
self._release_lock()
|
|
139
|
+
|
|
140
|
+
def get_events(
|
|
141
|
+
self,
|
|
142
|
+
workstream_id: Optional[int] = None,
|
|
143
|
+
since: Optional[datetime] = None,
|
|
144
|
+
event_type: Optional[str] = None,
|
|
145
|
+
subagent: Optional[str] = None,
|
|
146
|
+
limit: Optional[int] = None,
|
|
147
|
+
) -> List[SubagentEvent]:
|
|
148
|
+
"""Read filtered events from all rotated + current files (newest last)."""
|
|
149
|
+
# Collect all event files: current + rotated, oldest first
|
|
150
|
+
pattern = str(self.log_dir / "events*.jsonl")
|
|
151
|
+
all_files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=False)
|
|
152
|
+
|
|
153
|
+
events: List[SubagentEvent] = []
|
|
154
|
+
for fpath in all_files:
|
|
155
|
+
if not os.path.exists(fpath):
|
|
156
|
+
continue
|
|
157
|
+
with open(fpath, "r", encoding="utf-8") as f:
|
|
158
|
+
for line in f:
|
|
159
|
+
line = line.strip()
|
|
160
|
+
if not line:
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(line)
|
|
164
|
+
event = SubagentEvent.from_dict(data)
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
if workstream_id is not None and event.workstream_id != workstream_id:
|
|
168
|
+
continue
|
|
169
|
+
if subagent is not None and event.subagent != subagent:
|
|
170
|
+
continue
|
|
171
|
+
if event_type is not None and event.event_type != event_type:
|
|
172
|
+
continue
|
|
173
|
+
if since is not None:
|
|
174
|
+
try:
|
|
175
|
+
ts = datetime.fromisoformat(event.timestamp)
|
|
176
|
+
if ts.tzinfo is None:
|
|
177
|
+
ts = ts.replace(tzinfo=UTC)
|
|
178
|
+
if ts < since:
|
|
179
|
+
continue
|
|
180
|
+
except ValueError:
|
|
181
|
+
pass
|
|
182
|
+
events.append(event)
|
|
183
|
+
if limit is not None:
|
|
184
|
+
events = events[-limit:]
|
|
185
|
+
return events
|
|
186
|
+
|
|
187
|
+
def tail(self, lines: int = 20) -> List[SubagentEvent]:
|
|
188
|
+
"""Return the last N events from the current file only."""
|
|
189
|
+
if not self.events_file.exists():
|
|
190
|
+
return []
|
|
191
|
+
all_events: List[SubagentEvent] = []
|
|
192
|
+
with open(self.events_file, "r", encoding="utf-8") as f:
|
|
193
|
+
f.seek(0, os.SEEK_END)
|
|
194
|
+
size = f.tell()
|
|
195
|
+
chunk_start = max(0, size - 256 * 1024)
|
|
196
|
+
f.seek(chunk_start)
|
|
197
|
+
data = f.read()
|
|
198
|
+
for line in data.splitlines():
|
|
199
|
+
line = line.strip()
|
|
200
|
+
if not line:
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
all_events.append(SubagentEvent.from_dict(json.loads(line)))
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
return all_events[-lines:]
|
|
207
|
+
|
|
208
|
+
def stats(self) -> Dict[str, Any]:
|
|
209
|
+
"""Quick counters for the dashboard summary bar."""
|
|
210
|
+
events = self.get_events()
|
|
211
|
+
counts: Dict[str, int] = {}
|
|
212
|
+
for e in events:
|
|
213
|
+
counts[e.event_type] = counts.get(e.event_type, 0) + 1
|
|
214
|
+
return {"total": len(events), "by_type": counts}
|
|
215
|
+
|
|
216
|
+
def clear(self) -> None:
|
|
217
|
+
if self.events_file.exists():
|
|
218
|
+
self.events_file.unlink()
|
|
219
|
+
# Also clear rotated files
|
|
220
|
+
pattern = str(self.log_dir / "events-*.jsonl")
|
|
221
|
+
for fpath in glob.glob(pattern):
|
|
222
|
+
try:
|
|
223
|
+
os.remove(fpath)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
def rotated_files(self) -> List[Path]:
|
|
228
|
+
"""List rotated event files (oldest first)."""
|
|
229
|
+
pattern = str(self.log_dir / "events-*.jsonl")
|
|
230
|
+
return [Path(f) for f in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=False)]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_log_cache: Dict[str, EventLog] = {}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_event_log(project: str) -> EventLog:
|
|
237
|
+
"""Get (or create) a cached EventLog for the project."""
|
|
238
|
+
if project not in _log_cache:
|
|
239
|
+
_log_cache[project] = EventLog(project)
|
|
240
|
+
return _log_cache[project]
|