@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/tools/ci_merge_wait.py
CHANGED
|
@@ -75,7 +75,26 @@ def get_pr_status(pr_number):
|
|
|
75
75
|
|
|
76
76
|
def check_ci_status(status_rollup):
|
|
77
77
|
"""
|
|
78
|
-
Analyze status check rollup.
|
|
78
|
+
Analyze status check rollup from gh pr view --json statusCheckRollup.
|
|
79
|
+
Handles both CheckRun (status + conclusion) and StatusContext (state) entries.
|
|
80
|
+
|
|
81
|
+
CheckRun classification:
|
|
82
|
+
- status=COMPLETED + conclusion in (FAILURE, CANCELLED, TIMED_OUT, ACTION_REQUIRED, STARTUP_FAILURE) = FAILURE
|
|
83
|
+
- status=COMPLETED + conclusion in (NEUTRAL, SKIPPED) or None/empty = SUCCESS (non-blocking per GitHub)
|
|
84
|
+
- status in (QUEUED, IN_PROGRESS) = PENDING
|
|
85
|
+
- status not recognized = PENDING (fail-closed)
|
|
86
|
+
|
|
87
|
+
StatusContext classification:
|
|
88
|
+
- state='success' = SUCCESS
|
|
89
|
+
- state in ('failure', 'error') = FAILURE
|
|
90
|
+
- state='pending' = PENDING
|
|
91
|
+
- state in ('neutral', 'skipped') = SUCCESS (non-blocking advisory checks)
|
|
92
|
+
- state not recognized = PENDING (fail-closed)
|
|
93
|
+
|
|
94
|
+
Unrecognized check shapes (no status/state field) = PENDING (fail-closed).
|
|
95
|
+
|
|
96
|
+
GitHub semantics: NEUTRAL and SKIPPED conclusions/states are non-blocking and do not prevent merge.
|
|
97
|
+
|
|
79
98
|
Returns: ("pending", None), ("success", None), or ("failure", check_name)
|
|
80
99
|
"""
|
|
81
100
|
if not status_rollup:
|
|
@@ -87,11 +106,53 @@ def check_ci_status(status_rollup):
|
|
|
87
106
|
failed_checks = []
|
|
88
107
|
|
|
89
108
|
for check in status_rollup:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
check_name = check.get("name", "unknown")
|
|
110
|
+
check_status = None
|
|
111
|
+
|
|
112
|
+
# Determine check classification (CheckRun or StatusContext or unrecognized)
|
|
113
|
+
if "status" in check:
|
|
114
|
+
# CheckRun entry (has 'status' field)
|
|
115
|
+
status = check.get("status", "").upper()
|
|
116
|
+
conclusion = check.get("conclusion", "")
|
|
117
|
+
|
|
118
|
+
if status == "COMPLETED":
|
|
119
|
+
# Check conclusion for failure indicators
|
|
120
|
+
if conclusion and conclusion.upper() in ("FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"):
|
|
121
|
+
check_status = "failure"
|
|
122
|
+
else:
|
|
123
|
+
# COMPLETED with no/empty conclusion or other conclusion = success
|
|
124
|
+
check_status = "success"
|
|
125
|
+
elif status in ("QUEUED", "IN_PROGRESS"):
|
|
126
|
+
check_status = "pending"
|
|
127
|
+
else:
|
|
128
|
+
# Unrecognized status value (fail-closed)
|
|
129
|
+
check_status = "pending"
|
|
130
|
+
|
|
131
|
+
elif "state" in check:
|
|
132
|
+
# StatusContext entry (has 'state' field)
|
|
133
|
+
state = check.get("state", "").lower()
|
|
134
|
+
if state == "success":
|
|
135
|
+
check_status = "success"
|
|
136
|
+
elif state in ("failure", "error"):
|
|
137
|
+
check_status = "failure"
|
|
138
|
+
elif state == "pending":
|
|
139
|
+
check_status = "pending"
|
|
140
|
+
elif state in ("neutral", "skipped"):
|
|
141
|
+
# Non-blocking advisory or skipped checks (GitHub semantics)
|
|
142
|
+
check_status = "success"
|
|
143
|
+
else:
|
|
144
|
+
# Unrecognized state value (fail-closed)
|
|
145
|
+
check_status = "pending"
|
|
146
|
+
|
|
147
|
+
else:
|
|
148
|
+
# Unrecognized shape (no status or state field) - fail-closed
|
|
149
|
+
check_status = "pending"
|
|
150
|
+
|
|
151
|
+
# Collect check status
|
|
152
|
+
if check_status == "failure":
|
|
153
|
+
failed_checks.append(check_name)
|
|
154
|
+
elif check_status == "pending":
|
|
155
|
+
pending_checks.append(check_name)
|
|
95
156
|
|
|
96
157
|
# Determine overall status
|
|
97
158
|
if failed_checks:
|
|
@@ -124,54 +185,174 @@ def merge_pr(pr_number, merge_method, dry_run=False):
|
|
|
124
185
|
|
|
125
186
|
def run_self_test():
|
|
126
187
|
"""
|
|
127
|
-
Run self-test with
|
|
128
|
-
No network calls; verifies the merge guard logic.
|
|
188
|
+
Run self-test with real GitHub API payload structures.
|
|
189
|
+
No network calls; verifies the merge guard logic with CheckRun and StatusContext payloads.
|
|
129
190
|
Returns True if all tests pass, False otherwise.
|
|
130
191
|
"""
|
|
131
|
-
print("Running self-test with
|
|
192
|
+
print("Running self-test with real GitHub API payloads...")
|
|
132
193
|
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
{"name": "test-unit", "status": "
|
|
136
|
-
{"name": "test-integration", "status": "
|
|
137
|
-
{"name": "lint", "status": "
|
|
194
|
+
# Test 1: CheckRun success case (COMPLETED with no conclusion)
|
|
195
|
+
checkrun_success = [
|
|
196
|
+
{"name": "test-unit", "status": "COMPLETED", "conclusion": None},
|
|
197
|
+
{"name": "test-integration", "status": "COMPLETED", "conclusion": ""},
|
|
198
|
+
{"name": "lint", "status": "COMPLETED", "conclusion": None},
|
|
138
199
|
]
|
|
139
|
-
|
|
140
|
-
# Test 1: success case
|
|
141
|
-
ci_status, failed_check = check_ci_status(success_rollup)
|
|
200
|
+
ci_status, failed_check = check_ci_status(checkrun_success)
|
|
142
201
|
if ci_status != "success":
|
|
143
|
-
print(f"FAIL: Expected 'success', got '{ci_status}'")
|
|
202
|
+
print(f"FAIL: Expected 'success' for CheckRun COMPLETED, got '{ci_status}'")
|
|
144
203
|
return False
|
|
145
|
-
print("[OK] success case
|
|
204
|
+
print("[OK] CheckRun success case (COMPLETED + no conclusion)")
|
|
146
205
|
|
|
147
|
-
# Test 2:
|
|
148
|
-
|
|
149
|
-
{"name": "test-unit", "status": "
|
|
150
|
-
{"name": "test-integration", "status": "
|
|
206
|
+
# Test 2: CheckRun in-progress case (should be pending)
|
|
207
|
+
checkrun_pending = [
|
|
208
|
+
{"name": "test-unit", "status": "IN_PROGRESS", "conclusion": None},
|
|
209
|
+
{"name": "test-integration", "status": "COMPLETED", "conclusion": None},
|
|
151
210
|
]
|
|
152
|
-
ci_status, failed_check = check_ci_status(
|
|
211
|
+
ci_status, failed_check = check_ci_status(checkrun_pending)
|
|
153
212
|
if ci_status != "pending":
|
|
154
|
-
print(f"FAIL: Expected 'pending', got '{ci_status}'")
|
|
213
|
+
print(f"FAIL: Expected 'pending' for IN_PROGRESS, got '{ci_status}'")
|
|
155
214
|
return False
|
|
156
|
-
print("[OK] pending case
|
|
215
|
+
print("[OK] CheckRun pending case (IN_PROGRESS)")
|
|
157
216
|
|
|
158
|
-
# Test 3: failure case
|
|
159
|
-
|
|
160
|
-
{"name": "test-unit", "status": "FAILURE"},
|
|
161
|
-
{"name": "test-integration", "status": "
|
|
217
|
+
# Test 3: CheckRun failure case (COMPLETED with FAILURE conclusion)
|
|
218
|
+
checkrun_failure = [
|
|
219
|
+
{"name": "test-unit", "status": "COMPLETED", "conclusion": "FAILURE"},
|
|
220
|
+
{"name": "test-integration", "status": "COMPLETED", "conclusion": None},
|
|
162
221
|
]
|
|
163
|
-
ci_status, failed_check = check_ci_status(
|
|
222
|
+
ci_status, failed_check = check_ci_status(checkrun_failure)
|
|
164
223
|
if ci_status != "failure" or failed_check != "test-unit":
|
|
165
224
|
print(f"FAIL: Expected 'failure' with 'test-unit', got '{ci_status}' / '{failed_check}'")
|
|
166
225
|
return False
|
|
167
|
-
print("[OK] failure case
|
|
226
|
+
print("[OK] CheckRun failure case (COMPLETED + FAILURE)")
|
|
168
227
|
|
|
169
|
-
# Test 4:
|
|
228
|
+
# Test 4: StatusContext success case (state=success)
|
|
229
|
+
statuscontext_success = [
|
|
230
|
+
{"name": "continuous-integration/travis-ci/push", "state": "success"},
|
|
231
|
+
]
|
|
232
|
+
ci_status, failed_check = check_ci_status(statuscontext_success)
|
|
233
|
+
if ci_status != "success":
|
|
234
|
+
print(f"FAIL: Expected 'success' for StatusContext state=success, got '{ci_status}'")
|
|
235
|
+
return False
|
|
236
|
+
print("[OK] StatusContext success case (state=success)")
|
|
237
|
+
|
|
238
|
+
# Test 5: StatusContext failure case (state=failure)
|
|
239
|
+
statuscontext_failure = [
|
|
240
|
+
{"name": "continuous-integration/travis-ci/push", "state": "failure"},
|
|
241
|
+
]
|
|
242
|
+
ci_status, failed_check = check_ci_status(statuscontext_failure)
|
|
243
|
+
if ci_status != "failure" or failed_check != "continuous-integration/travis-ci/push":
|
|
244
|
+
print(f"FAIL: Expected 'failure' with StatusContext, got '{ci_status}' / '{failed_check}'")
|
|
245
|
+
return False
|
|
246
|
+
print("[OK] StatusContext failure case (state=failure)")
|
|
247
|
+
|
|
248
|
+
# Test 6: StatusContext pending case (state=pending)
|
|
249
|
+
statuscontext_pending = [
|
|
250
|
+
{"name": "continuous-integration/travis-ci/push", "state": "pending"},
|
|
251
|
+
]
|
|
252
|
+
ci_status, failed_check = check_ci_status(statuscontext_pending)
|
|
253
|
+
if ci_status != "pending":
|
|
254
|
+
print(f"FAIL: Expected 'pending' for StatusContext state=pending, got '{ci_status}'")
|
|
255
|
+
return False
|
|
256
|
+
print("[OK] StatusContext pending case (state=pending)")
|
|
257
|
+
|
|
258
|
+
# Test 7: Mixed CheckRun and StatusContext (all success)
|
|
259
|
+
mixed_success = [
|
|
260
|
+
{"name": "test-unit", "status": "COMPLETED", "conclusion": None},
|
|
261
|
+
{"name": "travis-ci", "state": "success"},
|
|
262
|
+
]
|
|
263
|
+
ci_status, _ = check_ci_status(mixed_success)
|
|
264
|
+
if ci_status != "success":
|
|
265
|
+
print(f"FAIL: Expected 'success' for mixed payloads, got '{ci_status}'")
|
|
266
|
+
return False
|
|
267
|
+
print("[OK] Mixed CheckRun + StatusContext success case")
|
|
268
|
+
|
|
269
|
+
# Test 8: Mixed payloads with pending (should block merge)
|
|
270
|
+
mixed_pending = [
|
|
271
|
+
{"name": "test-unit", "status": "COMPLETED", "conclusion": None},
|
|
272
|
+
{"name": "lint", "status": "QUEUED", "conclusion": None},
|
|
273
|
+
]
|
|
274
|
+
ci_status, _ = check_ci_status(mixed_pending)
|
|
275
|
+
if ci_status != "pending":
|
|
276
|
+
print(f"FAIL: Expected 'pending' for QUEUED check, got '{ci_status}'")
|
|
277
|
+
return False
|
|
278
|
+
print("[OK] Mixed payloads with QUEUED (pending)")
|
|
279
|
+
|
|
280
|
+
# Test 9: No checks (treat as success)
|
|
170
281
|
ci_status, _ = check_ci_status([])
|
|
171
282
|
if ci_status != "success":
|
|
172
283
|
print(f"FAIL: Expected 'success' for no checks, got '{ci_status}'")
|
|
173
284
|
return False
|
|
174
|
-
print("[OK]
|
|
285
|
+
print("[OK] No-checks case: treated as success")
|
|
286
|
+
|
|
287
|
+
# Test 10: Unrecognized shape (fail-closed: should not succeed)
|
|
288
|
+
unrecognized = [
|
|
289
|
+
{"name": "mystery-check"},
|
|
290
|
+
]
|
|
291
|
+
ci_status, _ = check_ci_status(unrecognized)
|
|
292
|
+
if ci_status == "success":
|
|
293
|
+
print(f"FAIL: Expected not 'success' for unrecognized shape, got '{ci_status}'")
|
|
294
|
+
return False
|
|
295
|
+
print("[OK] Unrecognized check shape (fail-closed)")
|
|
296
|
+
|
|
297
|
+
# Test 11: CheckRun cancelled (counts as failure)
|
|
298
|
+
cancelled_check = [
|
|
299
|
+
{"name": "test-unit", "status": "COMPLETED", "conclusion": "CANCELLED"},
|
|
300
|
+
]
|
|
301
|
+
ci_status, _ = check_ci_status(cancelled_check)
|
|
302
|
+
if ci_status != "failure":
|
|
303
|
+
print(f"FAIL: Expected 'failure' for CANCELLED, got '{ci_status}'")
|
|
304
|
+
return False
|
|
305
|
+
print("[OK] CheckRun CANCELLED (failure)")
|
|
306
|
+
|
|
307
|
+
# Test 12: CheckRun neutral conclusion (non-blocking advisory, should be success)
|
|
308
|
+
neutral_check = [
|
|
309
|
+
{"name": "advisory-lint", "status": "COMPLETED", "conclusion": "NEUTRAL"},
|
|
310
|
+
]
|
|
311
|
+
ci_status, _ = check_ci_status(neutral_check)
|
|
312
|
+
if ci_status != "success":
|
|
313
|
+
print(f"FAIL: Expected 'success' for NEUTRAL conclusion, got '{ci_status}'")
|
|
314
|
+
return False
|
|
315
|
+
print("[OK] CheckRun NEUTRAL conclusion (success, non-blocking)")
|
|
316
|
+
|
|
317
|
+
# Test 13: CheckRun skipped conclusion (non-blocking, should be success)
|
|
318
|
+
skipped_check = [
|
|
319
|
+
{"name": "optional-test", "status": "COMPLETED", "conclusion": "SKIPPED"},
|
|
320
|
+
]
|
|
321
|
+
ci_status, _ = check_ci_status(skipped_check)
|
|
322
|
+
if ci_status != "success":
|
|
323
|
+
print(f"FAIL: Expected 'success' for SKIPPED conclusion, got '{ci_status}'")
|
|
324
|
+
return False
|
|
325
|
+
print("[OK] CheckRun SKIPPED conclusion (success, non-blocking)")
|
|
326
|
+
|
|
327
|
+
# Test 14: StatusContext neutral state (non-blocking advisory, should be success)
|
|
328
|
+
neutral_state = [
|
|
329
|
+
{"name": "advisory-check", "state": "neutral"},
|
|
330
|
+
]
|
|
331
|
+
ci_status, _ = check_ci_status(neutral_state)
|
|
332
|
+
if ci_status != "success":
|
|
333
|
+
print(f"FAIL: Expected 'success' for StatusContext state=neutral, got '{ci_status}'")
|
|
334
|
+
return False
|
|
335
|
+
print("[OK] StatusContext neutral state (success, non-blocking)")
|
|
336
|
+
|
|
337
|
+
# Test 15: StatusContext skipped state (non-blocking, should be success)
|
|
338
|
+
skipped_state = [
|
|
339
|
+
{"name": "optional-check", "state": "skipped"},
|
|
340
|
+
]
|
|
341
|
+
ci_status, _ = check_ci_status(skipped_state)
|
|
342
|
+
if ci_status != "success":
|
|
343
|
+
print(f"FAIL: Expected 'success' for StatusContext state=skipped, got '{ci_status}'")
|
|
344
|
+
return False
|
|
345
|
+
print("[OK] StatusContext skipped state (success, non-blocking)")
|
|
346
|
+
|
|
347
|
+
# Test 16: Fabricated unknown state (fail-closed to pending)
|
|
348
|
+
unknown_state = [
|
|
349
|
+
{"name": "mystery-state", "state": "fabricated_unknown_state"},
|
|
350
|
+
]
|
|
351
|
+
ci_status, _ = check_ci_status(unknown_state)
|
|
352
|
+
if ci_status != "pending":
|
|
353
|
+
print(f"FAIL: Expected 'pending' for unknown state, got '{ci_status}'")
|
|
354
|
+
return False
|
|
355
|
+
print("[OK] Fabricated unknown state (pending, fail-closed)")
|
|
175
356
|
|
|
176
357
|
print("\nAll self-tests passed!")
|
|
177
358
|
return True
|
package/tools/common.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Common utilities shared across tools.
|
|
4
|
+
|
|
5
|
+
Functions:
|
|
6
|
+
get_state_dir() -> Path
|
|
7
|
+
Resolve state directory from AESOP_STATE_ROOT env var or default to ./state
|
|
8
|
+
|
|
9
|
+
check_heartbeat_staleness(hb_file, threshold_s) -> (is_stale, age_s, info)
|
|
10
|
+
Check if a heartbeat file is stale and return staleness, age, and descriptive info
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_state_dir():
|
|
19
|
+
"""Resolve state directory from env var or current working directory.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Path: Directory path for state files. Either from AESOP_STATE_ROOT env var
|
|
23
|
+
or defaults to ./state relative to cwd.
|
|
24
|
+
"""
|
|
25
|
+
if os.environ.get("AESOP_STATE_ROOT"):
|
|
26
|
+
return Path(os.environ["AESOP_STATE_ROOT"])
|
|
27
|
+
# Default to ./state (relative to cwd)
|
|
28
|
+
return Path.cwd() / "state"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_heartbeat_staleness(hb_file, threshold_s):
|
|
32
|
+
"""Check if a heartbeat file is stale.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
hb_file: Path to heartbeat file (contains epoch timestamp as first line)
|
|
36
|
+
threshold_s: Staleness threshold in seconds; age >= threshold is stale
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Tuple of (is_stale, age_s, info):
|
|
40
|
+
is_stale (bool): True if file missing, unreadable, or age >= threshold_s
|
|
41
|
+
age_s (int): Age in seconds (0 if file missing/unreadable)
|
|
42
|
+
info (str or None): Descriptive message if stale/missing, None if fresh
|
|
43
|
+
"""
|
|
44
|
+
if not hb_file.exists():
|
|
45
|
+
return True, 0, "Heartbeat file missing"
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
content = hb_file.read_text(encoding="utf-8").strip()
|
|
49
|
+
if not content:
|
|
50
|
+
return True, 0, "Heartbeat file empty"
|
|
51
|
+
|
|
52
|
+
timestamp = int(content)
|
|
53
|
+
except (ValueError, IOError):
|
|
54
|
+
return True, 0, "Heartbeat file unreadable"
|
|
55
|
+
|
|
56
|
+
age_seconds = int(time.time()) - timestamp
|
|
57
|
+
age_seconds = max(0, age_seconds) # Never negative
|
|
58
|
+
|
|
59
|
+
if age_seconds >= threshold_s:
|
|
60
|
+
return True, age_seconds, f"Heartbeat stale ({age_seconds}s >= {threshold_s}s)"
|
|
61
|
+
|
|
62
|
+
return False, age_seconds, None
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cost ceiling guard — trips tools/halt.py's kill switch when fleet spend
|
|
4
|
+
(tokens) meets or exceeds a configured ceiling.
|
|
5
|
+
|
|
6
|
+
Why: wave-26 critique — autonomy expanded to self-merging portfolio PRs on
|
|
7
|
+
green with no ceiling/cap/limit/abort anywhere in the harness. This is the cap.
|
|
8
|
+
|
|
9
|
+
Configuration (aesop.config.json):
|
|
10
|
+
"limits": {
|
|
11
|
+
"max_wave_tokens": null, # null = disabled (opt-in)
|
|
12
|
+
"max_daily_tokens": null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Spend source: an explicit --spent/spent= figure always wins. Otherwise spend
|
|
16
|
+
is read from the cost ledger (tools/fleet_ledger.py's OUTCOMES-LEDGER.md under
|
|
17
|
+
the resolved state dir): sum of tokens_in + tokens_out across all ledger rows.
|
|
18
|
+
Missing/unreadable ledger -> spend of 0 (never trips on a ledger that doesn't
|
|
19
|
+
exist yet).
|
|
20
|
+
|
|
21
|
+
API:
|
|
22
|
+
check(spent=None, period="wave", config=None, state_dir=None, trip=True) -> dict
|
|
23
|
+
Returns {"period", "ceiling", "spent", "exceeded", "tripped"}.
|
|
24
|
+
When exceeded and trip=True, calls tools/halt.py's halt() with a reason
|
|
25
|
+
describing the breach, and "tripped" is True. When ceiling is None
|
|
26
|
+
(unconfigured), exceeded is always False and nothing is ever tripped.
|
|
27
|
+
|
|
28
|
+
CLI:
|
|
29
|
+
python tools/cost_ceiling.py --check --spent N [--period wave|daily]
|
|
30
|
+
Exit 0 if not exceeded (or ceiling unconfigured), exit 1 if exceeded
|
|
31
|
+
(and thus tripped, unless already halted).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import halt
|
|
42
|
+
import fleet_ledger
|
|
43
|
+
except ImportError:
|
|
44
|
+
from tools import halt
|
|
45
|
+
from tools import fleet_ledger
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config():
|
|
49
|
+
"""Load aesop.config.json from current directory, return dict (or {} if absent/bad)."""
|
|
50
|
+
config_file = Path("aesop.config.json")
|
|
51
|
+
if not config_file.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"[cost_ceiling] Failed to load config: {e}", file=sys.stderr)
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_ceiling(config, period):
|
|
62
|
+
"""Extract the configured ceiling for `period` ('wave' or 'daily'), or None."""
|
|
63
|
+
limits = config.get("limits", {}) if isinstance(config, dict) else {}
|
|
64
|
+
if not isinstance(limits, dict):
|
|
65
|
+
return None
|
|
66
|
+
key = f"max_{period}_tokens"
|
|
67
|
+
value = limits.get(key)
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return int(value)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_ledger_total_tokens(state_dir, period="wave"):
|
|
77
|
+
"""Sum tokens_in + tokens_out from OUTCOMES-LEDGER.md.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
state_dir: path to state directory
|
|
81
|
+
period: "wave" (all rows) or "daily" (today's rows only, filtered by UTC date)
|
|
82
|
+
|
|
83
|
+
Returns 0 if the ledger doesn't exist or is unreadable/empty.
|
|
84
|
+
Uses fleet_ledger.py's shared parser (single source of truth).
|
|
85
|
+
"""
|
|
86
|
+
# Set the state root temporarily so fleet_ledger can find the ledger
|
|
87
|
+
import os
|
|
88
|
+
old_state_root = os.environ.get("AESOP_STATE_ROOT")
|
|
89
|
+
try:
|
|
90
|
+
os.environ["AESOP_STATE_ROOT"] = str(state_dir)
|
|
91
|
+
rows = fleet_ledger.parse_ledger_rows()
|
|
92
|
+
finally:
|
|
93
|
+
if old_state_root is not None:
|
|
94
|
+
os.environ["AESOP_STATE_ROOT"] = old_state_root
|
|
95
|
+
else:
|
|
96
|
+
os.environ.pop("AESOP_STATE_ROOT", None)
|
|
97
|
+
|
|
98
|
+
if not rows:
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
total = 0
|
|
102
|
+
|
|
103
|
+
if period == "daily":
|
|
104
|
+
# Filter to today's UTC date only
|
|
105
|
+
today_utc = datetime.now(timezone.utc).date()
|
|
106
|
+
for row in rows:
|
|
107
|
+
# Extract date from ISO timestamp (format: YYYY-MM-DDTHH:MM:SSZ or similar)
|
|
108
|
+
try:
|
|
109
|
+
iso_ts = row['iso_ts']
|
|
110
|
+
# Parse the date part (first 10 characters: YYYY-MM-DD)
|
|
111
|
+
row_date = datetime.fromisoformat(iso_ts.replace('Z', '+00:00')).date()
|
|
112
|
+
if row_date == today_utc:
|
|
113
|
+
total += row['tokens_in'] + row['tokens_out']
|
|
114
|
+
except (ValueError, IndexError, KeyError):
|
|
115
|
+
# Skip malformed timestamps
|
|
116
|
+
continue
|
|
117
|
+
else:
|
|
118
|
+
# period == "wave": sum all rows
|
|
119
|
+
for row in rows:
|
|
120
|
+
total += row['tokens_in'] + row['tokens_out']
|
|
121
|
+
|
|
122
|
+
return total
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check(spent=None, period="wave", config=None, state_dir=None, trip=True):
|
|
126
|
+
"""Check spend against the configured ceiling for `period`.
|
|
127
|
+
|
|
128
|
+
Returns a dict: {"period", "ceiling", "spent", "exceeded", "tripped"}.
|
|
129
|
+
"""
|
|
130
|
+
if config is None:
|
|
131
|
+
config = load_config()
|
|
132
|
+
if state_dir is None:
|
|
133
|
+
state_dir = halt.resolve_state_dir(config=config)
|
|
134
|
+
state_dir = Path(state_dir)
|
|
135
|
+
|
|
136
|
+
ceiling = get_ceiling(config, period)
|
|
137
|
+
|
|
138
|
+
if spent is None:
|
|
139
|
+
spent = read_ledger_total_tokens(state_dir, period=period)
|
|
140
|
+
spent = int(spent)
|
|
141
|
+
|
|
142
|
+
exceeded = ceiling is not None and spent >= ceiling
|
|
143
|
+
tripped = False
|
|
144
|
+
|
|
145
|
+
if exceeded and trip:
|
|
146
|
+
reason = (
|
|
147
|
+
f"cost ceiling exceeded: {period} spend {spent} tokens >= "
|
|
148
|
+
f"ceiling {ceiling} tokens"
|
|
149
|
+
)
|
|
150
|
+
halt.halt(reason, state_dir=state_dir)
|
|
151
|
+
tripped = True
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"period": period,
|
|
155
|
+
"ceiling": ceiling,
|
|
156
|
+
"spent": spent,
|
|
157
|
+
"exceeded": exceeded,
|
|
158
|
+
"tripped": tripped,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv=None):
|
|
163
|
+
parser = argparse.ArgumentParser(description="Cost ceiling guard for the fleet kill switch.")
|
|
164
|
+
parser.add_argument("--check", action="store_true", help="Run the ceiling check (required).")
|
|
165
|
+
parser.add_argument("--spent", type=int, default=None, help="Explicit spend figure in tokens; defaults to ledger total.")
|
|
166
|
+
parser.add_argument("--period", choices=("wave", "daily"), default="wave", help="Which ceiling to check against (default: wave).")
|
|
167
|
+
args = parser.parse_args(argv)
|
|
168
|
+
|
|
169
|
+
if not args.check:
|
|
170
|
+
parser.print_usage(sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
|
|
173
|
+
result = check(spent=args.spent, period=args.period)
|
|
174
|
+
|
|
175
|
+
if result["ceiling"] is None:
|
|
176
|
+
print(f"[cost_ceiling] no {args.period} ceiling configured — skipping (spent={result['spent']})")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if result["exceeded"]:
|
|
180
|
+
print(
|
|
181
|
+
f"[cost_ceiling] EXCEEDED: {args.period} spend {result['spent']} >= "
|
|
182
|
+
f"ceiling {result['ceiling']} — HALT tripped"
|
|
183
|
+
)
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
print(f"[cost_ceiling] ok: {args.period} spend {result['spent']} < ceiling {result['ceiling']}")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
sys.exit(main())
|
package/tools/dash.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aesop dash — Launch the web dashboard
|
|
5
|
+
*
|
|
6
|
+
* Spawns python ui/serve.py with the configured port.
|
|
7
|
+
* Honors PORT environment variable, aesop.config.json dashboard.port, or defaults to 8770.
|
|
8
|
+
* Prints the dashboard URL before launching.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { spawn, spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const CURRENT_DIR = process.cwd();
|
|
16
|
+
|
|
17
|
+
// Resolve Python interpreter portably (prefer python3, fallback to python)
|
|
18
|
+
function resolvePythonInterpreter() {
|
|
19
|
+
if (spawnSync('python3', ['--version'], { stdio: 'ignore' }).error === undefined) {
|
|
20
|
+
return 'python3';
|
|
21
|
+
}
|
|
22
|
+
if (spawnSync('python', ['--version'], { stdio: 'ignore' }).error === undefined) {
|
|
23
|
+
return 'python';
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadConfig() {
|
|
29
|
+
const configPath = path.join(CURRENT_DIR, 'aesop.config.json');
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(configPath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
35
|
+
return JSON.parse(content);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
(async function main() {
|
|
42
|
+
try {
|
|
43
|
+
// Verify dashboard script exists
|
|
44
|
+
const dashScript = path.join(CURRENT_DIR, 'ui', 'serve.py');
|
|
45
|
+
if (!fs.existsSync(dashScript)) {
|
|
46
|
+
console.error(`Error: dashboard script not found at ${dashScript}`);
|
|
47
|
+
console.error('Are you running this from the aesop root directory?');
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Determine port: env var > config > default
|
|
53
|
+
let port = 8770;
|
|
54
|
+
|
|
55
|
+
if (process.env.PORT) {
|
|
56
|
+
const envPort = parseInt(process.env.PORT, 10);
|
|
57
|
+
if (!isNaN(envPort) && envPort > 0 && envPort < 65536) {
|
|
58
|
+
port = envPort;
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
const config = loadConfig();
|
|
62
|
+
if (config && config.dashboard && config.dashboard.port) {
|
|
63
|
+
const configPort = parseInt(config.dashboard.port, 10);
|
|
64
|
+
if (!isNaN(configPort) && configPort > 0 && configPort < 65536) {
|
|
65
|
+
port = configPort;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`Launching aesop dashboard on http://localhost:${port}\n`);
|
|
71
|
+
|
|
72
|
+
// Resolve Python interpreter
|
|
73
|
+
const pythonExe = resolvePythonInterpreter();
|
|
74
|
+
if (!pythonExe) {
|
|
75
|
+
console.error('Error: python3 or python not found on PATH');
|
|
76
|
+
console.error('Please install Python 3 to run the dashboard');
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Spawn the dashboard script with PORT env var set
|
|
82
|
+
const proc = spawn(pythonExe, [dashScript], {
|
|
83
|
+
cwd: CURRENT_DIR,
|
|
84
|
+
stdio: 'inherit',
|
|
85
|
+
env: { ...process.env, PORT: port.toString() },
|
|
86
|
+
shell: process.platform === 'win32'
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Exit with the dashboard process's exit code
|
|
90
|
+
proc.on('exit', (code) => {
|
|
91
|
+
process.exitCode = code || 0;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
proc.on('error', (err) => {
|
|
95
|
+
console.error(`Error spawning dashboard: ${err.message}`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(`Error launching dashboard: ${err.message}`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
})();
|