@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.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. 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 oldest event and retry
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
- q.get_nowait() # Remove oldest
84
- q.put_nowait((event_name, payload)) # Add new
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
- _maybe_emit("data", _snapshot_data(), last_hashes)
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
- cached_agents_snapshot = get_fleet_agents()
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
- tracker_mtime = (config.STATE_DIR / "tracker.json").stat().st_mtime if (config.STATE_DIR / "tracker.json").exists() else None
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
- if tracker_mtime != last_tracker_mtime:
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
- status_mtime = (config.STATE_DIR / "orchestrator-status.json").stat().st_mtime if (config.STATE_DIR / "orchestrator-status.json").exists() else None
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
- if status_mtime != last_status_mtime:
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 (wave-14 6th section; mtime-gated on the
155
- # outcomes ledger, mirroring the tracker gate above)
237
+ # Emit cost section (mtime+size-gated on the outcomes ledger)
156
238
  try:
157
- cost_mtime = config.LEDGER_FILE.stat().st_mtime if config.LEDGER_FILE.exists() else None
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
- if cost_mtime != last_cost_mtime:
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