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/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
|
workstreams/dashboard.py
ADDED
|
@@ -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)
|