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.
- dashboard/__init__.py +0 -0
- dashboard/app.py +219 -0
- dashboard/data.py +240 -0
- dashboard/templates/index.html +412 -0
- modastack/__init__.py +0 -0
- modastack/__version__.py +5 -0
- modastack/board_setup.py +86 -0
- modastack/browser.py +324 -0
- modastack/cli.py +2218 -0
- modastack/config.py +342 -0
- modastack/doctor.py +70 -0
- modastack/github_issues.py +166 -0
- modastack/history.py +370 -0
- modastack/manager/__init__.py +0 -0
- modastack/manager/events/__init__.py +6 -0
- modastack/manager/events/consumer.py +284 -0
- modastack/manager/events/event_client.py +429 -0
- modastack/manager/events/slack_responder.py +91 -0
- modastack/manager/session.py +430 -0
- modastack/monitors/__init__.py +20 -0
- modastack/monitors/checks.py +144 -0
- modastack/monitors/registry.py +183 -0
- modastack/monitors/scheduler.py +214 -0
- modastack/monitors/schema.py +109 -0
- modastack/prompts/__init__.py +14 -0
- modastack/prompts/agent_base.md +40 -0
- modastack/prompts/agents/engineer.md +492 -0
- modastack/prompts/manager_base.md +172 -0
- modastack/prompts/manager_engineering.md +152 -0
- modastack/prompts/resolver.py +40 -0
- modastack/relay.py +83 -0
- modastack/scanner.py +47 -0
- modastack/sdk.py +219 -0
- modastack/session.py +277 -0
- modastack/setup.py +141 -0
- modastack/subagent.py +1065 -0
- modastack/tmux.py +287 -0
- modastack/workflow/__init__.py +8 -0
- modastack/workflow/orchestrator.py +571 -0
- modastack/workflow/schema.py +91 -0
- modastack/workflow/state.py +187 -0
- modastack/workflow/triggers.py +89 -0
- modastack/workflow/variables.py +201 -0
- modastack-0.2.0.dist-info/METADATA +33 -0
- modastack-0.2.0.dist-info/RECORD +47 -0
- modastack-0.2.0.dist-info/WHEEL +4 -0
- 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
|