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.
@@ -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]