modastack 0.2.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.
Files changed (47) hide show
  1. dashboard/__init__.py +0 -0
  2. dashboard/app.py +219 -0
  3. dashboard/data.py +240 -0
  4. dashboard/templates/index.html +412 -0
  5. modastack/__init__.py +0 -0
  6. modastack/__version__.py +5 -0
  7. modastack/board_setup.py +86 -0
  8. modastack/browser.py +324 -0
  9. modastack/cli.py +2218 -0
  10. modastack/config.py +342 -0
  11. modastack/doctor.py +70 -0
  12. modastack/github_issues.py +166 -0
  13. modastack/history.py +370 -0
  14. modastack/manager/__init__.py +0 -0
  15. modastack/manager/events/__init__.py +6 -0
  16. modastack/manager/events/consumer.py +284 -0
  17. modastack/manager/events/event_client.py +429 -0
  18. modastack/manager/events/slack_responder.py +91 -0
  19. modastack/manager/session.py +430 -0
  20. modastack/monitors/__init__.py +20 -0
  21. modastack/monitors/checks.py +144 -0
  22. modastack/monitors/registry.py +183 -0
  23. modastack/monitors/scheduler.py +214 -0
  24. modastack/monitors/schema.py +109 -0
  25. modastack/prompts/__init__.py +14 -0
  26. modastack/prompts/agent_base.md +40 -0
  27. modastack/prompts/agents/engineer.md +492 -0
  28. modastack/prompts/manager_base.md +172 -0
  29. modastack/prompts/manager_engineering.md +152 -0
  30. modastack/prompts/resolver.py +40 -0
  31. modastack/relay.py +83 -0
  32. modastack/scanner.py +47 -0
  33. modastack/sdk.py +219 -0
  34. modastack/session.py +277 -0
  35. modastack/setup.py +141 -0
  36. modastack/subagent.py +1065 -0
  37. modastack/tmux.py +287 -0
  38. modastack/workflow/__init__.py +8 -0
  39. modastack/workflow/orchestrator.py +571 -0
  40. modastack/workflow/schema.py +91 -0
  41. modastack/workflow/state.py +187 -0
  42. modastack/workflow/triggers.py +89 -0
  43. modastack/workflow/variables.py +201 -0
  44. modastack-0.2.0.dist-info/METADATA +33 -0
  45. modastack-0.2.0.dist-info/RECORD +47 -0
  46. modastack-0.2.0.dist-info/WHEEL +4 -0
  47. modastack-0.2.0.dist-info/entry_points.txt +2 -0
