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/manager.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Core Workstreams Manager - init, start, dispatch, sync, PR, merge, monitor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, UTC, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .config import load_config, save_config
|
|
14
|
+
from .models import WorkstreamConfig, WorkstreamsConfig
|
|
15
|
+
from .event_log import get_event_log, EventLog
|
|
16
|
+
from .notifier import Notifier
|
|
17
|
+
from .subagent_client import subagent_report, subagent_done, subagent_started
|
|
18
|
+
from .multiplexer import get_multiplexer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WorkstreamsManager:
|
|
22
|
+
"""Main manager for workstreams."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: WorkstreamsConfig):
|
|
25
|
+
self.config = config
|
|
26
|
+
self.base_path = Path(config.base_path).resolve()
|
|
27
|
+
self.config_file = self.base_path / ".workstreams.yaml"
|
|
28
|
+
self.event_log = get_event_log(config.project)
|
|
29
|
+
self.notifier = Notifier(config.project)
|
|
30
|
+
self._mux = None
|
|
31
|
+
|
|
32
|
+
# -- multiplexer ---------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _get_multiplexer(self):
|
|
35
|
+
if self._mux is None:
|
|
36
|
+
try:
|
|
37
|
+
self._mux = get_multiplexer(self.config.multiplexer, self.config)
|
|
38
|
+
except ValueError:
|
|
39
|
+
self._mux = None
|
|
40
|
+
return self._mux
|
|
41
|
+
|
|
42
|
+
# -- init ----------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def init_project(
|
|
45
|
+
self,
|
|
46
|
+
force: bool = False,
|
|
47
|
+
num_workstreams: int = 4,
|
|
48
|
+
agent: Optional[str] = None,
|
|
49
|
+
) -> bool:
|
|
50
|
+
"""Initialize workstreams for a project.
|
|
51
|
+
|
|
52
|
+
Creates git worktrees (or branches) + config file. Does NOT
|
|
53
|
+
create a multiplexer session - call start() for that.
|
|
54
|
+
"""
|
|
55
|
+
if self.config_file.exists() and not force:
|
|
56
|
+
print(
|
|
57
|
+
f"Configuration already exists at {self.config_file}. "
|
|
58
|
+
"Use --force to overwrite."
|
|
59
|
+
)
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
if not self.config.workstreams:
|
|
63
|
+
for i in range(1, num_workstreams + 1):
|
|
64
|
+
ws = WorkstreamConfig(
|
|
65
|
+
id=i,
|
|
66
|
+
name=f"ws{i}",
|
|
67
|
+
path=f"worktrees/ws{i}",
|
|
68
|
+
branch=f"ws/{i}",
|
|
69
|
+
command="",
|
|
70
|
+
env={},
|
|
71
|
+
)
|
|
72
|
+
self.config.workstreams.append(ws)
|
|
73
|
+
|
|
74
|
+
if agent:
|
|
75
|
+
self.config.agent = agent
|
|
76
|
+
|
|
77
|
+
for ws in self.config.workstreams:
|
|
78
|
+
self._create_workstream(ws)
|
|
79
|
+
|
|
80
|
+
save_config(self.config, self.base_path)
|
|
81
|
+
print(
|
|
82
|
+
f"Initialized {len(self.config.workstreams)} workstream(s) for "
|
|
83
|
+
f"project '{self.config.project}'"
|
|
84
|
+
)
|
|
85
|
+
print(f" Config: {self.config_file}")
|
|
86
|
+
print(" Next: workstreams start [--cmd 'claude']")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def _create_workstream(self, ws: WorkstreamConfig) -> None:
|
|
90
|
+
ws_path = self.base_path / ws.path
|
|
91
|
+
ws_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
if self.config.mode == "worktree":
|
|
94
|
+
try:
|
|
95
|
+
# Fast path: branch already exists
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["git", "worktree", "add", str(ws_path), ws.branch],
|
|
98
|
+
check=True,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
)
|
|
102
|
+
_ = result
|
|
103
|
+
created = True
|
|
104
|
+
except (subprocess.CalledProcessError, subprocess.SubprocessError):
|
|
105
|
+
created = False
|
|
106
|
+
if not created:
|
|
107
|
+
# Branch may not exist yet - create it from base branch
|
|
108
|
+
base = self.config.base_branch
|
|
109
|
+
probe = subprocess.run(["git", "rev-parse", "--verify", base], capture_output=True, text=True)
|
|
110
|
+
if probe.returncode != 0:
|
|
111
|
+
base = "HEAD"
|
|
112
|
+
print(f" (base branch '{self.config.base_branch}' not found; using HEAD)")
|
|
113
|
+
r2 = subprocess.run(
|
|
114
|
+
["git", "branch", ws.branch, base],
|
|
115
|
+
check=False,
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
)
|
|
119
|
+
if r2.returncode != 0 and "already" not in (r2.stderr or "").lower():
|
|
120
|
+
print(f" warning: could not create branch {ws.branch}: {(r2.stderr or '').strip()}")
|
|
121
|
+
r3 = subprocess.run(
|
|
122
|
+
["git", "worktree", "add", str(ws_path), ws.branch],
|
|
123
|
+
check=False,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
)
|
|
127
|
+
if r3.returncode != 0:
|
|
128
|
+
# Clean partial state and fail with a clear message
|
|
129
|
+
if ws_path.exists() and ws_path.is_dir():
|
|
130
|
+
shutil.rmtree(ws_path, ignore_errors=True)
|
|
131
|
+
print(f" error: git worktree add failed for {ws.branch}: {(r3.stderr or '').strip()}")
|
|
132
|
+
print(f" Run 'git worktree prune' and retry, or use --no-worktrees.")
|
|
133
|
+
return
|
|
134
|
+
else:
|
|
135
|
+
ws_path.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
(ws_path / "logs").mkdir(exist_ok=True)
|
|
138
|
+
|
|
139
|
+
# -- start / attach -------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def start(self, workstream_id: Optional[int] = None, command: Optional[str] = None) -> None:
|
|
142
|
+
"""Start workstreams in the configured multiplexer."""
|
|
143
|
+
workstreams = self._filter_ws(workstream_id)
|
|
144
|
+
mux = self._get_multiplexer()
|
|
145
|
+
if mux is None:
|
|
146
|
+
print(f"Unsupported multiplexer: {self.config.multiplexer}")
|
|
147
|
+
return
|
|
148
|
+
mux.start([ws.to_dict() for ws in workstreams], command)
|
|
149
|
+
|
|
150
|
+
def attach(self, multiplexer: Optional[str] = None, session: Optional[str] = None) -> None:
|
|
151
|
+
mux_name = multiplexer or self.config.multiplexer
|
|
152
|
+
sess = session or f"workstreams-{self.config.project}"
|
|
153
|
+
mux = get_multiplexer(mux_name, self.config)
|
|
154
|
+
mux.attach(sess)
|
|
155
|
+
|
|
156
|
+
# -- status / monitor ------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def status(self, workstream_id: Optional[int] = None, verbose: bool = False, live: bool = False, as_json: bool = False) -> None:
|
|
159
|
+
if live:
|
|
160
|
+
self.monitor(refresh=2, once=False)
|
|
161
|
+
return
|
|
162
|
+
statuses = []
|
|
163
|
+
for ws in self._filter_ws(workstream_id):
|
|
164
|
+
from .dashboard import LiveDashboard
|
|
165
|
+
dash = LiveDashboard(self, auto_refresh=False)
|
|
166
|
+
statuses.append(dash._get_workstream_status(ws))
|
|
167
|
+
|
|
168
|
+
if as_json:
|
|
169
|
+
print(json.dumps([s.to_dict() for s in statuses], indent=2, default=str))
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
for s in statuses:
|
|
173
|
+
print(f"\n=== Workstream {s.id}: {s.name} ===")
|
|
174
|
+
print(f" Path: {s.path}")
|
|
175
|
+
print(f" Branch: {s.branch}")
|
|
176
|
+
print(f" Git: {s.git_status}")
|
|
177
|
+
print(f" Last: {s.last_commit or s.last_activity or '-'}")
|
|
178
|
+
print(f" Pane: {s.pane or '-'}")
|
|
179
|
+
if s.pid:
|
|
180
|
+
print(f" PID: {s.pid}")
|
|
181
|
+
if s.alerts:
|
|
182
|
+
print(" Alerts:")
|
|
183
|
+
for a in s.alerts:
|
|
184
|
+
print(f" - {a}")
|
|
185
|
+
|
|
186
|
+
def monitor(self, refresh: int = 2, once: bool = False) -> None:
|
|
187
|
+
from .dashboard import LiveDashboard
|
|
188
|
+
dash = LiveDashboard(self, auto_refresh=not once)
|
|
189
|
+
dash.refresh_rate = float(refresh)
|
|
190
|
+
if once:
|
|
191
|
+
print(dash.render_once())
|
|
192
|
+
else:
|
|
193
|
+
dash.run()
|
|
194
|
+
|
|
195
|
+
# -- dispatch (the core workstream concept) --------------------------------
|
|
196
|
+
|
|
197
|
+
def dispatch(
|
|
198
|
+
self,
|
|
199
|
+
workstream_id: int,
|
|
200
|
+
subagent: str,
|
|
201
|
+
issue: int,
|
|
202
|
+
prompt: Optional[str] = None,
|
|
203
|
+
wait: bool = False,
|
|
204
|
+
) -> int:
|
|
205
|
+
"""Dispatch a subagent to a workstream pane.
|
|
206
|
+
|
|
207
|
+
Logs a `started` event, appends to the workstream log, notifies
|
|
208
|
+
other terminals, and (if the multiplexer session is running)
|
|
209
|
+
sends the prompt to the pane via tmux send-keys.
|
|
210
|
+
|
|
211
|
+
Returns exit code: 0 ok, 5 workstream not found, 4 mux unavailable.
|
|
212
|
+
"""
|
|
213
|
+
ws = self.config.workstream(workstream_id)
|
|
214
|
+
if not ws:
|
|
215
|
+
print(f"Workstream {workstream_id} not found")
|
|
216
|
+
return 5
|
|
217
|
+
|
|
218
|
+
prompt = prompt or f"Work on issue #{issue} as {subagent}"
|
|
219
|
+
|
|
220
|
+
subagent_started(self.config.project, workstream_id, subagent, issue, prompt)
|
|
221
|
+
|
|
222
|
+
self._append_workstream_log(ws, f"DISPATCH [{subagent}] issue #{issue}: {prompt}")
|
|
223
|
+
|
|
224
|
+
self.notifier.send(
|
|
225
|
+
f"Workstream {ws.name}",
|
|
226
|
+
f"{subagent} started on issue #{issue}",
|
|
227
|
+
)
|
|
228
|
+
print(f"Dispatched {subagent} -> workstream {ws.name} (issue #{issue})")
|
|
229
|
+
|
|
230
|
+
mux = self._get_multiplexer()
|
|
231
|
+
if mux is not None:
|
|
232
|
+
try:
|
|
233
|
+
mux.send_command(workstream_id, prompt)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
print(f" (warning) could not send to pane: {e}", file=sys.stderr)
|
|
236
|
+
|
|
237
|
+
if wait:
|
|
238
|
+
# Block until the subagent reports 'done' or 'completed'
|
|
239
|
+
return self._wait_for_done(workstream_id, subagent)
|
|
240
|
+
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
def _wait_for_done(self, workstream_id: int, subagent: str) -> int:
|
|
244
|
+
"""Poll the event log until this subagent reports done/completed/failed."""
|
|
245
|
+
print(f"Waiting for {subagent} on workstream {workstream_id} to finish...")
|
|
246
|
+
import time as _time
|
|
247
|
+
deadline = _time.time() + 4 * 3600 # 4h safety
|
|
248
|
+
last_seen = 0
|
|
249
|
+
while _time.time() < deadline:
|
|
250
|
+
events = self.event_log.get_events(workstream_id=workstream_id)
|
|
251
|
+
tail = [e for e in events if e.subagent == subagent]
|
|
252
|
+
if len(tail) > last_seen:
|
|
253
|
+
last_seen = len(tail)
|
|
254
|
+
for e in tail:
|
|
255
|
+
if e.event_type in ("completed", "failed", "done"):
|
|
256
|
+
label = {"completed": "completed", "failed": "FAILED", "done": "done"}[e.event_type]
|
|
257
|
+
print(f" {subagent} {label}: {e.message}")
|
|
258
|
+
return 0 if e.event_type != "failed" else 1
|
|
259
|
+
_time.sleep(3)
|
|
260
|
+
print(" (timeout) no terminal event received", file=sys.stderr)
|
|
261
|
+
return 2
|
|
262
|
+
|
|
263
|
+
# -- work (new: run agent command in pane + wait) ---------------------------
|
|
264
|
+
|
|
265
|
+
def work(
|
|
266
|
+
self,
|
|
267
|
+
workstream_id: int,
|
|
268
|
+
agent_cmd: str,
|
|
269
|
+
task: str,
|
|
270
|
+
subagent: str = "agent",
|
|
271
|
+
issue: int = 0,
|
|
272
|
+
wait: bool = False,
|
|
273
|
+
) -> int:
|
|
274
|
+
"""Run an agent command in a workstream pane.
|
|
275
|
+
|
|
276
|
+
This is the agent-agnostic entry point: the `agent_cmd` is anything
|
|
277
|
+
like `claude`, `codex`, `opencode run`, `qwen`, `cline`, ...
|
|
278
|
+
We build: `<agent_cmd> <task>` (plus optional flags via env) and
|
|
279
|
+
send it to the pane.
|
|
280
|
+
"""
|
|
281
|
+
ws = self.config.workstream(workstream_id)
|
|
282
|
+
if not ws:
|
|
283
|
+
print(f"Workstream {workstream_id} not found")
|
|
284
|
+
return 5
|
|
285
|
+
|
|
286
|
+
command = f"{agent_cmd} {task}"
|
|
287
|
+
self._append_workstream_log(ws, f"WORK [{subagent}] {command}")
|
|
288
|
+
|
|
289
|
+
if wait:
|
|
290
|
+
subagent_started(self.config.project, workstream_id, subagent, issue, command)
|
|
291
|
+
|
|
292
|
+
mux = self._get_multiplexer()
|
|
293
|
+
if mux is not None:
|
|
294
|
+
try:
|
|
295
|
+
mux.send_command(workstream_id, command)
|
|
296
|
+
print(f"Sent to workstream {ws.name}: {command}")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
print(f"(warning) pane send failed: {e}", file=sys.stderr)
|
|
299
|
+
else:
|
|
300
|
+
print(f"(no multiplexer session - ran command in shell instead)")
|
|
301
|
+
result = subprocess.run(command, shell=True, cwd=self.base_path / ws.path)
|
|
302
|
+
rc = result.returncode
|
|
303
|
+
|
|
304
|
+
if wait:
|
|
305
|
+
return self._wait_for_done(workstream_id, subagent)
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
# -- assign ----------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def assign(self, workstream_id: int, issue_numbers: List[int]) -> None:
|
|
311
|
+
ws = self.config.workstream(workstream_id)
|
|
312
|
+
if not ws:
|
|
313
|
+
print(f"Workstream {workstream_id} not found")
|
|
314
|
+
return
|
|
315
|
+
self._append_workstream_log(ws, f"ASSIGN issues {issue_numbers}")
|
|
316
|
+
print(f"Assigned issues {issue_numbers} to workstream {ws.name}")
|
|
317
|
+
|
|
318
|
+
# -- sync -------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def sync(self, workstream_id: Optional[int] = None, rebase: bool = False) -> int:
|
|
321
|
+
rc = 0
|
|
322
|
+
for ws in self._filter_ws(workstream_id):
|
|
323
|
+
ws_path = self.base_path / ws.path
|
|
324
|
+
if not ws_path.exists():
|
|
325
|
+
print(f" {ws.name}: path missing, skipping")
|
|
326
|
+
rc = 3
|
|
327
|
+
continue
|
|
328
|
+
print(f"Syncing {ws.name}...")
|
|
329
|
+
for cmd in (
|
|
330
|
+
["git", "fetch", "origin"],
|
|
331
|
+
["git", "rebase", f"origin/{self.config.base_branch}"] if rebase
|
|
332
|
+
else ["git", "merge", f"origin/{self.config.base_branch}"],
|
|
333
|
+
):
|
|
334
|
+
result = subprocess.run(cmd, cwd=ws_path, capture_output=True, text=True)
|
|
335
|
+
if result.returncode != 0:
|
|
336
|
+
print(f" failed: {result.stderr.strip() or result.stdout.strip()}")
|
|
337
|
+
rc = 3
|
|
338
|
+
break
|
|
339
|
+
else:
|
|
340
|
+
print(f" Synced {ws.name}")
|
|
341
|
+
return rc
|
|
342
|
+
|
|
343
|
+
# -- PR -------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def pr(
|
|
346
|
+
self,
|
|
347
|
+
workstream_id: int,
|
|
348
|
+
title: str,
|
|
349
|
+
body: str = "",
|
|
350
|
+
base: Optional[str] = None,
|
|
351
|
+
draft: bool = False,
|
|
352
|
+
) -> int:
|
|
353
|
+
ws = self.config.workstream(workstream_id)
|
|
354
|
+
if not ws:
|
|
355
|
+
print(f"Workstream {workstream_id} not found")
|
|
356
|
+
return 5
|
|
357
|
+
base = base or self.config.base_branch
|
|
358
|
+
|
|
359
|
+
ws_path = self.base_path / ws.path
|
|
360
|
+
result = subprocess.run(
|
|
361
|
+
["git", "push", "origin", ws.branch],
|
|
362
|
+
cwd=ws_path,
|
|
363
|
+
capture_output=True,
|
|
364
|
+
text=True,
|
|
365
|
+
)
|
|
366
|
+
if result.returncode != 0:
|
|
367
|
+
print(f"Push failed: {result.stderr.strip()}")
|
|
368
|
+
return 3
|
|
369
|
+
|
|
370
|
+
cmd = [
|
|
371
|
+
"gh", "pr", "create",
|
|
372
|
+
"--title", title,
|
|
373
|
+
"--body", body,
|
|
374
|
+
"--base", base,
|
|
375
|
+
"--head", ws.branch,
|
|
376
|
+
]
|
|
377
|
+
if draft:
|
|
378
|
+
cmd.append("--draft")
|
|
379
|
+
result = subprocess.run(cmd, cwd=ws_path, capture_output=True, text=True)
|
|
380
|
+
if result.returncode == 0:
|
|
381
|
+
print(f"Created PR: {result.stdout.strip()}")
|
|
382
|
+
else:
|
|
383
|
+
print(f"Failed to create PR: {result.stderr.strip()}")
|
|
384
|
+
return 0 if result.returncode == 0 else 3
|
|
385
|
+
|
|
386
|
+
# -- merge ------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def merge(
|
|
389
|
+
self,
|
|
390
|
+
workstream_id: int,
|
|
391
|
+
method: str = "squash",
|
|
392
|
+
delete_branch: bool = False,
|
|
393
|
+
auto: bool = False,
|
|
394
|
+
) -> int:
|
|
395
|
+
ws = self.config.workstream(workstream_id)
|
|
396
|
+
if not ws:
|
|
397
|
+
print(f"Workstream {workstream_id} not found")
|
|
398
|
+
return 5
|
|
399
|
+
|
|
400
|
+
cmd = ["gh", "pr", "merge", ws.branch, "--method", method]
|
|
401
|
+
if delete_branch:
|
|
402
|
+
cmd.append("--delete-branch")
|
|
403
|
+
if auto:
|
|
404
|
+
cmd.append("--auto")
|
|
405
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
406
|
+
if result.returncode == 0:
|
|
407
|
+
print(f"Merged {ws.name} ({method})")
|
|
408
|
+
if delete_branch:
|
|
409
|
+
ws_path = self.base_path / ws.path
|
|
410
|
+
subprocess.run(["git", "worktree", "remove", str(ws_path)], check=False, capture_output=True)
|
|
411
|
+
if ws_path.exists():
|
|
412
|
+
shutil.rmtree(ws_path, ignore_errors=True)
|
|
413
|
+
else:
|
|
414
|
+
print(f"Failed to merge: {result.stderr.strip()}")
|
|
415
|
+
return 0 if result.returncode == 0 else 3
|
|
416
|
+
|
|
417
|
+
# -- run -------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def run(self, workstream_id: int, command: str) -> bool:
|
|
420
|
+
ws = self.config.workstream(workstream_id)
|
|
421
|
+
if not ws:
|
|
422
|
+
print(f"Workstream {workstream_id} not found")
|
|
423
|
+
return False
|
|
424
|
+
ws_path = self.base_path / ws.path
|
|
425
|
+
result = subprocess.run(command, shell=True, cwd=ws_path, capture_output=True, text=True)
|
|
426
|
+
if result.stdout:
|
|
427
|
+
print(result.stdout, end="")
|
|
428
|
+
if result.stderr:
|
|
429
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
430
|
+
self._append_workstream_log(ws, f"RUN: {command} -> exit {result.returncode}")
|
|
431
|
+
return result.returncode == 0
|
|
432
|
+
|
|
433
|
+
# -- logs ------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
def logs(self, workstream_id: int, follow: bool = False, lines: int = 100) -> None:
|
|
436
|
+
ws = self.config.workstream(workstream_id)
|
|
437
|
+
if not ws:
|
|
438
|
+
print(f"Workstream {workstream_id} not found")
|
|
439
|
+
return
|
|
440
|
+
ws_path = self.base_path / ws.path
|
|
441
|
+
log_file = ws_path / "logs" / "worker.log"
|
|
442
|
+
if not log_file.exists():
|
|
443
|
+
print(f"No log file at {log_file}")
|
|
444
|
+
return
|
|
445
|
+
# Use Python for cross-platform (tail -f equivalent on Windows)
|
|
446
|
+
if follow:
|
|
447
|
+
import time as _time
|
|
448
|
+
try:
|
|
449
|
+
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
|
450
|
+
# Seek to end, then poll
|
|
451
|
+
f.seek(0, 2)
|
|
452
|
+
while True:
|
|
453
|
+
line = f.readline()
|
|
454
|
+
if line:
|
|
455
|
+
print(line, end="")
|
|
456
|
+
else:
|
|
457
|
+
_time.sleep(0.5)
|
|
458
|
+
except KeyboardInterrupt:
|
|
459
|
+
pass
|
|
460
|
+
else:
|
|
461
|
+
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
|
462
|
+
all_lines = f.readlines()
|
|
463
|
+
for ln in all_lines[-lines:]:
|
|
464
|
+
print(ln, end="")
|
|
465
|
+
|
|
466
|
+
def tail_logs(self, workstream_id: Optional[int] = None, follow: bool = True) -> None:
|
|
467
|
+
ws_list = self._filter_ws(workstream_id)
|
|
468
|
+
if len(ws_list) == 1:
|
|
469
|
+
self.logs(ws_list[0].id, follow=follow)
|
|
470
|
+
return
|
|
471
|
+
print("Tailing logs from all workstreams (Ctrl+C to stop):")
|
|
472
|
+
for ws in ws_list:
|
|
473
|
+
print(f"\n--- {ws.name} ({self.base_path / ws.path}) ---")
|
|
474
|
+
self.logs(ws.id, follow=False, lines=20)
|
|
475
|
+
|
|
476
|
+
# -- events / notify -------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
def get_events(
|
|
479
|
+
self,
|
|
480
|
+
workstream_id: Optional[int] = None,
|
|
481
|
+
since_minutes: int = 10,
|
|
482
|
+
event_type: Optional[str] = None,
|
|
483
|
+
subagent: Optional[str] = None,
|
|
484
|
+
limit: Optional[int] = None,
|
|
485
|
+
) -> List[Dict[str, Any]]:
|
|
486
|
+
since = datetime.now(UTC) - timedelta(minutes=since_minutes)
|
|
487
|
+
events = self.event_log.get_events(
|
|
488
|
+
workstream_id=workstream_id,
|
|
489
|
+
since=since,
|
|
490
|
+
event_type=event_type,
|
|
491
|
+
subagent=subagent,
|
|
492
|
+
limit=limit,
|
|
493
|
+
)
|
|
494
|
+
return [e.to_dict() for e in events]
|
|
495
|
+
|
|
496
|
+
def notify(self, title: str, message: str, urgency: str = "normal") -> None:
|
|
497
|
+
self.notifier.send(title, message, urgency)
|
|
498
|
+
|
|
499
|
+
# -- workstream management ---------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def add_workstream(self, name: str, branch: str, path: Optional[str] = None, command: str = "") -> WorkstreamConfig:
|
|
502
|
+
existing = len(self.config.workstreams)
|
|
503
|
+
new_id = max((ws.id for ws in self.config.workstreams), default=0) + 1
|
|
504
|
+
ws = WorkstreamConfig(
|
|
505
|
+
id=new_id,
|
|
506
|
+
name=name,
|
|
507
|
+
path=path or f"worktrees/{name}",
|
|
508
|
+
branch=branch,
|
|
509
|
+
command=command,
|
|
510
|
+
env={},
|
|
511
|
+
)
|
|
512
|
+
self.config.workstreams.append(ws)
|
|
513
|
+
self._create_workstream(ws)
|
|
514
|
+
save_config(self.config, self.base_path)
|
|
515
|
+
print(f"Added workstream {new_id} ({name}) -> {ws.path} on {branch}")
|
|
516
|
+
_ = existing
|
|
517
|
+
return ws
|
|
518
|
+
|
|
519
|
+
def remove_workstream(self, workstream_id: int, force: bool = False) -> int:
|
|
520
|
+
ws = self.config.workstream(workstream_id)
|
|
521
|
+
if not ws:
|
|
522
|
+
print(f"Workstream {workstream_id} not found")
|
|
523
|
+
return 5
|
|
524
|
+
ws_path = self.base_path / ws.path
|
|
525
|
+
if ws_path.exists() and ws_path.is_dir():
|
|
526
|
+
if not force:
|
|
527
|
+
print(f"Refusing to remove {ws_path} without --force (workstream may have uncommitted work)")
|
|
528
|
+
return 1
|
|
529
|
+
# Remove worktree (best effort)
|
|
530
|
+
subprocess.run(["git", "worktree", "remove", str(ws_path)], check=False, capture_output=True)
|
|
531
|
+
shutil.rmtree(ws_path, ignore_errors=True)
|
|
532
|
+
self.config.workstreams = [x for x in self.config.workstreams if x.id != workstream_id]
|
|
533
|
+
save_config(self.config, self.base_path)
|
|
534
|
+
print(f"Removed workstream {workstream_id} ({ws.name})")
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
def cleanup(self, workstream_id: Optional[int] = None, force: bool = False, all_done: bool = False) -> None:
|
|
538
|
+
"""Remove worktrees for completed (or all) workstreams."""
|
|
539
|
+
targets = self._filter_ws(workstream_id) if not all_done else self._filter_ws(workstream_id)
|
|
540
|
+
if all_done:
|
|
541
|
+
targets = self._filter_ws(None)
|
|
542
|
+
for ws in targets:
|
|
543
|
+
ws_path = self.base_path / ws.path
|
|
544
|
+
if not ws_path.exists():
|
|
545
|
+
continue
|
|
546
|
+
if not force and not workstream_id and not all_done:
|
|
547
|
+
print(f"Skipping {ws.name} (use --force or --all)")
|
|
548
|
+
continue
|
|
549
|
+
subprocess.run(["git", "worktree", "remove", str(ws_path)], check=False, capture_output=True)
|
|
550
|
+
shutil.rmtree(ws_path, ignore_errors=True)
|
|
551
|
+
print(f"Cleaned up {ws.name}")
|
|
552
|
+
|
|
553
|
+
# -- helpers -----------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
def _filter_ws(self, workstream_id: Optional[int]) -> List[WorkstreamConfig]:
|
|
556
|
+
if workstream_id is None:
|
|
557
|
+
return list(self.config.workstreams)
|
|
558
|
+
return [ws for ws in self.config.workstreams if ws.id == workstream_id]
|
|
559
|
+
|
|
560
|
+
def _append_workstream_log(self, ws: WorkstreamConfig, line: str) -> None:
|
|
561
|
+
ws_path = self.base_path / ws.path
|
|
562
|
+
log_file = ws_path / "logs" / "worker.log"
|
|
563
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
564
|
+
ts = datetime.now(UTC).isoformat()
|
|
565
|
+
try:
|
|
566
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
567
|
+
f.write(f"[{ts}] {line}\n")
|
|
568
|
+
except OSError:
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def now() -> str:
|
|
573
|
+
return datetime.now(UTC).isoformat()
|
workstreams/models.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Data models for workstreams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, asdict
|
|
6
|
+
from datetime import datetime, UTC
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkstreamConfig:
|
|
12
|
+
"""Configuration for a single workstream."""
|
|
13
|
+
|
|
14
|
+
id: int
|
|
15
|
+
name: str
|
|
16
|
+
path: str
|
|
17
|
+
branch: str
|
|
18
|
+
command: str
|
|
19
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
20
|
+
multiplexer: str = "tmux"
|
|
21
|
+
layout: str = "even-horizontal"
|
|
22
|
+
shared_deps: List[str] = field(default_factory=list)
|
|
23
|
+
mode: str = "worktree" # worktree|branch
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: Dict[str, Any]) -> "WorkstreamConfig":
|
|
30
|
+
known = {
|
|
31
|
+
"id", "name", "path", "branch", "command", "env",
|
|
32
|
+
"multiplexer", "layout", "shared_deps", "mode",
|
|
33
|
+
}
|
|
34
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WorkstreamsConfig:
|
|
39
|
+
"""Project-level configuration."""
|
|
40
|
+
|
|
41
|
+
project: str
|
|
42
|
+
multiplexer: str = "tmux"
|
|
43
|
+
layout: str = "even-horizontal"
|
|
44
|
+
base_branch: str = "main"
|
|
45
|
+
mode: str = "worktree" # worktree|branch
|
|
46
|
+
workstreams: List[WorkstreamConfig] = field(default_factory=list)
|
|
47
|
+
shared_deps: List[str] = field(default_factory=list)
|
|
48
|
+
base_path: str = "."
|
|
49
|
+
agent: str = "auto" # auto|claude|codex|opencode|qwen|generic
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
d = asdict(self)
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: Dict[str, Any]) -> "WorkstreamsConfig":
|
|
57
|
+
known = {
|
|
58
|
+
"project", "multiplexer", "layout", "base_branch", "mode",
|
|
59
|
+
"shared_deps", "base_path", "agent",
|
|
60
|
+
}
|
|
61
|
+
workstreams = [
|
|
62
|
+
WorkstreamConfig.from_dict(ws) for ws in data.get("workstreams", [])
|
|
63
|
+
]
|
|
64
|
+
base = {k: v for k, v in data.items() if k in known and k != "workstreams"}
|
|
65
|
+
base["workstreams"] = workstreams
|
|
66
|
+
return cls(**base)
|
|
67
|
+
|
|
68
|
+
def workstream(self, ws_id: int) -> Optional[WorkstreamConfig]:
|
|
69
|
+
for ws in self.workstreams:
|
|
70
|
+
if ws.id == ws_id:
|
|
71
|
+
return ws
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class WorkstreamStatus:
|
|
77
|
+
"""Real-time status of a workstream."""
|
|
78
|
+
|
|
79
|
+
id: int
|
|
80
|
+
name: str
|
|
81
|
+
branch: str
|
|
82
|
+
path: str
|
|
83
|
+
git_status: str = "unknown"
|
|
84
|
+
last_commit: str = ""
|
|
85
|
+
last_activity: str = ""
|
|
86
|
+
pid: Optional[int] = None
|
|
87
|
+
command: str = ""
|
|
88
|
+
pane: Optional[str] = None
|
|
89
|
+
log_tail: List[str] = field(default_factory=list)
|
|
90
|
+
alerts: List[str] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
93
|
+
return asdict(self)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class SubagentEvent:
|
|
98
|
+
"""Event from a subagent (or any external process)."""
|
|
99
|
+
|
|
100
|
+
workstream_id: int
|
|
101
|
+
subagent: str
|
|
102
|
+
issue: int
|
|
103
|
+
event_type: str # started, progress, completed, failed, error, done
|
|
104
|
+
message: str
|
|
105
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
106
|
+
data: Dict[str, Any] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
109
|
+
return asdict(self)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: Dict[str, Any]) -> "SubagentEvent":
|
|
113
|
+
return cls(
|
|
114
|
+
workstream_id=data.get("workstream_id", 0),
|
|
115
|
+
subagent=data.get("subagent", "unknown"),
|
|
116
|
+
issue=data.get("issue", 0),
|
|
117
|
+
event_type=data.get("event_type", "progress"),
|
|
118
|
+
message=data.get("message", ""),
|
|
119
|
+
timestamp=data.get("timestamp", datetime.now(UTC).isoformat()),
|
|
120
|
+
data=data.get("data", {}) or {},
|
|
121
|
+
)
|