@matt82198/aesop 0.1.0-beta.5 → 0.1.0
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 +158 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- package/ui/web/vitest.config.ts +0 -12
package/ui/sse.py
CHANGED
|
@@ -5,18 +5,24 @@ import json
|
|
|
5
5
|
import queue
|
|
6
6
|
import sys
|
|
7
7
|
import threading
|
|
8
|
+
import time
|
|
8
9
|
|
|
9
10
|
import config
|
|
10
11
|
import cost
|
|
11
12
|
from collectors import (parse_audit_backlog, _snapshot_data, _snapshot_tracker,
|
|
12
13
|
_snapshot_orchestrator_status, drain_tracker_inbox)
|
|
13
|
-
from agents import get_fleet_agents, _transcripts_fingerprint
|
|
14
|
+
from agents import get_fleet_agents, _transcripts_fingerprint, sanitize_agents_for_broadcast
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
HEARTBEAT_EVENT_NAME = 'heartbeat'
|
|
18
|
+
HEARTBEAT_INTERVAL = 15 # seconds; emit every 15s to detect collector thread death
|
|
19
|
+
|
|
16
20
|
_sse_lock = threading.Lock()
|
|
17
21
|
|
|
18
22
|
_sse_clients = [] # list[queue.Queue]
|
|
19
23
|
|
|
24
|
+
_dropped_counts = {} # dict[queue.Queue, int] — track dropped events per client
|
|
25
|
+
|
|
20
26
|
_latest_lock = threading.Lock()
|
|
21
27
|
|
|
22
28
|
_latest_snapshots = {"data": None, "backlog": None, "agents": None,
|
|
@@ -49,6 +55,7 @@ def reset_state():
|
|
|
49
55
|
_latest_snapshots[k] = None
|
|
50
56
|
with _sse_lock:
|
|
51
57
|
_sse_clients.clear()
|
|
58
|
+
_dropped_counts.clear()
|
|
52
59
|
|
|
53
60
|
|
|
54
61
|
def register_sse_client():
|
|
@@ -65,12 +72,17 @@ def unregister_sse_client(q):
|
|
|
65
72
|
with _sse_lock:
|
|
66
73
|
if q in _sse_clients:
|
|
67
74
|
_sse_clients.remove(q)
|
|
75
|
+
_dropped_counts.pop(q, None) # Clean up dropped count for this client
|
|
68
76
|
|
|
69
77
|
def broadcast_sse(event_name, payload):
|
|
70
78
|
"""Push (event_name, payload) onto every currently-registered client queue.
|
|
71
79
|
|
|
72
80
|
If a client queue is full, drop the oldest event to make room (bounded backpressure).
|
|
73
81
|
This prevents one slow client from blocking the broadcast.
|
|
82
|
+
|
|
83
|
+
Tracks dropped events: when a client's queue overflows, we increment the dropped counter
|
|
84
|
+
and attach a "dropped": N field to the event being queued, so the frontend can detect
|
|
85
|
+
that it missed updates.
|
|
74
86
|
"""
|
|
75
87
|
with _sse_lock:
|
|
76
88
|
clients = list(_sse_clients)
|
|
@@ -78,10 +90,27 @@ def broadcast_sse(event_name, payload):
|
|
|
78
90
|
try:
|
|
79
91
|
q.put_nowait((event_name, payload))
|
|
80
92
|
except queue.Full:
|
|
81
|
-
# Queue is full: drop the
|
|
93
|
+
# Queue is full: drop oldest, track the drop, and add dropped field to new event
|
|
94
|
+
with _sse_lock:
|
|
95
|
+
_dropped_counts[q] = _dropped_counts.get(q, 0) + 1
|
|
96
|
+
dropped = _dropped_counts[q]
|
|
97
|
+
|
|
98
|
+
# Try to parse payload and add dropped field
|
|
99
|
+
effective_payload = payload
|
|
82
100
|
try:
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
data = json.loads(payload)
|
|
102
|
+
data["dropped"] = dropped
|
|
103
|
+
effective_payload = json.dumps(data, default=str, sort_keys=True)
|
|
104
|
+
except (json.JSONDecodeError, TypeError):
|
|
105
|
+
# If payload is not JSON, can't attach dropped count; use original
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
q.get_nowait() # Remove oldest event
|
|
110
|
+
q.put_nowait((event_name, effective_payload)) # Add new event with dropped field
|
|
111
|
+
# Reset the dropped counter after successful queue
|
|
112
|
+
with _sse_lock:
|
|
113
|
+
_dropped_counts[q] = 0
|
|
85
114
|
except Exception as e:
|
|
86
115
|
print(f"[collector_loop] Exception: {type(e).__name__}: {e}", file=sys.stderr, flush=True)
|
|
87
116
|
except Exception:
|
|
@@ -106,15 +135,59 @@ def collector_loop(stop_event):
|
|
|
106
135
|
cached_backlog_snapshot = {"tiers": []}
|
|
107
136
|
cached_agents_snapshot = []
|
|
108
137
|
last_tracker_mtime = object()
|
|
138
|
+
last_tracker_size = object()
|
|
109
139
|
last_status_mtime = object()
|
|
140
|
+
last_status_size = object()
|
|
110
141
|
last_cost_mtime = object()
|
|
142
|
+
last_cost_size = object()
|
|
111
143
|
cached_tracker_snapshot = {'items': []}
|
|
112
144
|
cached_status_snapshot = {'orchestrators': []}
|
|
113
145
|
cached_cost_snapshot = {}
|
|
146
|
+
# Wave-19: Gate data section sources on mtime to avoid expensive reads every tick
|
|
147
|
+
last_backup_log_mtime = object() # sentinel
|
|
148
|
+
last_backup_log_size = object()
|
|
149
|
+
last_alerts_log_mtime = object() # sentinel
|
|
150
|
+
last_alerts_log_size = object()
|
|
151
|
+
cached_data_snapshot = {}
|
|
152
|
+
# Wave-20: heartbeat emission to detect collector thread death
|
|
153
|
+
last_heartbeat_time = 0.0
|
|
114
154
|
|
|
115
155
|
while not stop_event.is_set():
|
|
116
156
|
try:
|
|
117
|
-
|
|
157
|
+
current_time = time.time()
|
|
158
|
+
|
|
159
|
+
# Wave-20: Emit heartbeat every 15s to signal collector thread is alive
|
|
160
|
+
if current_time - last_heartbeat_time >= HEARTBEAT_INTERVAL:
|
|
161
|
+
last_heartbeat_time = current_time
|
|
162
|
+
heartbeat_payload = json.dumps({"timestamp": int(current_time * 1000)}, default=str)
|
|
163
|
+
broadcast_sse(HEARTBEAT_EVENT_NAME, heartbeat_payload)
|
|
164
|
+
|
|
165
|
+
# Wave-19: Gate data section on mtimes+sizes to avoid expensive file reads every tick.
|
|
166
|
+
# Only regenerate the snapshot if one of the underlying log files changed.
|
|
167
|
+
try:
|
|
168
|
+
backup_log_stat = config.BACKUP_LOG.stat() if config.BACKUP_LOG.exists() else None
|
|
169
|
+
backup_log_mtime = backup_log_stat.st_mtime if backup_log_stat else None
|
|
170
|
+
backup_log_size = backup_log_stat.st_size if backup_log_stat else None
|
|
171
|
+
except OSError:
|
|
172
|
+
backup_log_mtime = None
|
|
173
|
+
backup_log_size = None
|
|
174
|
+
try:
|
|
175
|
+
alerts_log_stat = config.ALERTS_LOG.stat() if config.ALERTS_LOG.exists() else None
|
|
176
|
+
alerts_log_mtime = alerts_log_stat.st_mtime if alerts_log_stat else None
|
|
177
|
+
alerts_log_size = alerts_log_stat.st_size if alerts_log_stat else None
|
|
178
|
+
except OSError:
|
|
179
|
+
alerts_log_mtime = None
|
|
180
|
+
alerts_log_size = None
|
|
181
|
+
|
|
182
|
+
if ((backup_log_mtime, backup_log_size) != (last_backup_log_mtime, last_backup_log_size) or
|
|
183
|
+
(alerts_log_mtime, alerts_log_size) != (last_alerts_log_mtime, last_alerts_log_size)):
|
|
184
|
+
last_backup_log_mtime = backup_log_mtime
|
|
185
|
+
last_backup_log_size = backup_log_size
|
|
186
|
+
last_alerts_log_mtime = alerts_log_mtime
|
|
187
|
+
last_alerts_log_size = alerts_log_size
|
|
188
|
+
cached_data_snapshot = _snapshot_data()
|
|
189
|
+
|
|
190
|
+
_maybe_emit("data", cached_data_snapshot, last_hashes)
|
|
118
191
|
|
|
119
192
|
try:
|
|
120
193
|
backlog_mtime = config.AUDIT_BACKLOG_FILE.stat().st_mtime if config.AUDIT_BACKLOG_FILE.exists() else None
|
|
@@ -128,37 +201,50 @@ def collector_loop(stop_event):
|
|
|
128
201
|
fingerprint = _transcripts_fingerprint()
|
|
129
202
|
if fingerprint != last_agents_fingerprint:
|
|
130
203
|
last_agents_fingerprint = fingerprint
|
|
131
|
-
|
|
204
|
+
agents = get_fleet_agents()
|
|
205
|
+
# Wave-19: strip large prompt fields before broadcast
|
|
206
|
+
cached_agents_snapshot = sanitize_agents_for_broadcast(agents)
|
|
132
207
|
_maybe_emit("agents", cached_agents_snapshot, last_hashes)
|
|
133
208
|
|
|
134
|
-
# Emit tracker section (mtime-gated)
|
|
209
|
+
# Emit tracker section (mtime+size-gated)
|
|
135
210
|
try:
|
|
136
|
-
|
|
211
|
+
tracker_stat = (config.STATE_DIR / "tracker.json").stat() if (config.STATE_DIR / "tracker.json").exists() else None
|
|
212
|
+
tracker_mtime = tracker_stat.st_mtime if tracker_stat else None
|
|
213
|
+
tracker_size = tracker_stat.st_size if tracker_stat else None
|
|
137
214
|
except OSError:
|
|
138
215
|
tracker_mtime = None
|
|
139
|
-
|
|
216
|
+
tracker_size = None
|
|
217
|
+
if (tracker_mtime, tracker_size) != (last_tracker_mtime, last_tracker_size):
|
|
140
218
|
last_tracker_mtime = tracker_mtime
|
|
219
|
+
last_tracker_size = tracker_size
|
|
141
220
|
cached_tracker_snapshot = _snapshot_tracker()
|
|
142
221
|
_maybe_emit("tracker", cached_tracker_snapshot, last_hashes)
|
|
143
222
|
|
|
144
|
-
# Emit status section (mtime-gated)
|
|
223
|
+
# Emit status section (mtime+size-gated)
|
|
145
224
|
try:
|
|
146
|
-
|
|
225
|
+
status_stat = (config.STATE_DIR / "orchestrator-status.json").stat() if (config.STATE_DIR / "orchestrator-status.json").exists() else None
|
|
226
|
+
status_mtime = status_stat.st_mtime if status_stat else None
|
|
227
|
+
status_size = status_stat.st_size if status_stat else None
|
|
147
228
|
except OSError:
|
|
148
229
|
status_mtime = None
|
|
149
|
-
|
|
230
|
+
status_size = None
|
|
231
|
+
if (status_mtime, status_size) != (last_status_mtime, last_status_size):
|
|
150
232
|
last_status_mtime = status_mtime
|
|
233
|
+
last_status_size = status_size
|
|
151
234
|
cached_status_snapshot = _snapshot_orchestrator_status()
|
|
152
235
|
_maybe_emit("status", cached_status_snapshot, last_hashes)
|
|
153
236
|
|
|
154
|
-
# Emit cost section (
|
|
155
|
-
# outcomes ledger, mirroring the tracker gate above)
|
|
237
|
+
# Emit cost section (mtime+size-gated on the outcomes ledger)
|
|
156
238
|
try:
|
|
157
|
-
|
|
239
|
+
cost_stat = config.LEDGER_FILE.stat() if config.LEDGER_FILE.exists() else None
|
|
240
|
+
cost_mtime = cost_stat.st_mtime if cost_stat else None
|
|
241
|
+
cost_size = cost_stat.st_size if cost_stat else None
|
|
158
242
|
except OSError:
|
|
159
243
|
cost_mtime = None
|
|
160
|
-
|
|
244
|
+
cost_size = None
|
|
245
|
+
if (cost_mtime, cost_size) != (last_cost_mtime, last_cost_size):
|
|
161
246
|
last_cost_mtime = cost_mtime
|
|
247
|
+
last_cost_size = cost_size
|
|
162
248
|
cached_cost_snapshot = cost.get_cost_summary()
|
|
163
249
|
_maybe_emit("cost", cached_cost_snapshot, last_hashes)
|
|
164
250
|
|
package/ui/wave_prs.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wave PR board collector — read-only snapshot of open PRs + feat/* branches.
|
|
3
|
+
|
|
4
|
+
Gathers the current wave's pull-request / branch status so the dashboard can
|
|
5
|
+
show CI, mergeability, age, and the top blocker at a glance without a trip to
|
|
6
|
+
GitHub. Read-only: only `gh pr list` and `git for-each-ref` are ever run, both
|
|
7
|
+
with a short timeout and cwd pinned to the repo root.
|
|
8
|
+
|
|
9
|
+
Robustness (wave-27 lesson): every subprocess text read passes
|
|
10
|
+
encoding='utf-8', errors='replace'. Missing `gh`, an un-authenticated `gh`, a
|
|
11
|
+
non-git cwd, or simply zero PRs all degrade to a well-formed empty payload
|
|
12
|
+
(available flag + human error), never an exception that 500s the endpoint.
|
|
13
|
+
|
|
14
|
+
A tiny module-level cache (default 5s) keeps repeated polls snappy — `gh pr
|
|
15
|
+
list` hits the network, so we do not want to run it on every dashboard tick.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
import config
|
|
23
|
+
|
|
24
|
+
# gh fields we request. statusCheckRollup carries per-check conclusions; we
|
|
25
|
+
# roll them up into one of passing/failing/pending/none client-agnostic states.
|
|
26
|
+
_GH_FIELDS = (
|
|
27
|
+
"number,title,headRefName,mergeable,isDraft,url,createdAt,"
|
|
28
|
+
"reviewDecision,statusCheckRollup"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_CACHE_TTL_SECONDS = 5.0
|
|
32
|
+
_SUBPROCESS_TIMEOUT_SECONDS = 12
|
|
33
|
+
|
|
34
|
+
# Module-level cache: (expires_at_epoch, payload_dict). Guarded by the GIL for
|
|
35
|
+
# the simple read-modify-write here; the collector is I/O bound, not a hot loop.
|
|
36
|
+
_cache = {"expires": 0.0, "payload": None}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _gh_bin():
|
|
40
|
+
"""Path to the GitHub CLI binary.
|
|
41
|
+
|
|
42
|
+
Defaults to `gh` (resolved on PATH). Override with AESOP_GH_BIN to point at
|
|
43
|
+
a specific install or a wrapper — read at call time so it stays live.
|
|
44
|
+
"""
|
|
45
|
+
return os.environ.get("AESOP_GH_BIN", "gh")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run(cmd):
|
|
49
|
+
"""Run a subprocess, return (ok, stdout, stderr).
|
|
50
|
+
|
|
51
|
+
ok is False on a missing binary, a non-zero exit, or a timeout. Text is
|
|
52
|
+
decoded utf-8 with errors='replace' so undecodable bytes never raise.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
cmd,
|
|
57
|
+
cwd=str(config.AESOP_ROOT),
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
errors="replace",
|
|
62
|
+
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
63
|
+
)
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
return False, "", f"{cmd[0]}: command not found"
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
return False, "", f"{cmd[0]}: timed out"
|
|
68
|
+
except OSError as e:
|
|
69
|
+
return False, "", f"{cmd[0]}: {e}"
|
|
70
|
+
return proc.returncode == 0, proc.stdout or "", proc.stderr or ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rollup_ci(status_check_rollup):
|
|
74
|
+
"""Reduce gh's statusCheckRollup list to one state.
|
|
75
|
+
|
|
76
|
+
Returns one of: 'passing', 'failing', 'pending', 'none'. Color-independent
|
|
77
|
+
label; the frontend pairs it with an icon + text so status never rides on
|
|
78
|
+
color alone.
|
|
79
|
+
"""
|
|
80
|
+
if not status_check_rollup:
|
|
81
|
+
return "none"
|
|
82
|
+
saw_pending = False
|
|
83
|
+
for check in status_check_rollup:
|
|
84
|
+
# CheckRun uses `status`/`conclusion`; StatusContext uses `state`.
|
|
85
|
+
state = (check.get("state") or "").upper()
|
|
86
|
+
status = (check.get("status") or "").upper()
|
|
87
|
+
conclusion = (check.get("conclusion") or "").upper()
|
|
88
|
+
|
|
89
|
+
if state in ("FAILURE", "ERROR") or conclusion in (
|
|
90
|
+
"FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE",
|
|
91
|
+
):
|
|
92
|
+
return "failing"
|
|
93
|
+
if state == "PENDING" or status in ("QUEUED", "IN_PROGRESS", "PENDING", "WAITING"):
|
|
94
|
+
saw_pending = True
|
|
95
|
+
return "pending" if saw_pending else "passing"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _blocker(ci, mergeable, is_draft, review_decision):
|
|
99
|
+
"""Top single blocker for a PR, or None when it looks ready to merge."""
|
|
100
|
+
if is_draft:
|
|
101
|
+
return "Draft — not ready for review"
|
|
102
|
+
if ci == "failing":
|
|
103
|
+
return "CI failing"
|
|
104
|
+
if (mergeable or "").upper() == "CONFLICTING":
|
|
105
|
+
return "Merge conflict"
|
|
106
|
+
if ci == "pending":
|
|
107
|
+
return "CI pending"
|
|
108
|
+
if (review_decision or "").upper() == "CHANGES_REQUESTED":
|
|
109
|
+
return "Changes requested"
|
|
110
|
+
if (review_decision or "").upper() == "REVIEW_REQUIRED":
|
|
111
|
+
return "Review required"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _collect_prs():
|
|
116
|
+
"""Return (available, error, prs). Never raises."""
|
|
117
|
+
ok, out, err = _run([
|
|
118
|
+
_gh_bin(), "pr", "list", "--state", "open", "--limit", "50", "--json", _GH_FIELDS,
|
|
119
|
+
])
|
|
120
|
+
if not ok:
|
|
121
|
+
low = (err or "").lower()
|
|
122
|
+
if "command not found" in low:
|
|
123
|
+
return False, "GitHub CLI (gh) is not installed.", []
|
|
124
|
+
if "auth" in low or "not logged" in low or "gh auth login" in low:
|
|
125
|
+
return False, "GitHub CLI is not authenticated (run: gh auth login).", []
|
|
126
|
+
if "not a git" in low or "no git repository" in low:
|
|
127
|
+
return False, "Not a GitHub repository.", []
|
|
128
|
+
# Any other failure: surface a trimmed reason, degrade to empty.
|
|
129
|
+
reason = (err or "gh pr list failed").strip().splitlines()
|
|
130
|
+
return False, reason[0] if reason else "gh pr list failed", []
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
raw = json.loads(out) if out.strip() else []
|
|
134
|
+
except (ValueError, TypeError):
|
|
135
|
+
return False, "Could not parse gh output.", []
|
|
136
|
+
|
|
137
|
+
prs = []
|
|
138
|
+
for item in raw:
|
|
139
|
+
ci = _rollup_ci(item.get("statusCheckRollup"))
|
|
140
|
+
mergeable = item.get("mergeable") or "UNKNOWN"
|
|
141
|
+
is_draft = bool(item.get("isDraft"))
|
|
142
|
+
review = item.get("reviewDecision") or ""
|
|
143
|
+
prs.append({
|
|
144
|
+
"number": item.get("number"),
|
|
145
|
+
"title": item.get("title") or "(untitled)",
|
|
146
|
+
"branch": item.get("headRefName") or "",
|
|
147
|
+
"url": item.get("url") or "",
|
|
148
|
+
"ci": ci,
|
|
149
|
+
"mergeable": mergeable,
|
|
150
|
+
"is_draft": is_draft,
|
|
151
|
+
"review_decision": review,
|
|
152
|
+
"created_at": item.get("createdAt") or "",
|
|
153
|
+
"blocker": _blocker(ci, mergeable, is_draft, review),
|
|
154
|
+
"has_pr": True,
|
|
155
|
+
})
|
|
156
|
+
return True, None, prs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _collect_branch_only(pr_branches):
|
|
160
|
+
"""feat/* branches (local + remote) that have no open PR yet.
|
|
161
|
+
|
|
162
|
+
Best-effort: a git failure just yields nothing extra (PRs still render).
|
|
163
|
+
"""
|
|
164
|
+
ok, out, _err = _run([
|
|
165
|
+
"git", "for-each-ref", "--format=%(refname:short)",
|
|
166
|
+
"refs/heads/feat/", "refs/remotes/origin/feat/",
|
|
167
|
+
])
|
|
168
|
+
if not ok:
|
|
169
|
+
return []
|
|
170
|
+
seen = set()
|
|
171
|
+
rows = []
|
|
172
|
+
for line in out.splitlines():
|
|
173
|
+
name = line.strip()
|
|
174
|
+
if not name:
|
|
175
|
+
continue
|
|
176
|
+
# Normalize origin/feat/x -> feat/x so local+remote of one branch dedupe.
|
|
177
|
+
branch = name[len("origin/"):] if name.startswith("origin/") else name
|
|
178
|
+
if branch in seen or branch in pr_branches:
|
|
179
|
+
continue
|
|
180
|
+
seen.add(branch)
|
|
181
|
+
rows.append({
|
|
182
|
+
"number": None,
|
|
183
|
+
"title": branch,
|
|
184
|
+
"branch": branch,
|
|
185
|
+
"url": "",
|
|
186
|
+
"ci": "none",
|
|
187
|
+
"mergeable": "UNKNOWN",
|
|
188
|
+
"is_draft": False,
|
|
189
|
+
"review_decision": "",
|
|
190
|
+
"created_at": "",
|
|
191
|
+
"blocker": "No PR opened yet",
|
|
192
|
+
"has_pr": False,
|
|
193
|
+
})
|
|
194
|
+
return rows
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_wave_prs(force=False):
|
|
198
|
+
"""Snapshot of open PRs + PR-less feat/* branches for the PR board.
|
|
199
|
+
|
|
200
|
+
Shape:
|
|
201
|
+
{
|
|
202
|
+
"available": bool, # False when gh is missing/unauthed
|
|
203
|
+
"error": str | None, # human reason when available is False
|
|
204
|
+
"generated_at": str, # ISO 8601 UTC
|
|
205
|
+
"prs": [ {number, title, branch, url, ci, mergeable, is_draft,
|
|
206
|
+
review_decision, created_at, blocker, has_pr}, ... ]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
Cached for _CACHE_TTL_SECONDS so rapid polls don't re-run gh. Never raises.
|
|
210
|
+
"""
|
|
211
|
+
now = time.time()
|
|
212
|
+
if not force and _cache["payload"] is not None and now < _cache["expires"]:
|
|
213
|
+
return _cache["payload"]
|
|
214
|
+
|
|
215
|
+
available, error, prs = _collect_prs()
|
|
216
|
+
if available:
|
|
217
|
+
pr_branches = {p["branch"] for p in prs if p.get("branch")}
|
|
218
|
+
prs = prs + _collect_branch_only(pr_branches)
|
|
219
|
+
# Sort: PRs first (by number desc), then branch-only rows by name.
|
|
220
|
+
prs.sort(key=lambda p: (p["number"] is None, -(p["number"] or 0), p["branch"]))
|
|
221
|
+
|
|
222
|
+
payload = {
|
|
223
|
+
"available": available,
|
|
224
|
+
"error": error,
|
|
225
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
|
226
|
+
"prs": prs,
|
|
227
|
+
}
|
|
228
|
+
_cache["payload"] = payload
|
|
229
|
+
_cache["expires"] = now + _CACHE_TTL_SECONDS
|
|
230
|
+
return payload
|