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/config.py ADDED
@@ -0,0 +1,227 @@
1
+ """Configuration loading and saving for workstreams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ from .models import WorkstreamConfig, WorkstreamsConfig
11
+
12
+
13
+ def _yaml_load(text: str) -> Optional[Dict[str, Any]]:
14
+ try:
15
+ import yaml # type: ignore
16
+ except ImportError:
17
+ return None
18
+ try:
19
+ data = yaml.safe_load(text)
20
+ return data if isinstance(data, dict) else None
21
+ except Exception:
22
+ return None
23
+
24
+
25
+ def _yaml_dump(data: Dict[str, Any]) -> str:
26
+ try:
27
+ import yaml # type: ignore
28
+ except ImportError:
29
+ return None # type: ignore[return-value]
30
+ return yaml.dump(data, default_flow_style=False, sort_keys=False)
31
+
32
+
33
+ def config_path_in(base_path: Path) -> Path:
34
+ return base_path / ".workstreams.yaml"
35
+
36
+
37
+ def load_config(
38
+ base_path: Optional[Path] = None,
39
+ project: Optional[str] = None,
40
+ ) -> WorkstreamsConfig:
41
+ """Load workstreams configuration.
42
+
43
+ Resolution order:
44
+ 1. <base_path>/.workstreams.yaml (if it exists and matches --project when given)
45
+ 2. Environment variables (WORKSTREAMS_PROJECT / _MULTIPLEXER / _LAYOUT / _BASE_BRANCH / _MODE)
46
+ 3. Sensible defaults
47
+ """
48
+ base_path = (base_path or Path.cwd()).resolve()
49
+ config_file = config_path_in(base_path)
50
+ data: Dict[str, Any] = {}
51
+ if config_file.exists():
52
+ try:
53
+ text = config_file.read_text()
54
+ parsed = _yaml_load(text)
55
+ if parsed is not None:
56
+ data = parsed
57
+ else:
58
+ # Fallback parser for simple YAML (no pyyaml installed)
59
+ parsed = _simple_yaml_load(text)
60
+ if parsed is not None:
61
+ data = parsed
62
+ except OSError:
63
+ data = {}
64
+
65
+ workstreams = [
66
+ WorkstreamConfig.from_dict(ws) if isinstance(ws, dict) else ws
67
+ for ws in data.get("workstreams", [])
68
+ ]
69
+
70
+ project = project or data.get("project") or os.environ.get("WORKSTREAMS_PROJECT")
71
+ if not project:
72
+ project = base_path.name or "myproject"
73
+
74
+ return WorkstreamsConfig(
75
+ project=project,
76
+ multiplexer=data.get("multiplexer") or os.environ.get("WORKSTREAMS_MULTIPLEXER") or "tmux",
77
+ layout=data.get("layout") or os.environ.get("WORKSTREAMS_LAYOUT") or "even-horizontal",
78
+ base_branch=data.get("base_branch") or os.environ.get("WORKSTREAMS_BASE_BRANCH") or "main",
79
+ mode=data.get("mode") or os.environ.get("WORKSTREAMS_WORKTREE_MODE") or "worktree",
80
+ workstreams=workstreams,
81
+ shared_deps=data.get("shared_deps")
82
+ or (
83
+ [d for d in os.environ.get("WORKSTREAMS_SHARED_DEPS", "").split(",") if d]
84
+ if os.environ.get("WORKSTREAMS_SHARED_DEPS")
85
+ else []
86
+ ),
87
+ base_path=str(base_path),
88
+ agent=data.get("agent", "auto"),
89
+ )
90
+
91
+
92
+ def _simple_yaml_load(text: str) -> Optional[Dict[str, Any]]:
93
+ """Very small YAML subset parser: top-level scalars and lists of key: value maps.
94
+
95
+ Only used as a fallback when pyyaml is not installed. Supports the shape
96
+ that save_config produces. Not a general YAML parser.
97
+ """
98
+ result: Dict[str, Any] = {}
99
+ current: Optional[Dict[str, Any]] = None
100
+ in_workstreams = False
101
+ lines = text.splitlines()
102
+ i = 0
103
+ while i < len(lines):
104
+ line = lines[i]
105
+ stripped = line.strip()
106
+ i += 1
107
+ if not stripped or stripped.startswith("#"):
108
+ continue
109
+ if not line.startswith(" ") and stripped.startswith("workstreams:"):
110
+ in_workstreams = True
111
+ result["workstreams"] = []
112
+ continue
113
+ if not line.startswith(" "):
114
+ in_workstreams = False
115
+ key, _, value = stripped.partition(":")
116
+ key, value = key.strip(), value.strip()
117
+ if value:
118
+ if value.startswith("[") and value.endswith("]"):
119
+ inner = value[1:-1].strip()
120
+ result[key] = [v.strip().strip("'\"") for v in inner.split(",")] if inner else []
121
+ else:
122
+ result[key] = value.strip("'\"")
123
+ else:
124
+ result[key] = None
125
+ continue
126
+
127
+ if in_workstreams:
128
+ # New workstream entry: "- key: value"
129
+ if stripped.startswith("- "):
130
+ if current is not None:
131
+ result["workstreams"].append(current)
132
+ current = {}
133
+ item = stripped[2:]
134
+ if ":" in item:
135
+ k, _, v = item.partition(":")
136
+ current[k.strip()] = _parse_scalar(v.strip())
137
+ else:
138
+ current.setdefault("name", _parse_scalar(item))
139
+ continue
140
+ # Sub-keys inside a workstream entry
141
+ if current is not None and ":" in stripped:
142
+ k, _, v = stripped.partition(":")
143
+ k, v = k.strip(), v.strip()
144
+ if v:
145
+ current[k] = _parse_scalar(v)
146
+ else:
147
+ current[k] = {}
148
+ continue
149
+ # Indented list under a key (shared_deps style)
150
+ if stripped.startswith("- ") and current is not None:
151
+ pass
152
+ # Top-level list value continuation (shared_deps)
153
+ if not line.startswith(" ") is False and False:
154
+ pass
155
+ if current:
156
+ result.setdefault("workstreams", []).append(current)
157
+ # Convert "key:\n - a\n - b" style lists that follow a scalar None
158
+ for key, value in list(result.items()):
159
+ if value is None and key != "workstreams":
160
+ # Look ahead: already handled in-line; leave as None
161
+ pass
162
+ return result if result else None
163
+
164
+
165
+ def _parse_scalar(value: str) -> Any:
166
+ if value == "" or value is None:
167
+ return None
168
+ if value.startswith("[") and value.endswith("]"):
169
+ inner = value[1:-1].strip()
170
+ return [v.strip().strip("'\"") for v in inner.split(",")] if inner else []
171
+ if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')):
172
+ return value[1:-1]
173
+ if value.lower() in ("true", "false"):
174
+ return value.lower() == "true"
175
+ try:
176
+ return int(value)
177
+ except ValueError:
178
+ pass
179
+ try:
180
+ return float(value)
181
+ except ValueError:
182
+ pass
183
+ return value
184
+
185
+
186
+ def save_config(config: WorkstreamsConfig, base_path: Optional[Path] = None) -> Path:
187
+ """Save configuration to .workstreams.yaml (or .json fallback without pyyaml)."""
188
+ base_path = Path(base_path or config.base_path).resolve()
189
+ base_path.mkdir(parents=True, exist_ok=True)
190
+ config_file = config_path_in(base_path)
191
+
192
+ config_dict: Dict[str, Any] = {
193
+ "project": config.project,
194
+ "multiplexer": config.multiplexer,
195
+ "layout": config.layout,
196
+ "base_branch": config.base_branch,
197
+ "mode": config.mode,
198
+ "shared_deps": config.shared_deps,
199
+ "agent": config.agent,
200
+ "workstreams": [
201
+ {
202
+ "id": ws.id,
203
+ "name": ws.name,
204
+ "path": ws.path,
205
+ "branch": ws.branch,
206
+ "command": ws.command,
207
+ "env": ws.env,
208
+ }
209
+ for ws in config.workstreams
210
+ ],
211
+ }
212
+
213
+ dumped = _yaml_dump(config_dict)
214
+ if dumped is None:
215
+ config_file.with_suffix(".json").write_text(json.dumps(config_dict, indent=2) + "\n")
216
+ if config_file.exists():
217
+ config_file.unlink()
218
+ return config_file.with_suffix(".json")
219
+ config_file.write_text(dumped)
220
+ return config_file
221
+
222
+
223
+ def find_config_file(base_path: Path) -> Optional[Path]:
224
+ for candidate in (config_path_in(base_path), base_path / ".workstreams.json"):
225
+ if candidate.exists():
226
+ return candidate
227
+ return None
@@ -0,0 +1,341 @@
1
+ """Live monitoring dashboard.
2
+
3
+ Renders a full-screen TUI (curses-free, ANSI based) showing all
4
+ workstreams, their git status, running PIDs, log tails, alerts, and
5
+ the most recent subagent events. Ctrl+C or `q` exits.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import time
12
+ from datetime import datetime, UTC, timedelta
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from .models import WorkstreamStatus, SubagentEvent
17
+ from .event_log import get_event_log
18
+ from .notifier import Notifier
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from .manager import WorkstreamsManager
23
+
24
+ # ANSI helpers
25
+ def _c(code: str, text: str) -> str:
26
+ return f"\033[{code}m{text}\033[0m"
27
+
28
+ HEADER = "1;36"
29
+ DIM = "90"
30
+ GREEN = "32"
31
+ YELLOW = "33"
32
+ RED = "31"
33
+ CYAN = "36"
34
+ BLUE = "34"
35
+ MAGENTA = "35"
36
+ BOLD = "1"
37
+
38
+
39
+ class LiveDashboard:
40
+ """Terminal-based live monitoring dashboard."""
41
+
42
+ def __init__(self, manager: "WorkstreamsManager", auto_refresh: bool = True):
43
+ self.manager = manager
44
+ self.running = False
45
+ self.refresh_rate = 2.0
46
+ self.width = 100
47
+ self.auto_refresh = auto_refresh
48
+
49
+ # -- public API --------------------------------------------------------
50
+
51
+ def run(self) -> None:
52
+ """Run the dashboard until KeyboardInterrupt or 'q' key."""
53
+ self.running = True
54
+ self._detect_width()
55
+ try:
56
+ while self.running:
57
+ self._render()
58
+ if not self.auto_refresh:
59
+ break
60
+ # Wait for refresh interval, but break early on 'q'
61
+ try:
62
+ import select
63
+ ready, _, _ = select.select([sys.stdin], [], [], self.refresh_rate)
64
+ if ready:
65
+ ch = sys.stdin.read(1)
66
+ if ch in ("q", "Q", "\x03"):
67
+ break
68
+ except (ImportError, OSError):
69
+ time.sleep(self.refresh_rate)
70
+ except KeyboardInterrupt:
71
+ pass
72
+ finally:
73
+ # Show cursor, reset
74
+ print("\n\033[?25h\033[0m", end="")
75
+
76
+ def render_once(self) -> str:
77
+ """Render one frame and return it (for tests / `workstreams status --once`)."""
78
+ self._detect_width()
79
+ return self._build_frame()
80
+
81
+ # -- internals ---------------------------------------------------------
82
+
83
+ def _detect_width(self) -> None:
84
+ try:
85
+ import shutil
86
+ w = shutil.get_terminal_size().columns
87
+ self.width = max(60, min(w, 140))
88
+ except Exception:
89
+ self.width = 100
90
+
91
+ def _render(self) -> None:
92
+ frame = self._build_frame()
93
+ # Clear and print
94
+ sys.stdout.write("\033[2J\033[H\033[?25l")
95
+ sys.stdout.write(frame + "\n")
96
+ sys.stdout.flush()
97
+
98
+ def _build_frame(self) -> str:
99
+ now = datetime.now().strftime("%H:%M:%S")
100
+ project = self.manager.config.project
101
+ lines: List[str] = []
102
+ w = self.width
103
+
104
+ lines.append(_c(HEADER, "=" * w))
105
+ title = f" WORKSTREAMS MONITOR | {project} | {now} | q/Ctrl+C to exit"
106
+ lines.append(_c(HEADER, title))
107
+ lines.append(_c(HEADER, "=" * w))
108
+
109
+ statuses = self._get_all_statuses()
110
+ total = len(statuses)
111
+ active = sum(1 for s in statuses if s.pid is not None)
112
+ dirty = sum(1 for s in statuses if s.git_status == "dirty")
113
+ clean = sum(1 for s in statuses if s.git_status == "clean")
114
+ errors = sum(len(s.alerts) for s in statuses)
115
+
116
+ summary = (
117
+ f" Total: {total} | "
118
+ f"{_c(GREEN, f'Active: {active}')} | "
119
+ f"{_c(GREEN, f'Clean: {clean}')} "
120
+ f"{_c(YELLOW, f'Dirty: {dirty}')} | "
121
+ f"{_c(RED, f'Alerts: {errors}')}"
122
+ )
123
+ lines.append(summary)
124
+ lines.append(_c(DIM, "-" * w))
125
+
126
+ # Workstream table header
127
+ header = (
128
+ f" {'ID':>3} {'NAME':<16} {'BRANCH':<20} "
129
+ f"{'GIT':<8} {'PID':>8} {'LAST COMMIT / ACTIVITY'}"
130
+ )
131
+ lines.append(_c(BOLD, header))
132
+ lines.append(_c(DIM, "-" * w))
133
+
134
+ for s in statuses:
135
+ lines.append(self._workstream_row(s))
136
+
137
+ # Alerts
138
+ all_alerts: List[str] = []
139
+ for s in statuses:
140
+ for alert in s.alerts:
141
+ all_alerts.append(f"[{s.name}] {alert}")
142
+ if all_alerts:
143
+ lines.append("")
144
+ lines.append(_c(BOLD, _c(YELLOW, " RECENT ALERTS:")))
145
+ for alert in all_alerts[-5:]:
146
+ lines.append(f" {_c(YELLOW, '⚠')} {alert}")
147
+
148
+ # Subagent activity
149
+ events = self._get_recent_events()
150
+ if events:
151
+ lines.append("")
152
+ lines.append(_c(BOLD, _c(MAGENTA, " SUBAGENT ACTIVITY (last 10):")))
153
+ for event in events[-10:]:
154
+ color = self._event_color(event.event_type)
155
+ ts = event.timestamp[11:19] if event.timestamp else ""
156
+ lines.append(
157
+ f" {_c(color, ts)} "
158
+ f"{_c(CYAN, '[' + event.subagent + ']')}"
159
+ f" #{event.issue} {_c(color, event.event_type)}: "
160
+ f"{event.message[:60]}"
161
+ )
162
+
163
+ return "\n".join(lines)
164
+
165
+ def _workstream_row(self, s: WorkstreamStatus) -> str:
166
+ if s.git_status == "clean":
167
+ git_color = GREEN
168
+ git_text = "clean"
169
+ elif s.git_status == "dirty":
170
+ git_color = YELLOW
171
+ git_text = "dirty"
172
+ else:
173
+ git_color = RED
174
+ git_text = s.git_status or "unknown"
175
+
176
+ pid_text = str(s.pid) if s.pid else "-"
177
+ activity = s.last_commit or s.last_activity or "-"
178
+
179
+ return (
180
+ f" {s.id:>3} {_c(BOLD, s.name[:16].ljust(16))} {s.branch[:20].ljust(20)} "
181
+ f"{_c(git_color, git_text.ljust(8))} {pid_text:>8} {activity[:40]}"
182
+ )
183
+
184
+ # -- data gathering -----------------------------------------------------
185
+
186
+ def _get_all_statuses(self) -> List[WorkstreamStatus]:
187
+ return [self._get_workstream_status(ws) for ws in self.manager.config.workstreams]
188
+
189
+ def _get_workstream_status(self, ws) -> WorkstreamStatus:
190
+ ws_path = Path(self.manager.config.base_path) / ws.path
191
+ git_status = "unknown"
192
+ last_commit = ""
193
+ last_activity = ""
194
+ pid: Optional[int] = None
195
+ pane: Optional[str] = None
196
+ alerts: List[str] = []
197
+
198
+ # Resolve tmux pane if configured
199
+ try:
200
+ mux = self.manager._get_multiplexer()
201
+ if mux is not None:
202
+ pane = mux.pane_target(ws.id)
203
+ if getattr(mux, "is_running", lambda: False)():
204
+ pid = 1 # marker that session is alive
205
+ except Exception:
206
+ pass
207
+
208
+ if ws_path.exists():
209
+ git_status = self._git_status(ws_path)
210
+ last_commit = self._last_commit(ws_path)
211
+ last_activity = self._last_activity(ws_path)
212
+
213
+ # PID via tmux process check or pgrep
214
+ if pid is None:
215
+ pid = self._find_pid(ws_path, ws.name)
216
+
217
+ alerts = self._log_alerts(ws_path)
218
+
219
+ return WorkstreamStatus(
220
+ id=ws.id,
221
+ name=ws.name,
222
+ branch=ws.branch,
223
+ path=str(ws_path),
224
+ git_status=git_status,
225
+ last_commit=last_commit,
226
+ last_activity=last_activity,
227
+ pid=pid,
228
+ command=ws.command,
229
+ pane=pane,
230
+ alerts=alerts,
231
+ )
232
+
233
+ @staticmethod
234
+ def _git_status(ws_path: Path) -> str:
235
+ try:
236
+ result = subprocess.run(
237
+ ["git", "status", "--short"],
238
+ cwd=ws_path,
239
+ capture_output=True,
240
+ text=True,
241
+ timeout=10,
242
+ )
243
+ if result.returncode != 0:
244
+ return "not-git"
245
+ return "dirty" if result.stdout.strip() else "clean"
246
+ except (subprocess.SubprocessError, OSError):
247
+ return "unknown"
248
+
249
+ @staticmethod
250
+ def _last_commit(ws_path: Path) -> str:
251
+ try:
252
+ result = subprocess.run(
253
+ ["git", "log", "-1", "--format=%h %s"],
254
+ cwd=ws_path,
255
+ capture_output=True,
256
+ text=True,
257
+ timeout=10,
258
+ )
259
+ if result.returncode == 0:
260
+ return result.stdout.strip()[:50]
261
+ except (subprocess.SubprocessError, OSError):
262
+ pass
263
+ return ""
264
+
265
+ @staticmethod
266
+ def _last_activity(ws_path: Path) -> str:
267
+ log = ws_path / "logs" / "worker.log"
268
+ if not log.exists():
269
+ return ""
270
+ try:
271
+ mtime = log.stat().st_mtime
272
+ delta = datetime.now() - datetime.fromtimestamp(mtime)
273
+ secs = int(delta.total_seconds())
274
+ if secs < 60:
275
+ return f"{secs}s ago"
276
+ if secs < 3600:
277
+ return f"{secs // 60}m ago"
278
+ return f"{secs // 3600}h ago"
279
+ except OSError:
280
+ return ""
281
+
282
+ @staticmethod
283
+ def _find_pid(ws_path: Path, ws_name: str) -> Optional[int]:
284
+ """Find a PID for the workstream, cross-platform.
285
+
286
+ On POSIX: uses pgrep. On Windows: returns None (no built-in
287
+ process-name search without extra deps).
288
+ """
289
+ import platform
290
+ if platform.system() == "Windows":
291
+ return None
292
+ try:
293
+ result = subprocess.run(
294
+ ["pgrep", "-f", f"workstreams-{ws_name}"],
295
+ capture_output=True,
296
+ text=True,
297
+ timeout=5,
298
+ )
299
+ if result.returncode == 0 and result.stdout.strip():
300
+ return int(result.stdout.strip().split()[0])
301
+ except (OSError, ValueError, subprocess.SubprocessError):
302
+ pass
303
+ return None
304
+
305
+ @staticmethod
306
+ def _log_alerts(ws_path: Path) -> List[str]:
307
+ log = ws_path / "logs" / "worker.log"
308
+ if not log.exists():
309
+ return []
310
+ # Cross-platform: read file and scan in Python instead of grep
311
+ try:
312
+ with open(log, "r", encoding="utf-8", errors="replace") as f:
313
+ lines = f.readlines()
314
+ alerts = []
315
+ for i, ln in enumerate(lines, 1):
316
+ low = ln.lower()
317
+ if any(kw in low for kw in ("error", "fail", "warning", "traceback")):
318
+ alerts.append(f"{i}:{ln.rstrip()}")
319
+ return alerts[-3:]
320
+ except OSError:
321
+ return []
322
+
323
+ def _get_recent_events(self) -> List[SubagentEvent]:
324
+ event_log = get_event_log(self.manager.config.project)
325
+ since = datetime.now(UTC) - timedelta(minutes=10)
326
+ return event_log.get_events(since=since)
327
+
328
+ @staticmethod
329
+ def _event_color(event_type: str) -> str:
330
+ return {
331
+ "started": CYAN,
332
+ "progress": BLUE,
333
+ "completed": GREEN,
334
+ "done": GREEN,
335
+ "failed": RED,
336
+ "error": RED,
337
+ }.get(event_type, "37")
338
+
339
+ # -- module-level helpers ---------------------------------------------
340
+
341
+ import sys # noqa: E402 (used in run() for select loop)