@matt82198/aesop 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +50 -260
- package/bin/cli.js +7 -3
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +4 -3
- package/docs/PORTING.md +166 -0
- package/docs/TEAM-STATE.md +16 -184
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +50 -48
- package/driver/codex_driver.py +59 -12
- package/driver/openai_transport.py +33 -0
- package/driver/wave_bridge.py +9 -1
- package/driver/wave_loop.py +437 -70
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +22 -5
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +4 -4
- package/state_store/__init__.py +2 -1
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/write_api.py +462 -0
- package/tools/cost_ceiling.py +106 -15
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/eod_sweep.py +137 -33
- package/tools/mutation_test.py +130 -8
- package/tools/proposals.mjs +47 -2
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +73 -18
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/wave_preflight.py +296 -29
- package/tools/wave_templates.py +170 -45
- package/ui/collectors.py +81 -0
- package/ui/handler.py +230 -85
- package/ui/sse.py +3 -3
- package/ui/wave_telemetry.py +24 -18
- package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/docs/QUICKSTART.md +0 -80
- package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
tools.stateapi_lint — Scanner for direct state file opens outside the API.
|
|
4
|
+
|
|
5
|
+
Detects violations of the "reads go through state_store.read_api" rule by scanning
|
|
6
|
+
ui/ and tools/ for direct opens of state files (tracker.json, orchestrator-status.json,
|
|
7
|
+
heartbeat files, OUTCOMES-LEDGER.md) outside the read_api module.
|
|
8
|
+
|
|
9
|
+
Writers (state_store/export.py, state_store/ingest.py, etc.) are allowlisted.
|
|
10
|
+
|
|
11
|
+
RATCHET MODE (hardening):
|
|
12
|
+
Baseline is keyed by file + pattern-id (NOT line numbers).
|
|
13
|
+
Without --update-baseline flag:
|
|
14
|
+
- FAILS if baseline contains entries absent from current scan (stale baseline)
|
|
15
|
+
- FAILS if current violations missing from baseline (new violations)
|
|
16
|
+
- PASS only if: current violations exactly match baseline entries
|
|
17
|
+
With --update-baseline flag:
|
|
18
|
+
- Allows regenerating the baseline (sets current as new baseline)
|
|
19
|
+
|
|
20
|
+
CI MUST NEVER pass --update-baseline (hand-edits of baseline detected & fail).
|
|
21
|
+
|
|
22
|
+
Exit codes:
|
|
23
|
+
0 = all checks pass
|
|
24
|
+
1 = violations detected or baseline mismatch
|
|
25
|
+
2 = error
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
python tools/stateapi_lint.py [--root REPO_ROOT] [--json] [--update-baseline]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--root REPO_ROOT: Repository root (default: cwd)
|
|
32
|
+
--json: Output JSON instead of text
|
|
33
|
+
--update-baseline: Regenerate baseline from current violations (CI must never use)
|
|
34
|
+
"""
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# State files that should only be read via the API
|
|
42
|
+
STATE_FILES_TO_PROTECT = [
|
|
43
|
+
"tracker.json",
|
|
44
|
+
"orchestrator-status.json",
|
|
45
|
+
"OUTCOMES-LEDGER.md",
|
|
46
|
+
".watchdog-heartbeat",
|
|
47
|
+
".monitor-heartbeat",
|
|
48
|
+
".orchestrator-heartbeat",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# File patterns that are WRITERS and allowed to access state files directly
|
|
52
|
+
WRITER_ALLOWLIST = [
|
|
53
|
+
"state_store/export.py",
|
|
54
|
+
"state_store/ingest.py",
|
|
55
|
+
"state_store/read_api.py", # The read API facade itself (reads the state files)
|
|
56
|
+
"state_store/write_api.py", # The write API facade (reads/writes the projection atomically)
|
|
57
|
+
"ui/collectors.py", # Some readers also export/flush
|
|
58
|
+
"tools/cost.py", # Parses ledger
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Directories to scan for violations
|
|
62
|
+
SCAN_DIRS = ["ui", "tools", "state_store"] # Include state_store to catch internal issues
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_direct_opens(repo_root):
|
|
66
|
+
"""Scan repo for direct opens of protected state files outside the API.
|
|
67
|
+
|
|
68
|
+
Returns violations keyed by file + pattern-id (not line numbers).
|
|
69
|
+
Pattern-id is the matched pattern index, making the key stable across line edits.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
repo_root: Path to repository root
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
list: List of violation keys (file@pattern-id format)
|
|
76
|
+
"""
|
|
77
|
+
repo_root = Path(repo_root)
|
|
78
|
+
violations = []
|
|
79
|
+
|
|
80
|
+
# Pattern to detect file opens: Path(...).read_text(), open(...), json.load(open(...)), etc.
|
|
81
|
+
# Each pattern gets an ID for stable keying across line number changes
|
|
82
|
+
read_patterns = [
|
|
83
|
+
(r'["\']tracker\.json["\']', "tracker-json"),
|
|
84
|
+
(r'["\']orchestrator-status\.json["\']', "status-json"),
|
|
85
|
+
(r'["\']OUTCOMES-LEDGER\.md["\']', "ledger-md"),
|
|
86
|
+
(r'["\']\.watchdog-heartbeat["\']', "watchdog-hb"),
|
|
87
|
+
(r'["\']\.monitor-heartbeat["\']', "monitor-hb"),
|
|
88
|
+
(r'["\']\.orchestrator-heartbeat["\']', "orchestrator-hb"),
|
|
89
|
+
(r'state\s*/\s*tracker\.json', "state-tracker-json"),
|
|
90
|
+
(r'state\s*/\s*orchestrator-status', "state-status-json"),
|
|
91
|
+
(r'state\s*/\s*.*heartbeat', "state-heartbeat"),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
for scan_dir in SCAN_DIRS:
|
|
95
|
+
scan_path = repo_root / scan_dir
|
|
96
|
+
if not scan_path.exists():
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
for py_file in scan_path.rglob("*.py"):
|
|
100
|
+
# Check if this file is in the allowlist
|
|
101
|
+
relative_path = py_file.relative_to(repo_root)
|
|
102
|
+
is_allowed = any(
|
|
103
|
+
str(relative_path).replace("\\", "/") == allow.replace("\\", "/")
|
|
104
|
+
for allow in WRITER_ALLOWLIST
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if is_allowed:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
content = py_file.read_text(encoding="utf-8", errors="replace")
|
|
112
|
+
except Exception:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Skip if file imports read_api (it's using the facade correctly)
|
|
116
|
+
if "from state_store.read_api import" in content or "import state_store.read_api" in content:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Scan for violations
|
|
120
|
+
for line_num, line in enumerate(content.split("\n"), 1):
|
|
121
|
+
for pattern, pattern_id in read_patterns:
|
|
122
|
+
if re.search(pattern, line):
|
|
123
|
+
# Additional filter: skip comment lines and string literals in docstrings
|
|
124
|
+
if line.strip().startswith("#"):
|
|
125
|
+
continue
|
|
126
|
+
if '"""' in line or "'''" in line:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Key is file@pattern-id (stable across line edits)
|
|
130
|
+
# Use posix separators for cross-platform consistency
|
|
131
|
+
violation_key = f"{relative_path.as_posix()}@{pattern_id}"
|
|
132
|
+
if violation_key not in violations:
|
|
133
|
+
violations.append(violation_key)
|
|
134
|
+
break # Only report once per line per file
|
|
135
|
+
|
|
136
|
+
return sorted(violations)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def save_baseline(baseline_file, data):
|
|
140
|
+
"""Save violations baseline to JSON file.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
baseline_file: Path to baseline file
|
|
144
|
+
data: dict with "violations" list
|
|
145
|
+
"""
|
|
146
|
+
baseline_file = Path(baseline_file)
|
|
147
|
+
baseline_file.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
baseline_file.write_text(json.dumps(data, indent=2))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_baseline(baseline_file):
|
|
152
|
+
"""Load violations baseline from JSON file.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
dict with "violations" list, or empty dict if file missing.
|
|
156
|
+
"""
|
|
157
|
+
baseline_file = Path(baseline_file)
|
|
158
|
+
if not baseline_file.exists():
|
|
159
|
+
return {"violations": []}
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
return json.loads(baseline_file.read_text())
|
|
163
|
+
except Exception:
|
|
164
|
+
return {"violations": []}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def normalize_key(key):
|
|
168
|
+
"""Normalize violation keys to forward-slash separators for cross-platform consistency.
|
|
169
|
+
|
|
170
|
+
Converts backslash separators (legacy Windows baseline entries) to forward slashes.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
key: Violation key string
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
str: Key with forward-slash separators
|
|
177
|
+
"""
|
|
178
|
+
return key.replace("\\", "/")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def check_ratchet(baseline_violations, current_violations):
|
|
182
|
+
"""Check ratchet: baseline and current must match exactly.
|
|
183
|
+
|
|
184
|
+
Fails if:
|
|
185
|
+
1. Baseline contains entries absent from current (stale baseline)
|
|
186
|
+
2. Current violations missing from baseline (new violations)
|
|
187
|
+
|
|
188
|
+
Only passes if current violations exactly match baseline (bidirectional check).
|
|
189
|
+
Normalizes both sets to forward-slash separators for cross-platform consistency.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
baseline_violations: list of violation keys from baseline
|
|
193
|
+
current_violations: list of violation keys from current scan
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
tuple: (is_ok, stale_entries, new_violations)
|
|
197
|
+
is_ok (bool): True only if baseline == current
|
|
198
|
+
stale_entries (list): Entries in baseline not in current
|
|
199
|
+
new_violations (list): Entries in current not in baseline
|
|
200
|
+
"""
|
|
201
|
+
# Normalize both to forward slashes for comparison
|
|
202
|
+
baseline_set = set(normalize_key(v) for v in baseline_violations)
|
|
203
|
+
current_set = set(normalize_key(v) for v in current_violations)
|
|
204
|
+
|
|
205
|
+
stale = list(baseline_set - current_set)
|
|
206
|
+
new = list(current_set - baseline_set)
|
|
207
|
+
|
|
208
|
+
is_ok = (len(stale) == 0 and len(new) == 0)
|
|
209
|
+
return is_ok, sorted(stale), sorted(new)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(argv=None):
|
|
213
|
+
"""CLI entry point."""
|
|
214
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
215
|
+
|
|
216
|
+
repo_root = None
|
|
217
|
+
output_format = "text"
|
|
218
|
+
baseline_file = None
|
|
219
|
+
update_baseline = False
|
|
220
|
+
|
|
221
|
+
# Parse arguments
|
|
222
|
+
i = 0
|
|
223
|
+
while i < len(argv):
|
|
224
|
+
arg = argv[i]
|
|
225
|
+
if arg == "--root":
|
|
226
|
+
i += 1
|
|
227
|
+
if i < len(argv):
|
|
228
|
+
repo_root = argv[i]
|
|
229
|
+
i += 1
|
|
230
|
+
elif arg.startswith("--root="):
|
|
231
|
+
repo_root = arg[len("--root="):]
|
|
232
|
+
i += 1
|
|
233
|
+
elif arg == "--json":
|
|
234
|
+
output_format = "json"
|
|
235
|
+
i += 1
|
|
236
|
+
elif arg == "--baseline":
|
|
237
|
+
i += 1
|
|
238
|
+
if i < len(argv):
|
|
239
|
+
baseline_file = argv[i]
|
|
240
|
+
i += 1
|
|
241
|
+
elif arg == "--update-baseline":
|
|
242
|
+
update_baseline = True
|
|
243
|
+
i += 1
|
|
244
|
+
else:
|
|
245
|
+
print(f"Unknown argument: {arg}", file=sys.stderr)
|
|
246
|
+
return 2
|
|
247
|
+
|
|
248
|
+
if repo_root is None:
|
|
249
|
+
repo_root = Path.cwd()
|
|
250
|
+
else:
|
|
251
|
+
repo_root = Path(repo_root)
|
|
252
|
+
|
|
253
|
+
if baseline_file is None:
|
|
254
|
+
baseline_file = repo_root / ".stateapi-baseline.json"
|
|
255
|
+
|
|
256
|
+
# Scan for violations
|
|
257
|
+
violations = find_direct_opens(str(repo_root))
|
|
258
|
+
|
|
259
|
+
# Load baseline
|
|
260
|
+
baseline_data = load_baseline(str(baseline_file))
|
|
261
|
+
baseline_violations = baseline_data.get("violations", [])
|
|
262
|
+
|
|
263
|
+
# If --update-baseline, save current as baseline and exit
|
|
264
|
+
if update_baseline:
|
|
265
|
+
save_baseline(baseline_file, {"violations": violations})
|
|
266
|
+
if output_format == "json":
|
|
267
|
+
print(json.dumps({"ok": True, "message": "Baseline updated", "count": len(violations)}, indent=2))
|
|
268
|
+
else:
|
|
269
|
+
print(f"Baseline updated: {len(violations)} violation(s) recorded")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
# Check ratchet: baseline must match current exactly
|
|
273
|
+
is_ok, stale_entries, new_violations = check_ratchet(baseline_violations, violations)
|
|
274
|
+
|
|
275
|
+
if output_format == "json":
|
|
276
|
+
result = {
|
|
277
|
+
"ok": is_ok,
|
|
278
|
+
"violations": violations,
|
|
279
|
+
"baseline_count": len(baseline_violations),
|
|
280
|
+
"current_count": len(violations),
|
|
281
|
+
"stale_entries": stale_entries,
|
|
282
|
+
"new_violations": new_violations,
|
|
283
|
+
}
|
|
284
|
+
print(json.dumps(result, indent=2))
|
|
285
|
+
else:
|
|
286
|
+
# Text format
|
|
287
|
+
print(f"State API lint: {len(violations)} violation(s) found")
|
|
288
|
+
if baseline_violations:
|
|
289
|
+
print(f" (baseline: {len(baseline_violations)})")
|
|
290
|
+
|
|
291
|
+
if violations:
|
|
292
|
+
for v in sorted(violations):
|
|
293
|
+
print(f" {v}")
|
|
294
|
+
|
|
295
|
+
# Report stale entries (in baseline but not current)
|
|
296
|
+
if stale_entries:
|
|
297
|
+
print(f"\nSTALE baseline entries ({len(stale_entries)}) — hand-edited or fixed?:")
|
|
298
|
+
for v in stale_entries:
|
|
299
|
+
print(f" {v}")
|
|
300
|
+
|
|
301
|
+
# Report new violations (in current but not baseline)
|
|
302
|
+
if new_violations:
|
|
303
|
+
print(f"\nNEW violations ({len(new_violations)}):")
|
|
304
|
+
for v in new_violations:
|
|
305
|
+
print(f" {v}")
|
|
306
|
+
|
|
307
|
+
# Final verdict
|
|
308
|
+
if is_ok:
|
|
309
|
+
if violations:
|
|
310
|
+
print(f"\nPASS: All {len(violations)} violations match baseline")
|
|
311
|
+
else:
|
|
312
|
+
print("\nPASS: No violations")
|
|
313
|
+
return 0
|
|
314
|
+
else:
|
|
315
|
+
if stale_entries and new_violations:
|
|
316
|
+
print(f"\nFAIL: {len(stale_entries)} stale + {len(new_violations)} new violation(s)")
|
|
317
|
+
elif stale_entries:
|
|
318
|
+
print(f"\nFAIL: {len(stale_entries)} stale baseline entries (hand-edited?)")
|
|
319
|
+
else:
|
|
320
|
+
print(f"\nFAIL: {len(new_violations)} new violation(s) detected")
|
|
321
|
+
return 1
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
sys.exit(main())
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_battery.py -- run the local union test battery, parallel by default.
|
|
3
|
+
|
|
4
|
+
Runs the four harnesses (python unittest discover, node --test, shell suites,
|
|
5
|
+
ui vitest+tsc) as concurrent subprocesses with per-harness rc capture, stdin
|
|
6
|
+
closed (the hook suite hangs on never-EOF stdin), and an explicit summary
|
|
7
|
+
table. Exit 0 only when every harness exits 0.
|
|
8
|
+
|
|
9
|
+
Per-harness timeout (AESOP_BATTERY_HARNESS_TIMEOUT_S, default 1800s = 30min):
|
|
10
|
+
on expiry, the process tree is killed and rc=124 is recorded with a TIMEOUT
|
|
11
|
+
note. Applies in both serial and parallel modes.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python tools/test_battery.py [--serial] [--skip ui|sh|node|py ...] [--json]
|
|
15
|
+
|
|
16
|
+
--serial runs harnesses one at a time (the pre-wave-29 behavior; fallback if
|
|
17
|
+
parallel runs prove load-fragile on a box). Logs land in the state scratch dir
|
|
18
|
+
(AESOP_BATTERY_LOGDIR or the system temp dir) as battery-<harness>.log.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import platform
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
32
|
+
|
|
33
|
+
HARNESSES = {
|
|
34
|
+
"py": [sys.executable, "-m", "unittest", "discover", "-s", "tests"],
|
|
35
|
+
"node": ["npm", "run", "test:node"],
|
|
36
|
+
"sh": ["npm", "run", "test:sh"],
|
|
37
|
+
"ui": None, # composite: tsc + vitest, run via _ui_command
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_harness_timeout():
|
|
42
|
+
"""Get per-harness timeout in seconds (env AESOP_BATTERY_HARNESS_TIMEOUT_S, default 1800)."""
|
|
43
|
+
timeout_s = os.environ.get("AESOP_BATTERY_HARNESS_TIMEOUT_S", "1800")
|
|
44
|
+
try:
|
|
45
|
+
return int(timeout_s)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return 1800
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _kill_process_tree(proc):
|
|
51
|
+
"""Kill process and all children (Windows: taskkill /T /F, others: SIGKILL)."""
|
|
52
|
+
try:
|
|
53
|
+
if platform.system() == "Windows":
|
|
54
|
+
subprocess.run(
|
|
55
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
timeout=5,
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
# Unix: SIGKILL via os.killpg (process group)
|
|
61
|
+
try:
|
|
62
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
63
|
+
except (ProcessLookupError, OSError):
|
|
64
|
+
# Process already gone or not in a group; try direct kill
|
|
65
|
+
try:
|
|
66
|
+
os.kill(proc.pid, signal.SIGKILL)
|
|
67
|
+
except (ProcessLookupError, OSError):
|
|
68
|
+
pass
|
|
69
|
+
except Exception:
|
|
70
|
+
# Ignore kill errors; process may already be dead
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _wait_with_timeout(proc, timeout_s):
|
|
75
|
+
"""Wait for process with timeout; return (rc, timed_out).
|
|
76
|
+
|
|
77
|
+
On timeout, kills the process tree and returns (124, True).
|
|
78
|
+
Otherwise returns (proc.returncode, False).
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
proc.wait(timeout=timeout_s)
|
|
82
|
+
return proc.returncode, False
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
_kill_process_tree(proc)
|
|
85
|
+
# Wait a moment for kill to take effect, then force reap
|
|
86
|
+
try:
|
|
87
|
+
proc.wait(timeout=1)
|
|
88
|
+
except subprocess.TimeoutExpired:
|
|
89
|
+
pass
|
|
90
|
+
return 124, True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ui_command():
|
|
94
|
+
# ui/web needs node_modules; npx resolves local binaries.
|
|
95
|
+
return "npx tsc --noEmit && npx vitest run --silent"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_harness(name, logdir, parallel=True):
|
|
99
|
+
"""Spawn one harness with stdin closed; return (name, Popen, logfile)."""
|
|
100
|
+
log = Path(logdir) / f"battery-{name}.log"
|
|
101
|
+
f = open(log, "w", encoding="utf-8", errors="replace")
|
|
102
|
+
env = os.environ.copy()
|
|
103
|
+
if parallel and name == "node" and "AESOP_TEST_CHILD_TIMEOUT_MS" not in env:
|
|
104
|
+
# Under 4-harness parallel load, scaffold child processes legitimately
|
|
105
|
+
# exceed the 30s solo ceiling; the tests honor this knob (wave-29).
|
|
106
|
+
env["AESOP_TEST_CHILD_TIMEOUT_MS"] = "90000"
|
|
107
|
+
if name == "ui":
|
|
108
|
+
proc = subprocess.Popen(
|
|
109
|
+
_ui_command(), shell=True, cwd=str(REPO / "ui" / "web"),
|
|
110
|
+
stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.STDOUT, env=env,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# npm needs shell resolution on Windows.
|
|
114
|
+
use_shell = os.name == "nt" and HARNESSES[name][0] == "npm"
|
|
115
|
+
cmd = " ".join(HARNESSES[name]) if use_shell else HARNESSES[name]
|
|
116
|
+
proc = subprocess.Popen(
|
|
117
|
+
cmd, shell=use_shell, cwd=str(REPO),
|
|
118
|
+
stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.STDOUT, env=env,
|
|
119
|
+
)
|
|
120
|
+
return name, proc, f, log
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
ap = argparse.ArgumentParser(description="Run the local union test battery")
|
|
125
|
+
ap.add_argument("--serial", action="store_true", help="run harnesses sequentially")
|
|
126
|
+
ap.add_argument("--skip", action="append", default=[], choices=list(HARNESSES),
|
|
127
|
+
help="skip a harness (repeatable)")
|
|
128
|
+
ap.add_argument("--json", action="store_true", help="machine-readable summary")
|
|
129
|
+
args = ap.parse_args()
|
|
130
|
+
|
|
131
|
+
logdir = os.environ.get("AESOP_BATTERY_LOGDIR") or tempfile.mkdtemp(prefix="aesop-battery-")
|
|
132
|
+
os.makedirs(logdir, exist_ok=True)
|
|
133
|
+
names = [n for n in HARNESSES if n not in args.skip]
|
|
134
|
+
started = time.time()
|
|
135
|
+
results = {}
|
|
136
|
+
timeout_s = _get_harness_timeout()
|
|
137
|
+
|
|
138
|
+
if args.serial:
|
|
139
|
+
for n in names:
|
|
140
|
+
name, proc, f, log = run_harness(n, logdir, parallel=False)
|
|
141
|
+
rc, timed_out = _wait_with_timeout(proc, timeout_s)
|
|
142
|
+
f.close()
|
|
143
|
+
result = {"rc": rc, "log": str(log)}
|
|
144
|
+
if timed_out:
|
|
145
|
+
result["note"] = "TIMEOUT"
|
|
146
|
+
results[name] = result
|
|
147
|
+
else:
|
|
148
|
+
procs = [run_harness(n, logdir) for n in names]
|
|
149
|
+
for name, proc, f, log in procs:
|
|
150
|
+
rc, timed_out = _wait_with_timeout(proc, timeout_s)
|
|
151
|
+
f.close()
|
|
152
|
+
result = {"rc": rc, "log": str(log)}
|
|
153
|
+
if timed_out:
|
|
154
|
+
result["note"] = "TIMEOUT"
|
|
155
|
+
results[name] = result
|
|
156
|
+
|
|
157
|
+
wall = round(time.time() - started, 1)
|
|
158
|
+
ok = all(r["rc"] == 0 for r in results.values())
|
|
159
|
+
if args.json:
|
|
160
|
+
print(json.dumps({"ok": ok, "wall_s": wall, "mode": "serial" if args.serial else "parallel",
|
|
161
|
+
"results": results}))
|
|
162
|
+
else:
|
|
163
|
+
print(f"battery mode={'serial' if args.serial else 'parallel'} wall={wall}s")
|
|
164
|
+
for n, r in results.items():
|
|
165
|
+
verdict = "PASS" if r["rc"] == 0 else "FAIL"
|
|
166
|
+
note_str = f" {r['note']}" if "note" in r else ""
|
|
167
|
+
print(f" {n:5s} rc={r['rc']} {verdict}{note_str} log={r['log']}")
|
|
168
|
+
print("BATTERY:", "GREEN" if ok else "RED")
|
|
169
|
+
sys.exit(0 if ok else 1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|