dashboard/__init__.py ADDED
File without changes
dashboard/app.py ADDED
@@ -0,0 +1,219 @@
1
+ """FastAPI dashboard — view layer for modastack."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI, Query, Request
10
+ from fastapi.responses import HTMLResponse
11
+ from fastapi.templating import Jinja2Templates
12
+
13
+ from . import data
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ app = FastAPI(title="modastack dashboard")
18
+ templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
19
+
20
+
21
+ @app.get("/", response_class=HTMLResponse)
22
+ async def index(request: Request):
23
+ return templates.TemplateResponse(request, "index.html")
24
+
25
+
26
+ @app.get("/api/status")
27
+ async def api_status():
28
+ manager, engineers = await asyncio.gather(
29
+ asyncio.to_thread(data.get_manager_status),
30
+ asyncio.to_thread(data.get_sessions),
31
+ )
32
+ return {"manager": manager, "engineers": engineers}
33
+
34
+
35
+ @app.get("/api/events")
36
+ async def api_events(
37
+ limit: int = Query(50, ge=1, le=500),
38
+ offset: int = Query(0, ge=0),
39
+ source: str | None = Query(None),
40
+ type: str | None = Query(None),
41
+ ):
42
+ events, total = data.read_events(
43
+ limit=limit, offset=offset, source=source, type_filter=type
44
+ )
45
+ return {"events": events, "total": total}
46
+
47
+
48
+ @app.get("/api/decisions")
49
+ async def api_decisions(limit: int = Query(10, ge=1, le=100)):
50
+ return {"decisions": data.read_decisions(limit=limit)}
51
+
52
+
53
+ @app.get("/api/sources")
54
+ async def api_sources():
55
+ return {"sources": data.get_event_sources()}
56
+
57
+
58
+ @app.get("/api/log")
59
+ async def api_log(limit: int = Query(50, ge=1, le=200), session: str = Query("")):
60
+ if not session:
61
+ session = data._get_manager_session_name()
62
+ return {"turns": data.get_conversation_log(limit=limit, session=session)}
63
+
64
+
65
+ @app.get("/api/logs")
66
+ async def api_logs(limit: int = Query(200, ge=1, le=2000)):
67
+ """Tail of the modastack process log (~/.modastack/modastack.log)."""
68
+ lines = await asyncio.to_thread(data.read_modastack_log, limit)
69
+ return {"lines": lines}
70
+
71
+
72
+ @app.post("/api/message")
73
+ async def api_send_message(request: Request):
74
+ body = await request.json()
75
+ text = body.get("text", "").strip()
76
+ if not text:
77
+ return {"ok": False, "error": "empty message"}
78
+ from modastack.manager.session import inject, is_alive
79
+ if not is_alive():
80
+ return {"ok": False, "error": "manager not running"}
81
+ inject(text)
82
+ return {"ok": True}
83
+
84
+
85
+ @app.post("/api/event")
86
+ async def api_post_event(request: Request):
87
+ """Enqueue a synthetic event onto the same queue webhooks use.
88
+
89
+ Used by out-of-band check processes (`modastack spawn --non-interactive
90
+ --post-event ...`) to report findings without touching the manager's
91
+ conversation — the drain loop routes it like any other event.
92
+ """
93
+ body = await request.json()
94
+ etype = (body.get("type") or "").strip()
95
+ if not etype:
96
+ return {"ok": False, "error": "missing event type"}
97
+
98
+ from modastack.manager.events.event_client import event_queue
99
+ event = {
100
+ "type": etype,
101
+ "source": (body.get("source") or "monitor").strip(),
102
+ "data": body.get("data") or {},
103
+ }
104
+ event_queue.put(event)
105
+ return {"ok": True}
106
+
107
+
108
+ @app.post("/api/consult")
109
+ async def api_consult(request: Request):
110
+ body = await request.json()
111
+ question = body.get("question", "").strip()
112
+ timeout = body.get("timeout", 300)
113
+ source = body.get("source", "engineer")
114
+ correlation_id = body.get("correlation_id", "")
115
+
116
+ if not question:
117
+ return {"ok": False, "error": "empty question"}
118
+
119
+ from modastack.manager.session import is_alive
120
+ if not is_alive():
121
+ return {"ok": False, "error": "manager not running"}
122
+
123
+ result = await asyncio.to_thread(
124
+ _do_consult, question, timeout, source, correlation_id
125
+ )
126
+ return result
127
+
128
+
129
+ def _do_consult(question, timeout, source, correlation_id):
130
+ from modastack.manager.session import inject_capture, last_inject_error
131
+
132
+ _log_consultation(correlation_id, source, question)
133
+
134
+ ok, response = inject_capture(
135
+ f"[CONSULTATION] {question}",
136
+ timeout=timeout,
137
+ wait_for_ready=timeout,
138
+ )
139
+ if not ok:
140
+ return {"ok": False, "error": f"inject failed: {last_inject_error()}"}
141
+
142
+ return {"ok": True, "response": response or "", "correlation_id": correlation_id}
143
+
144
+
145
+ def _log_consultation(correlation_id: str, source: str, question: str):
146
+ events_log = Path.home() / ".modastack" / "manager" / "events.jsonl"
147
+ events_log.parent.mkdir(parents=True, exist_ok=True)
148
+ entry = {
149
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
150
+ "type": "consultation.request",
151
+ "source": source,
152
+ "data": {
153
+ "correlation_id": correlation_id,
154
+ "question": question[:500],
155
+ },
156
+ }
157
+ with open(events_log, "a") as f:
158
+ f.write(json.dumps(entry) + "\n")
159
+
160
+
161
+ @app.post("/api/engineers/{issue_id}/message")
162
+ async def api_send_engineer_message(issue_id: str, request: Request):
163
+ body = await request.json()
164
+ text = body.get("text", "").strip()
165
+ if not text:
166
+ return {"ok": False, "error": "empty message"}
167
+
168
+ # Direct injection only works if the engineer sub-agent is running in
169
+ # this process (legacy fire-and-forget path). The supervised executor
170
+ # runs phases to completion without a live inbox, so fall back to
171
+ # relaying the feedback through the manager, who coordinates engineers.
172
+ from modastack.subagent import inject_message
173
+ if inject_message(issue_id, text):
174
+ return {"ok": True, "delivery": "direct"}
175
+
176
+ from modastack.manager.session import inject, is_alive
177
+ if not is_alive():
178
+ return {"ok": False, "error": "manager not running and no live engineer session"}
179
+
180
+ title = ""
181
+ entry = data.get_registry().get(f"eng-{issue_id.lower()}") or _engineer_entry(issue_id)
182
+ if entry:
183
+ title = entry.title
184
+ relay = (
185
+ f"[ENGINEER FEEDBACK] For engineer working on #{issue_id}"
186
+ + (f" ({title})" if title else "")
187
+ + f": {text}\n\nRelay this to the engineer or act on it as appropriate."
188
+ )
189
+ ok = await asyncio.to_thread(inject, relay, timeout=120, wait_for_ready=120)
190
+ if ok:
191
+ return {"ok": True, "delivery": "manager"}
192
+ return {"ok": False, "error": "could not deliver — manager busy or unavailable"}
193
+
194
+
195
+ def _engineer_entry(issue_id: str):
196
+ """Find the most recent registry entry for an issue across phases."""
197
+ registry = data.get_registry()
198
+ matches = [
199
+ e for e in registry.get_by_role("engineer")
200
+ if e.issue_id.lower() == issue_id.lower()
201
+ ]
202
+ if not matches:
203
+ return None
204
+ return max(matches, key=lambda e: e.last_activity)
205
+
206
+
207
+ @app.get("/api/workflow/{issue_id}")
208
+ async def api_workflow_progress(issue_id: str):
209
+ progress = data.get_workflow_progress(issue_id)
210
+ if not progress:
211
+ return {"progress": None}
212
+ return {"progress": progress}
213
+
214
+
215
+ def run_dashboard(port: int = 8095) -> None:
216
+ import uvicorn
217
+
218
+ log.info(f"Starting dashboard on http://localhost:{port}")
219
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
dashboard/data.py ADDED
@@ -0,0 +1,240 @@
1
+ """Read-only data access for the dashboard.
2
+
3
+ Reads events, activity log, and session registry.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from modastack.config import GLOBAL_CONFIG_DIR
11
+ from modastack.sdk import get_registry, SessionRegistry
12
+ from modastack.workflow.state import WorkflowRun
13
+ from modastack.workflow.schema import load_workflow
14
+
15
+ def _state_file(name: str) -> Path:
16
+ from modastack.sdk import get_repo_root
17
+ root = get_repo_root()
18
+ if root:
19
+ return root / ".modastack" / "state" / name
20
+ return GLOBAL_CONFIG_DIR / name
21
+
22
+
23
+ def _log_path() -> Path:
24
+ return _state_file("manager.log")
25
+
26
+
27
+ def _tail_lines(path: Path, limit: int) -> list[str]:
28
+ """Return the last `limit` lines of a file without reading it whole.
29
+
30
+ Seeks backward from the end in chunks so an unbounded log (the
31
+ modastack daemon's stdout sink never rotates) stays cheap to poll.
32
+ """
33
+ if not path.exists():
34
+ return []
35
+ block = 64 * 1024
36
+ with open(path, "rb") as f:
37
+ f.seek(0, os.SEEK_END)
38
+ size = f.tell()
39
+ data = b""
40
+ newlines = 0
41
+ pos = size
42
+ while pos > 0 and newlines <= limit:
43
+ read = min(block, pos)
44
+ pos -= read
45
+ f.seek(pos)
46
+ chunk = f.read(read)
47
+ data = chunk + data
48
+ newlines += chunk.count(b"\n")
49
+ text = data.decode("utf-8", errors="replace")
50
+ return text.splitlines()[-limit:]
51
+
52
+
53
+ def _read_jsonl_tail(path: Path, limit: int) -> list[dict]:
54
+ if not path.exists():
55
+ return []
56
+ lines = path.read_text().strip().splitlines()
57
+ result = []
58
+ for line in reversed(lines):
59
+ try:
60
+ result.append(json.loads(line))
61
+ except json.JSONDecodeError:
62
+ continue
63
+ if len(result) >= limit:
64
+ break
65
+ return result
66
+
67
+
68
+ def read_events(
69
+ limit: int = 50,
70
+ offset: int = 0,
71
+ source: str | None = None,
72
+ type_filter: str | None = None,
73
+ ) -> tuple[list[dict], int]:
74
+ if not _state_file("events.jsonl").exists():
75
+ return [], 0
76
+
77
+ lines = _state_file("events.jsonl").read_text().strip().splitlines()
78
+ all_events = []
79
+ for line in reversed(lines):
80
+ try:
81
+ event = json.loads(line)
82
+ except json.JSONDecodeError:
83
+ continue
84
+ if source and event.get("source") != source:
85
+ continue
86
+ if type_filter and type_filter not in event.get("type", ""):
87
+ continue
88
+ all_events.append(event)
89
+
90
+ total = len(all_events)
91
+ return all_events[offset : offset + limit], total
92
+
93
+
94
+ def read_decisions(limit: int = 10) -> list[dict]:
95
+ return _read_jsonl_tail(_state_file("decisions.jsonl"), limit)
96
+
97
+
98
+ def _is_running() -> bool:
99
+ if not _state_file("manager.pid").exists():
100
+ return False
101
+ try:
102
+ pid = int(_state_file("manager.pid").read_text().strip())
103
+ os.kill(pid, 0)
104
+ return True
105
+ except (ValueError, ProcessLookupError, PermissionError):
106
+ return False
107
+
108
+
109
+ def _activity_snippet(session: str, length: int = 120) -> str:
110
+ """Most recent assistant response text for a session, truncated.
111
+
112
+ Only scans the tail of the log — the latest response is near the end,
113
+ and these files are polled on the async /api/status path.
114
+ """
115
+ log_path = SessionRegistry.log_path(session)
116
+ for line in reversed(_tail_lines(log_path, 200)):
117
+ try:
118
+ entry = json.loads(line)
119
+ except json.JSONDecodeError:
120
+ continue
121
+ if entry.get("event") == "response":
122
+ text = (entry.get("text") or "").strip().replace("\n", " ")
123
+ return text[:length] + ("…" if len(text) > length else "")
124
+ return ""
125
+
126
+
127
+ def _get_manager_session_name() -> str:
128
+ from modastack.manager.session import get_default_session
129
+ session = get_default_session()
130
+ return session.session_name if session else "moda-manager"
131
+
132
+
133
+ def get_manager_status() -> dict:
134
+ from modastack.sdk import load_session_id
135
+ name = _get_manager_session_name()
136
+ running = _is_running()
137
+ session_id = load_session_id(name) or ""
138
+ registry = get_registry()
139
+ entry = registry.get(name)
140
+ status = entry.status if entry else ("running" if running else "stopped")
141
+ return {
142
+ "alive": running,
143
+ "state": status,
144
+ "session_id": session_id[:8] if session_id else "",
145
+ "activity": _activity_snippet(name),
146
+ "last_activity": entry.last_activity if entry else 0,
147
+ }
148
+
149
+
150
+ def get_sessions() -> list[dict]:
151
+ registry = get_registry()
152
+ engineers = [e for e in registry.get_by_role("engineer") if e.status not in ("done", "cancelled")]
153
+ result = []
154
+ for e in engineers:
155
+ result.append({
156
+ "name": e.name,
157
+ "issue_id": e.issue_id,
158
+ "title": e.title,
159
+ "phase": e.phase,
160
+ "repo": e.repo,
161
+ "cwd": e.cwd,
162
+ "status": e.status,
163
+ "started_at": e.started_at,
164
+ "last_activity": e.last_activity,
165
+ "activity": _activity_snippet(e.name),
166
+ })
167
+ return result
168
+
169
+
170
+ def read_modastack_log(limit: int = 200) -> list[str]:
171
+ """Return the last `limit` lines of the modastack process log."""
172
+ return _tail_lines(_log_path(), limit)
173
+
174
+
175
+ def get_conversation_log(limit: int = 50, session: str = "") -> list[dict]:
176
+ if not session:
177
+ session = _get_manager_session_name()
178
+ log_path = SessionRegistry.log_path(session)
179
+ if not log_path.exists():
180
+ return []
181
+ lines = log_path.read_text().strip().splitlines()
182
+ turns = []
183
+ for line in reversed(lines):
184
+ try:
185
+ turns.append(json.loads(line))
186
+ except json.JSONDecodeError:
187
+ continue
188
+ if len(turns) >= limit:
189
+ break
190
+ return turns
191
+
192
+
193
+ def get_event_sources() -> list[str]:
194
+ if not _state_file("events.jsonl").exists():
195
+ return []
196
+ sources = set()
197
+ for line in _state_file("events.jsonl").read_text().strip().splitlines()[-200:]:
198
+ try:
199
+ sources.add(json.loads(line).get("source", ""))
200
+ except json.JSONDecodeError:
201
+ continue
202
+ return sorted(s for s in sources if s)
203
+
204
+
205
+ def _load_workflow_info(workflow_name: str) -> tuple[dict[str, str], list[str]]:
206
+ from modastack.workflow.triggers import WORKFLOWS_DIR, USER_WORKFLOWS_DIR
207
+ for d in [USER_WORKFLOWS_DIR, WORKFLOWS_DIR]:
208
+ path = d / f"{workflow_name}.yaml"
209
+ if path.exists():
210
+ try:
211
+ wf = load_workflow(path)
212
+ labels = {nid: n.label for nid, n in wf.nodes.items() if n.label}
213
+ order = wf.topological_order()
214
+ return labels, order
215
+ except Exception:
216
+ pass
217
+ return {}, []
218
+
219
+
220
+ def get_workflow_progress(issue_id: str) -> dict | None:
221
+ for run in WorkflowRun.list_runs():
222
+ trigger_data = run.trigger_event.get("data", {})
223
+ rid = trigger_data.get("issue_id", "")
224
+ if rid.lstrip("#").lower() == issue_id.lower():
225
+ labels, all_node_ids = _load_workflow_info(run.workflow_name)
226
+ nodes = []
227
+ for nid in all_node_ids:
228
+ ns = run.nodes.get(nid)
229
+ nodes.append({
230
+ "id": nid,
231
+ "label": labels.get(nid, ""),
232
+ "status": ns.status if ns else "pending",
233
+ })
234
+ return {
235
+ "run_id": run.run_id,
236
+ "workflow": run.workflow_name,
237
+ "status": run.status,
238
+ "nodes": nodes,
239
+ }
240
+ return None