@matt82198/aesop 0.1.0 → 0.2.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 +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Browser-level proof for the DispatchPanel component (ui/web/dist/ + /api/wave/dispatch).
|
|
2
|
+
|
|
3
|
+
Drives the BUILT React app served by `python ui/serve.py` and exercises the
|
|
4
|
+
full stack of the dispatch panel — route wiring, component mount, the real
|
|
5
|
+
GET /api/wave/dispatch endpoint, polling behavior, and rendering — in a real
|
|
6
|
+
Chromium via Playwright.
|
|
7
|
+
|
|
8
|
+
Two phases, each a fresh server boot with fixture agent transcripts:
|
|
9
|
+
|
|
10
|
+
Available: fixture agents with varying phases (tool-use/thinking/stall) →
|
|
11
|
+
(a) console clean
|
|
12
|
+
(b) Activity view mounts and DispatchPanel is visible
|
|
13
|
+
(c) agent rows render with id, phase badge, age, tokens
|
|
14
|
+
(d) warnings display for inactive agents (age >5min)
|
|
15
|
+
(e) polling works: data updates on timer
|
|
16
|
+
(f) wave_phase header shows current wave info
|
|
17
|
+
|
|
18
|
+
Unavailable (no agents): empty transcripts dir →
|
|
19
|
+
(g) DispatchPanel shows "No active workflow" message
|
|
20
|
+
|
|
21
|
+
Run: python tools/verify_dispatch_panel.py (exit 0 = proven, 1 = failed)
|
|
22
|
+
python tools/verify_dispatch_panel.py --allow-skip (exit 0 = proven or skipped)
|
|
23
|
+
|
|
24
|
+
Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip).
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import socket
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
38
|
+
SERVE = REPO / "ui" / "serve.py"
|
|
39
|
+
|
|
40
|
+
# Generous, CI-safe waits.
|
|
41
|
+
SERVER_BOOT_TRIES = int(os.environ.get("AESOP_VERIFY_BOOT_TRIES", "150"))
|
|
42
|
+
SERVER_BOOT_SLEEP = 0.2
|
|
43
|
+
SEL_TIMEOUT_MS = int(os.environ.get("AESOP_VERIFY_SEL_TIMEOUT_MS", "30000"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def free_port():
|
|
47
|
+
s = socket.socket()
|
|
48
|
+
s.bind(("127.0.0.1", 0))
|
|
49
|
+
port = s.getsockname()[1]
|
|
50
|
+
s.close()
|
|
51
|
+
return port
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def copy_dist(root: Path):
|
|
55
|
+
real_dist = REPO / "ui" / "web" / "dist"
|
|
56
|
+
if real_dist.is_dir():
|
|
57
|
+
shutil.copytree(real_dist, root / "ui" / "web" / "dist")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_root_with_agents(num_agents=3):
|
|
61
|
+
"""Fresh temp root with dist + fixture agents; returns root."""
|
|
62
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-dispatch-panel-"))
|
|
63
|
+
(root / "state").mkdir(exist_ok=True)
|
|
64
|
+
|
|
65
|
+
# Create agent transcripts in real Claude Code layout
|
|
66
|
+
# Real structure: {project}/subagents/agent-*.jsonl (not memory/)
|
|
67
|
+
transcripts_root = root / "transcripts" / "aesop" / "subagents"
|
|
68
|
+
transcripts_root.mkdir(parents=True)
|
|
69
|
+
|
|
70
|
+
# Create fixture agents
|
|
71
|
+
agents = [
|
|
72
|
+
("fleet-fix-0", "tool-use", 0), # Fresh, tool-use phase
|
|
73
|
+
("fleet-fix-1", "stall", 500), # Old, stalled (>5min)
|
|
74
|
+
("fleet-review-0", "thinking", 15), # Recent, thinking
|
|
75
|
+
][:num_agents]
|
|
76
|
+
|
|
77
|
+
for agent_id, phase_hint, age_sec in agents:
|
|
78
|
+
agent_path = transcripts_root / f"agent-{agent_id}.jsonl"
|
|
79
|
+
# Create minimal NDJSON
|
|
80
|
+
if "tool" in phase_hint:
|
|
81
|
+
content = json.dumps({"type": "assistant", "text": "[tool_use: write]"}) + "\n"
|
|
82
|
+
elif "think" in phase_hint:
|
|
83
|
+
content = json.dumps({"type": "assistant", "text": "Assistant thinking"}) + "\n"
|
|
84
|
+
else:
|
|
85
|
+
content = (
|
|
86
|
+
json.dumps({"type": "assistant", "text": "thinking"}) + "\n" +
|
|
87
|
+
json.dumps({"type": "assistant", "text": "done"}) + "\n"
|
|
88
|
+
)
|
|
89
|
+
agent_path.write_text(content, encoding='utf-8')
|
|
90
|
+
# Set mtime for age
|
|
91
|
+
now = time.time()
|
|
92
|
+
old_time = now - age_sec
|
|
93
|
+
os.utime(agent_path, (old_time, old_time))
|
|
94
|
+
|
|
95
|
+
# Create dash-extra.mjs (required by dashboard)
|
|
96
|
+
(root / "dash").mkdir(exist_ok=True)
|
|
97
|
+
(root / "dash" / "dash-extra.mjs").write_text(
|
|
98
|
+
"console.log(JSON.stringify([]));\n", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
copy_dist(root)
|
|
101
|
+
return root
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def start_server(root: Path, port: int):
|
|
105
|
+
state_root = root / "state"
|
|
106
|
+
real_state = Path.home() / "aesop" / "state"
|
|
107
|
+
if state_root.resolve() == real_state.resolve():
|
|
108
|
+
raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
|
|
109
|
+
|
|
110
|
+
env = dict(os.environ,
|
|
111
|
+
AESOP_ROOT=str(root),
|
|
112
|
+
AESOP_STATE_ROOT=str(state_root),
|
|
113
|
+
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
114
|
+
AESOP_WEB_DIST=str(REPO / "ui" / "web" / "dist"),
|
|
115
|
+
AESOP_PROOF_FIXTURES="1",
|
|
116
|
+
AESOP_UI_COLLECT_INTERVAL="0.5",
|
|
117
|
+
PORT=str(port))
|
|
118
|
+
server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
|
|
119
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
120
|
+
for _ in range(SERVER_BOOT_TRIES):
|
|
121
|
+
try:
|
|
122
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
123
|
+
return server
|
|
124
|
+
except OSError:
|
|
125
|
+
time.sleep(SERVER_BOOT_SLEEP)
|
|
126
|
+
server.kill()
|
|
127
|
+
raise RuntimeError("server never came up")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def stop_server(server):
|
|
131
|
+
server.terminate()
|
|
132
|
+
try:
|
|
133
|
+
server.wait(timeout=5)
|
|
134
|
+
except subprocess.TimeoutExpired:
|
|
135
|
+
server.kill()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run_available(pw, failures):
|
|
139
|
+
"""Test DispatchPanel with active agents."""
|
|
140
|
+
root = build_root_with_agents(3)
|
|
141
|
+
port = free_port()
|
|
142
|
+
server = start_server(root, port)
|
|
143
|
+
try:
|
|
144
|
+
browser = pw.chromium.launch(headless=True)
|
|
145
|
+
page = browser.new_page()
|
|
146
|
+
console_errors, failed_urls = [], []
|
|
147
|
+
page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
|
|
148
|
+
page.on("pageerror", lambda e: console_errors.append(str(e)))
|
|
149
|
+
page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
153
|
+
# Navigate to Activity view
|
|
154
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
|
|
155
|
+
page.evaluate("location.hash = '#/activity'")
|
|
156
|
+
page.wait_for_selector("[data-testid='view-activity']", timeout=SEL_TIMEOUT_MS)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
failures.append(f"Activity view never mounted: {e}")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# (b) DispatchPanel is visible
|
|
162
|
+
try:
|
|
163
|
+
page.wait_for_selector("[data-testid='dispatch-panel']", timeout=SEL_TIMEOUT_MS)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
failures.append(f"DispatchPanel not visible: {e}")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# (c) Agent rows render with data
|
|
169
|
+
try:
|
|
170
|
+
rows = page.locator("[data-testid='dispatch-agent-row']").count()
|
|
171
|
+
assert rows >= 3, f"expected >=3 agent rows, got {rows}"
|
|
172
|
+
# Check first agent id
|
|
173
|
+
first_id = page.inner_text("[data-testid='dispatch-agent-row']").split()[0]
|
|
174
|
+
assert "fleet" in first_id or "fix" in first_id, f"unexpected agent id: {first_id}"
|
|
175
|
+
except Exception as e:
|
|
176
|
+
failures.append(f"Agent rows not rendered: {e}")
|
|
177
|
+
|
|
178
|
+
# (d) Warnings display for stalled agents
|
|
179
|
+
try:
|
|
180
|
+
body = page.inner_text("[data-testid='dispatch-panel']")
|
|
181
|
+
assert "inactive" in body.lower(), "warning for inactive agent not shown"
|
|
182
|
+
except Exception as e:
|
|
183
|
+
failures.append(f"Warnings not shown: {e}")
|
|
184
|
+
|
|
185
|
+
# (e) Polling works (data updates)
|
|
186
|
+
try:
|
|
187
|
+
# Get initial agent count
|
|
188
|
+
initial_rows = page.locator("[data-testid='dispatch-agent-row']").count()
|
|
189
|
+
# Wait for a poll cycle
|
|
190
|
+
time.sleep(2)
|
|
191
|
+
# SSE keeps connections open indefinitely, so use a short timeout
|
|
192
|
+
try:
|
|
193
|
+
page.wait_for_load_state("domcontentloaded", timeout=500)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass # Timeout expected when SSE is active
|
|
196
|
+
# Rows should still be there (no crash)
|
|
197
|
+
final_rows = page.locator("[data-testid='dispatch-agent-row']").count()
|
|
198
|
+
assert final_rows == initial_rows, "agent count changed during poll"
|
|
199
|
+
except Exception as e:
|
|
200
|
+
failures.append(f"Polling failed: {e}")
|
|
201
|
+
|
|
202
|
+
# (f) Wave phase header
|
|
203
|
+
try:
|
|
204
|
+
# Wave phase should be in the dispatch panel (or null)
|
|
205
|
+
panel = page.locator("[data-testid='dispatch-panel']").inner_text()
|
|
206
|
+
assert "Wave Dispatch" in panel, "header not found"
|
|
207
|
+
except Exception as e:
|
|
208
|
+
failures.append(f"Header not found: {e}")
|
|
209
|
+
|
|
210
|
+
# (a) Console clean
|
|
211
|
+
time.sleep(0.2)
|
|
212
|
+
real_errors = [e for e in console_errors
|
|
213
|
+
if "favicon" not in e.lower()
|
|
214
|
+
and "failed to load resource" not in e.lower()]
|
|
215
|
+
if real_errors:
|
|
216
|
+
failures.append(f"Console errors: {real_errors}")
|
|
217
|
+
|
|
218
|
+
browser.close()
|
|
219
|
+
finally:
|
|
220
|
+
stop_server(server)
|
|
221
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def run_unavailable(pw, failures):
|
|
225
|
+
"""Test DispatchPanel with no active agents."""
|
|
226
|
+
root = build_root_with_agents(0) # No agents
|
|
227
|
+
port = free_port()
|
|
228
|
+
server = start_server(root, port)
|
|
229
|
+
try:
|
|
230
|
+
browser = pw.chromium.launch(headless=True)
|
|
231
|
+
page = browser.new_page()
|
|
232
|
+
console_errors = []
|
|
233
|
+
page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
237
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
|
|
238
|
+
page.evaluate("location.hash = '#/activity'")
|
|
239
|
+
page.wait_for_selector("[data-testid='view-activity']", timeout=SEL_TIMEOUT_MS)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
failures.append(f"Activity view never mounted (unavailable): {e}")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
# (g) Unavailable state renders
|
|
245
|
+
try:
|
|
246
|
+
page.wait_for_selector("[data-testid='dispatch-panel-unavailable']", timeout=SEL_TIMEOUT_MS)
|
|
247
|
+
body = page.inner_text("[data-testid='dispatch-panel-unavailable']")
|
|
248
|
+
assert "No active workflow" in body, "unavailable message not shown"
|
|
249
|
+
except Exception as e:
|
|
250
|
+
failures.append(f"Unavailable state not rendered: {e}")
|
|
251
|
+
|
|
252
|
+
browser.close()
|
|
253
|
+
finally:
|
|
254
|
+
stop_server(server)
|
|
255
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main():
|
|
259
|
+
parser = argparse.ArgumentParser(
|
|
260
|
+
description="Browser proof for DispatchPanel component"
|
|
261
|
+
)
|
|
262
|
+
parser.add_argument(
|
|
263
|
+
"--allow-skip",
|
|
264
|
+
action="store_true",
|
|
265
|
+
help="Exit 0 if playwright unavailable (else 1)"
|
|
266
|
+
)
|
|
267
|
+
args = parser.parse_args()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
import playwright.sync_api as pw_api
|
|
271
|
+
pw = pw_api.sync_playwright().start()
|
|
272
|
+
except ImportError:
|
|
273
|
+
print("playwright not available; skipping browser proof", file=sys.stderr)
|
|
274
|
+
sys.exit(0 if args.allow_skip else 1)
|
|
275
|
+
|
|
276
|
+
failures = []
|
|
277
|
+
|
|
278
|
+
print("DispatchPanel proof: phase 1 (available agents)...", file=sys.stderr)
|
|
279
|
+
try:
|
|
280
|
+
run_available(pw, failures)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
failures.append(f"Phase 1 crashed: {e}")
|
|
283
|
+
|
|
284
|
+
print("DispatchPanel proof: phase 2 (unavailable)...", file=sys.stderr)
|
|
285
|
+
try:
|
|
286
|
+
run_unavailable(pw, failures)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
failures.append(f"Phase 2 crashed: {e}")
|
|
289
|
+
|
|
290
|
+
if failures:
|
|
291
|
+
print("\n=== FAILURES ===", file=sys.stderr)
|
|
292
|
+
for f in failures:
|
|
293
|
+
print(f" {f}", file=sys.stderr)
|
|
294
|
+
return 1
|
|
295
|
+
|
|
296
|
+
print("DispatchPanel proof: PASSED", file=sys.stderr)
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
sys.exit(main())
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""End-to-end verification of the wave failure drill-down feature.
|
|
3
|
+
|
|
4
|
+
Sets up a temporary AESOP_ROOT, stubs the gh CLI, starts the dashboard server,
|
|
5
|
+
and exercises the failure drill-down UI + API via Playwright. Verifies:
|
|
6
|
+
|
|
7
|
+
1. GET /api/wave/failure?pr=N endpoint returns correct shape
|
|
8
|
+
2. FailureDrilldown component toggles expand/collapse
|
|
9
|
+
3. Failure details render when expanded
|
|
10
|
+
4. Graceful degradation when gh is unavailable
|
|
11
|
+
|
|
12
|
+
Run: python tools/verify_failure_drilldown.py
|
|
13
|
+
(or supply --port=<port> for a custom port)
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from playwright.sync_api import sync_playwright, expect
|
|
28
|
+
except ImportError: # import-clean without playwright; main() reports the miss
|
|
29
|
+
sync_playwright = None
|
|
30
|
+
expect = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Paths
|
|
34
|
+
REPO_ROOT = Path(__file__).parent.parent
|
|
35
|
+
UI_PATH = REPO_ROOT / "ui"
|
|
36
|
+
SERVE_PATH = UI_PATH / "serve.py"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_server(fixture_root, port, extra_env=None):
|
|
40
|
+
"""Start the dashboard server in a background thread.
|
|
41
|
+
|
|
42
|
+
Returns: (server_process, cleanup_callback)
|
|
43
|
+
"""
|
|
44
|
+
env = os.environ.copy()
|
|
45
|
+
env["AESOP_ROOT"] = str(fixture_root)
|
|
46
|
+
env["AESOP_STATE_ROOT"] = str(fixture_root / "state")
|
|
47
|
+
# Point to the real repo's built dist (not the fixture root)
|
|
48
|
+
env["AESOP_WEB_DIST"] = str(REPO_ROOT / "ui" / "web" / "dist")
|
|
49
|
+
env["AESOP_PROOF_FIXTURES"] = "1"
|
|
50
|
+
env["PORT"] = str(port)
|
|
51
|
+
env["AESOP_UI_COLLECT_INTERVAL"] = "0.1"
|
|
52
|
+
if extra_env:
|
|
53
|
+
env.update(extra_env)
|
|
54
|
+
|
|
55
|
+
proc = subprocess.Popen(
|
|
56
|
+
[sys.executable, str(SERVE_PATH)],
|
|
57
|
+
env=env,
|
|
58
|
+
cwd=str(UI_PATH),
|
|
59
|
+
stdout=subprocess.PIPE,
|
|
60
|
+
stderr=subprocess.PIPE,
|
|
61
|
+
text=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Wait for server to be ready (crude: sleep and probe)
|
|
65
|
+
for _ in range(30):
|
|
66
|
+
try:
|
|
67
|
+
import http.client
|
|
68
|
+
con = http.client.HTTPConnection("127.0.0.1", port, timeout=1)
|
|
69
|
+
con.request("GET", "/")
|
|
70
|
+
con.close()
|
|
71
|
+
return proc, lambda: proc.terminate()
|
|
72
|
+
except Exception:
|
|
73
|
+
time.sleep(0.1)
|
|
74
|
+
|
|
75
|
+
raise RuntimeError(f"Server failed to start on port {port}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def stub_gh(fixture_root):
|
|
79
|
+
"""Create a stub gh CLI that always fails gracefully.
|
|
80
|
+
|
|
81
|
+
Returns: path to the stub script.
|
|
82
|
+
"""
|
|
83
|
+
stub = fixture_root / "stub-gh"
|
|
84
|
+
stub.write_text(
|
|
85
|
+
"#!/bin/bash\n"
|
|
86
|
+
"echo 'stubbed gh: not authenticated' >&2\n"
|
|
87
|
+
"exit 1\n",
|
|
88
|
+
encoding="utf-8"
|
|
89
|
+
)
|
|
90
|
+
stub.chmod(0o755)
|
|
91
|
+
return stub
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
if sync_playwright is None:
|
|
96
|
+
allow_skip = "--allow-skip" in sys.argv
|
|
97
|
+
msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
|
|
98
|
+
print(f"SKIP: {msg}" if allow_skip else f"FAIL: {msg}")
|
|
99
|
+
sys.exit(0 if allow_skip else 1)
|
|
100
|
+
parser = argparse.ArgumentParser(description="Verify wave failure drill-down feature")
|
|
101
|
+
parser.add_argument("--port", type=int, default=0, help="Dashboard port (default: auto)")
|
|
102
|
+
args = parser.parse_args()
|
|
103
|
+
|
|
104
|
+
fixture_root = Path(tempfile.mkdtemp(prefix="aesop-verify-failure-"))
|
|
105
|
+
port = args.port or 8771 # Use 8771 by default to avoid conflicts
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
print(f"[setup] fixture_root={fixture_root}")
|
|
109
|
+
(fixture_root / "state").mkdir(parents=True)
|
|
110
|
+
(fixture_root / "transcripts").mkdir()
|
|
111
|
+
|
|
112
|
+
stub_gh_path = stub_gh(fixture_root)
|
|
113
|
+
print(f"[setup] stub_gh={stub_gh_path}")
|
|
114
|
+
|
|
115
|
+
# Start server
|
|
116
|
+
extra_env = {"AESOP_GH_BIN": str(stub_gh_path)}
|
|
117
|
+
proc, cleanup = run_server(fixture_root, port, extra_env)
|
|
118
|
+
print(f"[server] started on port {port}")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Run Playwright tests
|
|
122
|
+
with sync_playwright() as p:
|
|
123
|
+
browser = p.chromium.launch(headless=True)
|
|
124
|
+
page = browser.new_page()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Test 1: GET /api/wave/failure endpoint
|
|
128
|
+
print("[test] GET /api/wave/failure?pr=123")
|
|
129
|
+
response = page.request.get(f"http://127.0.0.1:{port}/api/wave/failure?pr=123")
|
|
130
|
+
assert response.status == 200, f"Expected 200, got {response.status}"
|
|
131
|
+
body = json.loads(response.text())
|
|
132
|
+
assert "available" in body
|
|
133
|
+
assert "pr_number" in body
|
|
134
|
+
assert body["pr_number"] == 123
|
|
135
|
+
print(" ✓ Endpoint returns correct shape")
|
|
136
|
+
|
|
137
|
+
# Test 2: Navigate to dashboard (just to verify it loads)
|
|
138
|
+
print("[test] Dashboard loads")
|
|
139
|
+
page.goto(f"http://127.0.0.1:{port}/")
|
|
140
|
+
# Use domcontentloaded instead of networkidle because SSE keeps
|
|
141
|
+
# connections open indefinitely, preventing networkidle from firing
|
|
142
|
+
page.wait_for_load_state("domcontentloaded", timeout=5000)
|
|
143
|
+
title = page.title()
|
|
144
|
+
assert title, "Page should have a title"
|
|
145
|
+
print(f" ✓ Dashboard loaded: {title}")
|
|
146
|
+
|
|
147
|
+
# Test 3: Test 400 on missing ?pr=
|
|
148
|
+
print("[test] GET /api/wave/failure without ?pr= returns 400")
|
|
149
|
+
response = page.request.get(f"http://127.0.0.1:{port}/api/wave/failure")
|
|
150
|
+
assert response.status == 400, f"Expected 400, got {response.status}"
|
|
151
|
+
print(" ✓ Missing ?pr= rejected")
|
|
152
|
+
|
|
153
|
+
# Test 4: Test 400 on invalid ?pr=
|
|
154
|
+
print("[test] GET /api/wave/failure?pr=invalid returns 400")
|
|
155
|
+
response = page.request.get(f"http://127.0.0.1:{port}/api/wave/failure?pr=notanumber")
|
|
156
|
+
assert response.status == 400, f"Expected 400, got {response.status}"
|
|
157
|
+
print(" ✓ Invalid ?pr= rejected")
|
|
158
|
+
|
|
159
|
+
# Test 5: Verify no-cache headers
|
|
160
|
+
print("[test] Verify no-cache headers")
|
|
161
|
+
response = page.request.get(f"http://127.0.0.1:{port}/api/wave/failure?pr=456")
|
|
162
|
+
cache_control = response.headers.get("cache-control", "")
|
|
163
|
+
assert "no-cache" in cache_control, f"Expected no-cache in {cache_control}"
|
|
164
|
+
print(" ✓ No-cache headers present")
|
|
165
|
+
|
|
166
|
+
print("\n[success] All verification tests passed!")
|
|
167
|
+
|
|
168
|
+
finally:
|
|
169
|
+
browser.close()
|
|
170
|
+
|
|
171
|
+
finally:
|
|
172
|
+
cleanup()
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
print(f"\n[error] {e}", file=sys.stderr)
|
|
176
|
+
import traceback
|
|
177
|
+
traceback.print_exc()
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
finally:
|
|
181
|
+
shutil.rmtree(fixture_root, ignore_errors=True)
|
|
182
|
+
print(f"[cleanup] removed {fixture_root}")
|
|
183
|
+
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|
package/tools/verify_prboard.py
CHANGED
|
@@ -159,6 +159,8 @@ def start_server(root: Path, port: int, gh_path: Path):
|
|
|
159
159
|
AESOP_ROOT=str(root),
|
|
160
160
|
AESOP_STATE_ROOT=str(state_root),
|
|
161
161
|
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
162
|
+
AESOP_WEB_DIST=str(REPO / "ui" / "web" / "dist"),
|
|
163
|
+
AESOP_PROOF_FIXTURES="1",
|
|
162
164
|
AESOP_UI_COLLECT_INTERVAL="0.3",
|
|
163
165
|
AESOP_GH_BIN=str(gh_path),
|
|
164
166
|
PORT=str(port))
|