@matt82198/aesop 0.1.0 → 0.3.1
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.
- package/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Wave dispatch collector — live per-agent phase and activity visibility.
|
|
4
|
+
|
|
5
|
+
Surfaces what a wave's workers are doing RIGHT NOW: per-agent phase (dispatch/thinking/tool-use/stall/done),
|
|
6
|
+
last-activity age, and token burn estimates.
|
|
7
|
+
|
|
8
|
+
Data sources:
|
|
9
|
+
- Agent transcripts: recursively scanned from {transcripts_root}/**/agent-*.jsonl (mtime + file size).
|
|
10
|
+
Real layout: ~/.claude/projects/*/subagents/agent-*.jsonl or {session}/subagents/agent-*.jsonl.
|
|
11
|
+
Filters to recent files only (<30 min) to avoid hundreds of stale entries.
|
|
12
|
+
- Orchestrator status: state/orchestrator-status.json (phase info)
|
|
13
|
+
- Workflow journal: state/workflow.journal.jsonl (per-agent state transitions, optional)
|
|
14
|
+
|
|
15
|
+
Degrades to {available:false} when no active workflow.
|
|
16
|
+
|
|
17
|
+
Performance: includes ~5s in-process cache to avoid re-reading transcripts on rapid polls.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Conservative token estimate: 4.5 bytes per token (empirical average)
|
|
29
|
+
BYTES_PER_TOKEN = 4.5
|
|
30
|
+
|
|
31
|
+
# Module-level cache: (expires_at_epoch, payload_dict). Avoids re-scanning transcripts
|
|
32
|
+
# on rapid polls (typical dashboard: 2-3s interval). TTL matches wave_prs.py pattern.
|
|
33
|
+
# Cache is invalidated if config paths change (important for test isolation).
|
|
34
|
+
_CACHE_TTL_SECONDS = 5.0
|
|
35
|
+
_cache = {"expires": 0.0, "payload": None, "transcripts_root": None}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _infer_agent_phase_from_transcript(transcript_path):
|
|
39
|
+
"""Infer agent phase from transcript tail.
|
|
40
|
+
|
|
41
|
+
Reads the last ~10 lines of an NDJSON transcript to infer phase state.
|
|
42
|
+
Returns one of: 'dispatch', 'thinking', 'tool-use', 'stall', 'done'.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
transcript_path: Path to agent-*.jsonl file
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
str: phase label, or 'unknown' if inference fails
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
if not transcript_path.exists():
|
|
52
|
+
return "unknown"
|
|
53
|
+
|
|
54
|
+
# Read last ~10 lines to infer phase
|
|
55
|
+
with open(transcript_path, 'rb') as f:
|
|
56
|
+
# Seek to end and read backwards to get tail
|
|
57
|
+
f.seek(0, 2)
|
|
58
|
+
file_size = f.tell()
|
|
59
|
+
if file_size == 0:
|
|
60
|
+
return "dispatch" # Empty file, likely just dispatched
|
|
61
|
+
|
|
62
|
+
# Read last ~2KB to capture ~10 lines
|
|
63
|
+
read_size = min(2048, file_size)
|
|
64
|
+
f.seek(max(0, file_size - read_size))
|
|
65
|
+
tail = f.read().decode('utf-8', errors='replace')
|
|
66
|
+
|
|
67
|
+
# Count message types to infer phase
|
|
68
|
+
lines = tail.strip().split('\n')
|
|
69
|
+
message_types = []
|
|
70
|
+
for line in lines:
|
|
71
|
+
if not line.strip():
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
entry = json.loads(line)
|
|
75
|
+
if isinstance(entry, dict) and 'type' in entry:
|
|
76
|
+
message_types.append(entry['type'].lower())
|
|
77
|
+
except (json.JSONDecodeError, ValueError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# Phase inference logic:
|
|
81
|
+
# - If last message is user: dispatch
|
|
82
|
+
# - If last message is assistant: thinking or tool-use
|
|
83
|
+
# - If we see [tool_use: ...]: tool-use
|
|
84
|
+
# - If we see [error] or timestamp recent: stall
|
|
85
|
+
# - If multiple completions: done
|
|
86
|
+
if not message_types:
|
|
87
|
+
return "unknown"
|
|
88
|
+
|
|
89
|
+
last_type = message_types[-1] if message_types else "unknown"
|
|
90
|
+
|
|
91
|
+
# Simple heuristic: check for tool_use markers in tail
|
|
92
|
+
tail_lower = tail.lower()
|
|
93
|
+
if '[tool_use:' in tail_lower or 'tool_use' in last_type:
|
|
94
|
+
return "tool-use"
|
|
95
|
+
elif '[error]' in tail_lower or 'error' in tail_lower:
|
|
96
|
+
return "stall"
|
|
97
|
+
elif last_type == 'assistant':
|
|
98
|
+
# Check if we have many completions (indicates done)
|
|
99
|
+
completion_count = tail.count('"type": "assistant"') + tail.count('"type":"assistant"')
|
|
100
|
+
if completion_count >= 3:
|
|
101
|
+
return "done"
|
|
102
|
+
return "thinking"
|
|
103
|
+
elif last_type == 'user':
|
|
104
|
+
return "dispatch"
|
|
105
|
+
else:
|
|
106
|
+
return "thinking"
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"[wave_dispatch] Error inferring phase from {transcript_path}: {e}", file=sys.stderr)
|
|
109
|
+
return "unknown"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _estimate_tokens_from_file_size(file_path):
|
|
113
|
+
"""Estimate tokens from file size.
|
|
114
|
+
|
|
115
|
+
Uses conservative ratio: size (bytes) / 4.5 bytes-per-token.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
file_path: Path to transcript file
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
int: estimated token count, or 0 if file missing
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
if not file_path.exists():
|
|
125
|
+
return 0
|
|
126
|
+
size = file_path.stat().st_size
|
|
127
|
+
return max(0, int(size / BYTES_PER_TOKEN))
|
|
128
|
+
except Exception:
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_last_activity_age_sec(file_path):
|
|
133
|
+
"""Get age of last file modification in seconds.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
file_path: Path to transcript file
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
int: seconds since last mtime, or -1 if file missing
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
if not file_path.exists():
|
|
143
|
+
return -1
|
|
144
|
+
mtime = file_path.stat().st_mtime
|
|
145
|
+
now = time.time()
|
|
146
|
+
age_sec = int(now - mtime)
|
|
147
|
+
return max(0, age_sec)
|
|
148
|
+
except Exception:
|
|
149
|
+
return -1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_wave_dispatch(force=False):
|
|
153
|
+
"""Get consolidated wave dispatch snapshot.
|
|
154
|
+
|
|
155
|
+
Reads agent transcripts and orchestrator status at call time (not import time)
|
|
156
|
+
to ensure test isolation. Returns per-agent phase, activity age, and token burn.
|
|
157
|
+
|
|
158
|
+
Caches result for ~5s to avoid re-reading transcripts on rapid dashboard polls.
|
|
159
|
+
Pass force=True to bypass cache (mainly for testing).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
dict: {
|
|
163
|
+
"available": bool,
|
|
164
|
+
"wave_phase": str or None,
|
|
165
|
+
"agents": [
|
|
166
|
+
{
|
|
167
|
+
"id": str,
|
|
168
|
+
"phase": str,
|
|
169
|
+
"last_activity_age_sec": int,
|
|
170
|
+
"token_estimate": int,
|
|
171
|
+
"warnings": [str] (optional)
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
"at": str (ISO 8601)
|
|
175
|
+
}
|
|
176
|
+
"""
|
|
177
|
+
# Cache check: if valid cached payload exists and config hasn't changed, return it
|
|
178
|
+
# (Invalidate cache if transcripts_root changed, important for test isolation)
|
|
179
|
+
now_epoch = time.time()
|
|
180
|
+
current_transcripts_root = str(config.TRANSCRIPTS_ROOT)
|
|
181
|
+
cached_transcripts_root = _cache.get("transcripts_root")
|
|
182
|
+
|
|
183
|
+
if (not force and
|
|
184
|
+
_cache["payload"] is not None and
|
|
185
|
+
now_epoch < _cache["expires"] and
|
|
186
|
+
current_transcripts_root == cached_transcripts_root):
|
|
187
|
+
return _cache["payload"]
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
now = datetime.now(timezone.utc)
|
|
191
|
+
timestamp = now.isoformat(timespec='seconds').replace('+00:00', 'Z')
|
|
192
|
+
|
|
193
|
+
# Try to read orchestrator status to get wave phase
|
|
194
|
+
wave_phase = None
|
|
195
|
+
try:
|
|
196
|
+
orch_status_file = config.ORCH_STATUS_FILE
|
|
197
|
+
if orch_status_file.exists():
|
|
198
|
+
content = orch_status_file.read_text(encoding='utf-8')
|
|
199
|
+
orch_data = json.loads(content)
|
|
200
|
+
wave_phase = orch_data.get("phase")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
print(f"[wave_dispatch] Error reading orchestrator status: {e}", file=sys.stderr)
|
|
203
|
+
|
|
204
|
+
# Find active agent transcripts
|
|
205
|
+
agents = []
|
|
206
|
+
transcripts_root = config.TRANSCRIPTS_ROOT
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Scan recursively for agent-*.jsonl files matching real Claude Code layout:
|
|
210
|
+
# ~/.claude/projects/*/subagents/agent-*.jsonl or {session}/subagents/agent-*.jsonl
|
|
211
|
+
# Uses same pattern as agents.py to ensure consistency (ui/agents.py:482)
|
|
212
|
+
if transcripts_root.exists():
|
|
213
|
+
for transcript_file in transcripts_root.glob("**/agent-*.jsonl"):
|
|
214
|
+
# Filter to recent files only (<30 min) to avoid hundreds of stale entries
|
|
215
|
+
age_sec = _get_last_activity_age_sec(transcript_file)
|
|
216
|
+
if age_sec < 0: # File missing or error reading
|
|
217
|
+
continue
|
|
218
|
+
if age_sec > 1800: # 30 minutes; skip stale transcripts
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
agent_id = transcript_file.stem # e.g., "agent-12345"
|
|
222
|
+
if agent_id.startswith("agent-"):
|
|
223
|
+
# Remove "agent-" prefix for display
|
|
224
|
+
display_id = agent_id[6:] # "12345"
|
|
225
|
+
|
|
226
|
+
phase = _infer_agent_phase_from_transcript(transcript_file)
|
|
227
|
+
tokens = _estimate_tokens_from_file_size(transcript_file)
|
|
228
|
+
|
|
229
|
+
# Build warnings
|
|
230
|
+
warnings = []
|
|
231
|
+
if age_sec > 300: # 5 minutes
|
|
232
|
+
warnings.append("inactive >5min")
|
|
233
|
+
if age_sec > 600: # 10 minutes
|
|
234
|
+
warnings.append("stalled >10min")
|
|
235
|
+
|
|
236
|
+
agent_entry = {
|
|
237
|
+
"id": display_id,
|
|
238
|
+
"phase": phase,
|
|
239
|
+
"last_activity_age_sec": age_sec,
|
|
240
|
+
"token_estimate": tokens,
|
|
241
|
+
}
|
|
242
|
+
if warnings:
|
|
243
|
+
agent_entry["warnings"] = warnings
|
|
244
|
+
|
|
245
|
+
agents.append(agent_entry)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
print(f"[wave_dispatch] Error scanning transcripts: {e}", file=sys.stderr)
|
|
248
|
+
|
|
249
|
+
# Determine availability: if no agents found, mark as unavailable
|
|
250
|
+
available = len(agents) > 0
|
|
251
|
+
|
|
252
|
+
payload = {
|
|
253
|
+
"available": available,
|
|
254
|
+
"wave_phase": wave_phase,
|
|
255
|
+
"agents": agents,
|
|
256
|
+
"at": timestamp
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Cache this payload for ~5s to avoid re-reading transcripts on rapid polls
|
|
260
|
+
_cache["payload"] = payload
|
|
261
|
+
_cache["expires"] = now_epoch + _CACHE_TTL_SECONDS
|
|
262
|
+
_cache["transcripts_root"] = str(config.TRANSCRIPTS_ROOT)
|
|
263
|
+
|
|
264
|
+
return payload
|
|
265
|
+
except Exception as e:
|
|
266
|
+
print(f"[wave_dispatch] Uncaught error: {e}", file=sys.stderr)
|
|
267
|
+
payload = {
|
|
268
|
+
"available": False,
|
|
269
|
+
"wave_phase": None,
|
|
270
|
+
"agents": [],
|
|
271
|
+
"error": "Internal error",
|
|
272
|
+
"at": datetime.now(timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Cache error response too (so we don't retry immediately)
|
|
276
|
+
_cache["payload"] = payload
|
|
277
|
+
_cache["expires"] = now_epoch + _CACHE_TTL_SECONDS
|
|
278
|
+
_cache["transcripts_root"] = str(config.TRANSCRIPTS_ROOT)
|
|
279
|
+
|
|
280
|
+
return payload
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wave PR failure drill-down collector — CI job logs and failure details.
|
|
3
|
+
|
|
4
|
+
Provides structured access to GitHub CI job logs and failure details for a given
|
|
5
|
+
PR number. When the Wave PR Board shows a red PR row, the UI can drill down via
|
|
6
|
+
this endpoint to see which checks failed, their logs, and stderr excerpts.
|
|
7
|
+
|
|
8
|
+
Backend:
|
|
9
|
+
- Shells `gh run view --json jobs` to list jobs for the latest run on a PR branch
|
|
10
|
+
- For failing jobs, shells `gh api repos/{owner}/{repo}/actions/jobs/{id}/logs`
|
|
11
|
+
to fetch the full log, extracts ~100-line tail excerpt
|
|
12
|
+
- 5-second cache per PR to avoid hammering GitHub API
|
|
13
|
+
- Degrades to {available:false, error:...} when gh is missing/unauthed (never 500)
|
|
14
|
+
- Honors AESOP_GH_BIN override; all text uses encoding='utf-8', errors='replace'
|
|
15
|
+
|
|
16
|
+
Response shape when available:
|
|
17
|
+
{
|
|
18
|
+
"available": true,
|
|
19
|
+
"error": null,
|
|
20
|
+
"pr_number": <int>,
|
|
21
|
+
"branch": "<str>",
|
|
22
|
+
"latest_run": {
|
|
23
|
+
"id": "<str>",
|
|
24
|
+
"name": "<str>",
|
|
25
|
+
"status": "completed" | "in_progress" | "queued",
|
|
26
|
+
"conclusion": "success" | "failure" | "cancelled" | "timed_out" | null,
|
|
27
|
+
"url": "<str>"
|
|
28
|
+
},
|
|
29
|
+
"jobs": [
|
|
30
|
+
{
|
|
31
|
+
"id": <int>,
|
|
32
|
+
"name": "<str>",
|
|
33
|
+
"status": "completed" | "in_progress" | "queued",
|
|
34
|
+
"conclusion": "success" | "failure" | "cancelled" | "timed_out" | null,
|
|
35
|
+
"url": "<str>",
|
|
36
|
+
"log_excerpt": "<str>" | null # ~100 lines, tail-truncated; null if fetch fails
|
|
37
|
+
},
|
|
38
|
+
...
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
When gh is missing/unauthed:
|
|
43
|
+
{
|
|
44
|
+
"available": false,
|
|
45
|
+
"error": "<human reason>",
|
|
46
|
+
"pr_number": <int>,
|
|
47
|
+
"branch": "",
|
|
48
|
+
"latest_run": null,
|
|
49
|
+
"jobs": []
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import subprocess
|
|
56
|
+
import sys
|
|
57
|
+
import time
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
60
|
+
|
|
61
|
+
import config
|
|
62
|
+
|
|
63
|
+
_CACHE_TTL_SECONDS = 5.0
|
|
64
|
+
_SUBPROCESS_TIMEOUT_SECONDS = 12
|
|
65
|
+
_LOG_EXCERPT_LINES = 100
|
|
66
|
+
|
|
67
|
+
# Module-level cache: {pr_number: (expires_at_epoch, payload_dict)}
|
|
68
|
+
_cache: Dict[int, Tuple[float, Dict[str, Any]]] = {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _gh_bin() -> str:
|
|
72
|
+
"""Path to the GitHub CLI binary.
|
|
73
|
+
|
|
74
|
+
Defaults to `gh` (resolved on PATH). Override with AESOP_GH_BIN to point at
|
|
75
|
+
a specific install or a wrapper — read at call time so it stays live.
|
|
76
|
+
"""
|
|
77
|
+
return os.environ.get("AESOP_GH_BIN", "gh")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run(cmd: List[str]) -> Tuple[bool, str, str]:
|
|
81
|
+
"""Run a subprocess, return (ok, stdout, stderr).
|
|
82
|
+
|
|
83
|
+
ok is False on a missing binary, a non-zero exit, or a timeout. Text is
|
|
84
|
+
decoded utf-8 with errors='replace' so undecodable bytes never raise.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
proc = subprocess.run(
|
|
88
|
+
cmd,
|
|
89
|
+
cwd=str(config.AESOP_ROOT),
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
encoding="utf-8",
|
|
93
|
+
errors="replace",
|
|
94
|
+
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
95
|
+
)
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
return False, "", f"{cmd[0]}: command not found"
|
|
98
|
+
except subprocess.TimeoutExpired:
|
|
99
|
+
return False, "", f"{cmd[0]}: timed out"
|
|
100
|
+
except OSError as e:
|
|
101
|
+
return False, "", f"{cmd[0]}: {e}"
|
|
102
|
+
return proc.returncode == 0, proc.stdout or "", proc.stderr or ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_branch_for_pr(pr_number: int) -> Optional[str]:
|
|
106
|
+
"""Get the branch name for a PR number via gh pr view.
|
|
107
|
+
|
|
108
|
+
Returns the headRefName (branch) for the PR, or None if lookup fails.
|
|
109
|
+
"""
|
|
110
|
+
ok, out, _err = _run([
|
|
111
|
+
_gh_bin(), "pr", "view", str(pr_number),
|
|
112
|
+
"--json", "headRefName"
|
|
113
|
+
])
|
|
114
|
+
if not ok or not out.strip():
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(out)
|
|
118
|
+
return data.get("headRefName")
|
|
119
|
+
except (ValueError, TypeError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _get_latest_run(branch: str) -> Optional[Dict[str, Any]]:
|
|
124
|
+
"""Get the latest workflow run for a branch.
|
|
125
|
+
|
|
126
|
+
Shells: gh run list --branch <branch> --limit 1 --json id,name,status,conclusion,url
|
|
127
|
+
Returns a run object or None if lookup fails.
|
|
128
|
+
"""
|
|
129
|
+
ok, out, _err = _run([
|
|
130
|
+
_gh_bin(), "run", "list", "--branch", branch,
|
|
131
|
+
"--limit", "1",
|
|
132
|
+
"--json", "id,name,status,conclusion,url"
|
|
133
|
+
])
|
|
134
|
+
if not ok or not out.strip():
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(out)
|
|
138
|
+
return data[0] if data else None
|
|
139
|
+
except (ValueError, TypeError, IndexError):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_jobs_for_run(run_id: str) -> List[Dict[str, Any]]:
|
|
144
|
+
"""Get all jobs in a run.
|
|
145
|
+
|
|
146
|
+
Shells: gh run view <run_id> --json jobs --json id,name,status,conclusion,url
|
|
147
|
+
Returns a list of job objects, or empty list if lookup fails.
|
|
148
|
+
"""
|
|
149
|
+
ok, out, _err = _run([
|
|
150
|
+
_gh_bin(), "run", "view", run_id,
|
|
151
|
+
"--json", "jobs"
|
|
152
|
+
])
|
|
153
|
+
if not ok or not out.strip():
|
|
154
|
+
return []
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(out)
|
|
157
|
+
# data should be {"jobs": [{...}, ...]}
|
|
158
|
+
return data.get("jobs", []) if isinstance(data, dict) else data if isinstance(data, list) else []
|
|
159
|
+
except (ValueError, TypeError):
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _get_job_log_excerpt(job_id: int) -> Optional[str]:
|
|
164
|
+
"""Fetch the full log for a job and extract ~100-line tail excerpt.
|
|
165
|
+
|
|
166
|
+
Shells: gh api repos/{owner}/{repo}/actions/jobs/{job_id}/logs
|
|
167
|
+
(Note: gh api auto-fills {owner}/{repo} when run inside a repo.)
|
|
168
|
+
Extracts the last _LOG_EXCERPT_LINES lines as a tail, decompresses if gzipped.
|
|
169
|
+
|
|
170
|
+
Returns the tail excerpt (plain text, utf-8 with errors='replace'), or None if fetch fails.
|
|
171
|
+
"""
|
|
172
|
+
# Determine owner/repo from the current repo (gh api can infer them)
|
|
173
|
+
ok, repo_info, _err = _run([
|
|
174
|
+
"git", "remote", "get-url", "origin"
|
|
175
|
+
])
|
|
176
|
+
if not ok:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
# Try to extract owner/repo from the remote URL
|
|
180
|
+
# Supports: https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
|
181
|
+
match = re.search(r'(?:github\.com[:/]|git@github\.com:)([^/]+)/([^/.]+)', repo_info)
|
|
182
|
+
if not match:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
owner, repo = match.group(1), match.group(2)
|
|
186
|
+
|
|
187
|
+
# Fetch log via gh api
|
|
188
|
+
api_path = f"repos/{owner}/{repo}/actions/jobs/{job_id}/logs"
|
|
189
|
+
ok, out, _err = _run([
|
|
190
|
+
_gh_bin(), "api", api_path
|
|
191
|
+
])
|
|
192
|
+
if not ok or not out.strip():
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
# Split into lines and take the tail
|
|
196
|
+
lines = out.splitlines()
|
|
197
|
+
tail_lines = lines[-_LOG_EXCERPT_LINES:] if lines else []
|
|
198
|
+
return "\n".join(tail_lines) if tail_lines else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _get_failure_details(pr_number: int) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
202
|
+
"""Collect failure details for a PR.
|
|
203
|
+
|
|
204
|
+
Returns: (available, error, latest_run, jobs)
|
|
205
|
+
- available: bool — gh is working and authenticated
|
|
206
|
+
- error: str | None — human reason if available is False
|
|
207
|
+
- latest_run: run object or None
|
|
208
|
+
- jobs: list of job objects with log_excerpt fields
|
|
209
|
+
"""
|
|
210
|
+
# Get the branch for the PR
|
|
211
|
+
branch = _get_branch_for_pr(pr_number)
|
|
212
|
+
if not branch:
|
|
213
|
+
return False, f"Could not find branch for PR #{pr_number}", None, []
|
|
214
|
+
|
|
215
|
+
# Get the latest run for the branch
|
|
216
|
+
run = _get_latest_run(branch)
|
|
217
|
+
if not run:
|
|
218
|
+
# Could mean: no runs at all, or gh failure
|
|
219
|
+
# Assume success (no failure data to show) rather than error
|
|
220
|
+
return True, None, None, []
|
|
221
|
+
|
|
222
|
+
# Get all jobs in the run
|
|
223
|
+
jobs = _get_jobs_for_run(run["id"])
|
|
224
|
+
|
|
225
|
+
# For each job, fetch log excerpt if it failed
|
|
226
|
+
for job in jobs:
|
|
227
|
+
job_id = job.get("id")
|
|
228
|
+
if job_id and job.get("conclusion") == "failure":
|
|
229
|
+
excerpt = _get_job_log_excerpt(job_id)
|
|
230
|
+
job["log_excerpt"] = excerpt
|
|
231
|
+
else:
|
|
232
|
+
job["log_excerpt"] = None
|
|
233
|
+
|
|
234
|
+
return True, None, run, jobs
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_wave_failure(pr_number: int, force: bool = False) -> Dict[str, Any]:
|
|
238
|
+
"""Get consolidated failure drill-down data for a PR.
|
|
239
|
+
|
|
240
|
+
Shape:
|
|
241
|
+
{
|
|
242
|
+
"available": bool,
|
|
243
|
+
"error": str | None,
|
|
244
|
+
"pr_number": int,
|
|
245
|
+
"branch": str,
|
|
246
|
+
"latest_run": {...} | null,
|
|
247
|
+
"jobs": [{id, name, status, conclusion, url, log_excerpt}, ...]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
Cached for _CACHE_TTL_SECONDS so rapid polls don't re-run gh. Never raises.
|
|
251
|
+
"""
|
|
252
|
+
now = time.time()
|
|
253
|
+
if not force and pr_number in _cache:
|
|
254
|
+
expires, payload = _cache[pr_number]
|
|
255
|
+
if now < expires:
|
|
256
|
+
return payload
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
available, error, run, jobs = _get_failure_details(pr_number)
|
|
260
|
+
|
|
261
|
+
# Get branch name for the response
|
|
262
|
+
branch = ""
|
|
263
|
+
if run:
|
|
264
|
+
# Try to infer branch from run or PR
|
|
265
|
+
branch_name = _get_branch_for_pr(pr_number)
|
|
266
|
+
branch = branch_name or ""
|
|
267
|
+
|
|
268
|
+
payload = {
|
|
269
|
+
"available": available,
|
|
270
|
+
"error": error,
|
|
271
|
+
"pr_number": pr_number,
|
|
272
|
+
"branch": branch,
|
|
273
|
+
"latest_run": run,
|
|
274
|
+
"jobs": jobs,
|
|
275
|
+
}
|
|
276
|
+
except Exception as e:
|
|
277
|
+
print(f"[wave_failure] Uncaught error: {e}", file=sys.stderr)
|
|
278
|
+
payload = {
|
|
279
|
+
"available": False,
|
|
280
|
+
"error": f"Internal error: {e}",
|
|
281
|
+
"pr_number": pr_number,
|
|
282
|
+
"branch": "",
|
|
283
|
+
"latest_run": None,
|
|
284
|
+
"jobs": [],
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_cache[pr_number] = (now + _CACHE_TTL_SECONDS, payload)
|
|
288
|
+
return payload
|