@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
package/tools/wave_preflight.py
CHANGED
|
@@ -38,9 +38,13 @@ import os
|
|
|
38
38
|
import re
|
|
39
39
|
import subprocess
|
|
40
40
|
import sys
|
|
41
|
-
import time
|
|
42
41
|
from pathlib import Path
|
|
43
42
|
|
|
43
|
+
# Ensure both tools and state_store are importable (sys.path fix for bootstrapping)
|
|
44
|
+
repo_root = Path(__file__).parent.parent
|
|
45
|
+
if str(repo_root) not in sys.path:
|
|
46
|
+
sys.path.insert(0, str(repo_root))
|
|
47
|
+
|
|
44
48
|
try:
|
|
45
49
|
from common import get_state_dir, check_heartbeat_staleness
|
|
46
50
|
except ImportError:
|
|
@@ -51,6 +55,9 @@ try:
|
|
|
51
55
|
except ImportError:
|
|
52
56
|
from tools import halt
|
|
53
57
|
|
|
58
|
+
# Import ReadAPI unconditionally - import failure is a loud error
|
|
59
|
+
from state_store.read_api import ReadAPI
|
|
60
|
+
|
|
54
61
|
|
|
55
62
|
def load_config(root_dir=None):
|
|
56
63
|
"""Load aesop.config.json from root, return dict (or {} if absent/bad)."""
|
|
@@ -134,8 +141,10 @@ def parse_orchestrator_status_phase(status_json_path):
|
|
|
134
141
|
def check_orchestrator_status_freshness(status_json_path, threshold_s):
|
|
135
142
|
"""Check if orchestrator-status.json is fresh based on updated_at timestamp.
|
|
136
143
|
|
|
144
|
+
Uses ReadAPI (state_store.read_api) for all checks - single source of truth.
|
|
145
|
+
|
|
137
146
|
Args:
|
|
138
|
-
status_json_path: Path to orchestrator-status.json
|
|
147
|
+
status_json_path: Path to orchestrator-status.json (or state dir)
|
|
139
148
|
threshold_s: Staleness threshold in seconds
|
|
140
149
|
|
|
141
150
|
Returns:
|
|
@@ -144,30 +153,38 @@ def check_orchestrator_status_freshness(status_json_path, threshold_s):
|
|
|
144
153
|
age_s (int): Age in seconds (0 if file missing/unreadable)
|
|
145
154
|
info (str or None): Descriptive message if stale/missing, None if fresh
|
|
146
155
|
"""
|
|
147
|
-
if not status_json_path.exists():
|
|
148
|
-
return True, 0, "orchestrator-status.json file missing"
|
|
149
|
-
|
|
150
156
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
# Determine state_dir: if status_json_path is the status file, use its parent
|
|
158
|
+
status_path = Path(status_json_path)
|
|
159
|
+
if status_path.name == "orchestrator-status.json":
|
|
160
|
+
state_dir = status_path.parent
|
|
161
|
+
else:
|
|
162
|
+
state_dir = status_path
|
|
163
|
+
|
|
164
|
+
api = ReadAPI(str(state_dir))
|
|
165
|
+
status = api.read_orchestrator_status()
|
|
166
|
+
if status is None:
|
|
167
|
+
return True, 0, "orchestrator-status.json file missing or unreadable"
|
|
168
|
+
|
|
169
|
+
# Check freshness
|
|
170
|
+
updated_at = status.get("updated_at")
|
|
153
171
|
if not updated_at:
|
|
154
172
|
return True, 0, "orchestrator-status.json missing updated_at field"
|
|
155
173
|
|
|
156
|
-
# Parse ISO 8601 timestamp
|
|
157
|
-
import datetime
|
|
158
|
-
# Normalize Z suffix to +00:00 for fromisoformat compatibility
|
|
174
|
+
# Parse ISO 8601 timestamp
|
|
175
|
+
from datetime import datetime, timezone
|
|
159
176
|
normalized_ts = updated_at.replace("Z", "+00:00")
|
|
160
|
-
updated_dt = datetime.
|
|
177
|
+
updated_dt = datetime.fromisoformat(normalized_ts)
|
|
161
178
|
timestamp = updated_dt.timestamp()
|
|
162
179
|
|
|
180
|
+
import time
|
|
163
181
|
age_seconds = int(time.time()) - int(timestamp)
|
|
164
182
|
|
|
165
183
|
# Check for far-future timestamp (clock skew beyond tolerance)
|
|
166
|
-
# More than 120s in the future is treated as stale, not clamped-to-fresh
|
|
167
184
|
if age_seconds < -120:
|
|
168
185
|
return True, 0, "orchestrator-status timestamp in future (clock skew)"
|
|
169
186
|
|
|
170
|
-
# Clamp small negative ages to 0
|
|
187
|
+
# Clamp small negative ages to 0
|
|
171
188
|
age_seconds = max(0, age_seconds)
|
|
172
189
|
|
|
173
190
|
if age_seconds >= threshold_s:
|
|
@@ -259,6 +276,201 @@ def can_import_secret_scan():
|
|
|
259
276
|
return False, str(e)
|
|
260
277
|
|
|
261
278
|
|
|
279
|
+
def scan_backlog_items(tracker_path, work_dir, ledger_path=None):
|
|
280
|
+
"""Scan backlog items for risky conditions.
|
|
281
|
+
|
|
282
|
+
Analyzes todo items in tracker.json for:
|
|
283
|
+
(a) missing/empty owns_files (file ownership info)
|
|
284
|
+
(b) stale items (references to non-existent files)
|
|
285
|
+
(c) overlaps (two items owning same file)
|
|
286
|
+
(d) history signal (retry rate from ledger)
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
tracker_path: Path to state/tracker.json
|
|
290
|
+
work_dir: Working directory for resolving relative file paths
|
|
291
|
+
ledger_path: Optional path to ledger for history stats
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
dict with structure:
|
|
295
|
+
{
|
|
296
|
+
"success": bool,
|
|
297
|
+
"items": [
|
|
298
|
+
{
|
|
299
|
+
"id": str,
|
|
300
|
+
"title": str,
|
|
301
|
+
"flags": [{"type": str, "detail": str}, ...]
|
|
302
|
+
},
|
|
303
|
+
...
|
|
304
|
+
],
|
|
305
|
+
"ledger_stats": dict or None,
|
|
306
|
+
"summary": str
|
|
307
|
+
}
|
|
308
|
+
"""
|
|
309
|
+
tracker_path = Path(tracker_path)
|
|
310
|
+
work_dir = Path(work_dir)
|
|
311
|
+
result = {
|
|
312
|
+
"success": True,
|
|
313
|
+
"items": [],
|
|
314
|
+
"ledger_stats": None,
|
|
315
|
+
"summary": ""
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Load tracker.json
|
|
319
|
+
try:
|
|
320
|
+
if not tracker_path.exists():
|
|
321
|
+
result["success"] = False
|
|
322
|
+
result["summary"] = f"tracker file not found: {tracker_path}"
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
tracker_data = json.loads(tracker_path.read_text(encoding="utf-8"))
|
|
326
|
+
if not isinstance(tracker_data, dict) or "items" not in tracker_data:
|
|
327
|
+
result["success"] = False
|
|
328
|
+
result["summary"] = "tracker.json invalid: missing 'items' key"
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
items = tracker_data.get("items", [])
|
|
332
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
333
|
+
result["success"] = False
|
|
334
|
+
result["summary"] = f"tracker.json unreadable: {e}"
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
# Filter to todo items only
|
|
338
|
+
todo_items = [
|
|
339
|
+
item for item in items
|
|
340
|
+
if isinstance(item, dict) and item.get("status") == "todo"
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
# Build ownership map for overlap detection
|
|
344
|
+
ownership_map = {} # file_path -> set of item_ids
|
|
345
|
+
|
|
346
|
+
# Process each todo item
|
|
347
|
+
for item in todo_items:
|
|
348
|
+
item_id = item.get("id", "unknown")
|
|
349
|
+
title = item.get("title", "")
|
|
350
|
+
flags = []
|
|
351
|
+
|
|
352
|
+
# Flag (a): Check for missing/empty owns_files
|
|
353
|
+
owns_files = item.get("owns_files", [])
|
|
354
|
+
if not owns_files or (isinstance(owns_files, list) and len(owns_files) == 0):
|
|
355
|
+
flags.append({
|
|
356
|
+
"type": "missing_ownership",
|
|
357
|
+
"detail": "Item has no owns_files; cannot dispatch safely"
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
# Flag (b): Check for stale items (files not found)
|
|
361
|
+
if owns_files and isinstance(owns_files, list):
|
|
362
|
+
for file_ref in owns_files:
|
|
363
|
+
file_path = work_dir / file_ref if not Path(file_ref).is_absolute() else Path(file_ref)
|
|
364
|
+
if not file_path.exists():
|
|
365
|
+
flags.append({
|
|
366
|
+
"type": "stale_reference",
|
|
367
|
+
"detail": f"Referenced file not found: {file_ref}"
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
# Track for overlap detection
|
|
371
|
+
file_key = str(file_path.resolve())
|
|
372
|
+
if file_key not in ownership_map:
|
|
373
|
+
ownership_map[file_key] = set()
|
|
374
|
+
ownership_map[file_key].add(item_id)
|
|
375
|
+
|
|
376
|
+
if flags or owns_files: # Only include items with flags or ownership info
|
|
377
|
+
result["items"].append({
|
|
378
|
+
"id": item_id,
|
|
379
|
+
"title": title,
|
|
380
|
+
"flags": flags
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
# Flag (c): Detect overlaps
|
|
384
|
+
for file_path, item_ids in ownership_map.items():
|
|
385
|
+
if len(item_ids) > 1:
|
|
386
|
+
for item_id in item_ids:
|
|
387
|
+
# Find the item and add overlap flag
|
|
388
|
+
for item_result in result["items"]:
|
|
389
|
+
if item_result["id"] == item_id:
|
|
390
|
+
overlapping_ids = sorted(item_ids - {item_id})
|
|
391
|
+
item_result["flags"].append({
|
|
392
|
+
"type": "ownership_overlap",
|
|
393
|
+
"detail": f"File shared with items: {', '.join(overlapping_ids)}"
|
|
394
|
+
})
|
|
395
|
+
break
|
|
396
|
+
|
|
397
|
+
# Flag (d): Load ledger stats if provided
|
|
398
|
+
if ledger_path:
|
|
399
|
+
ledger_path = Path(ledger_path)
|
|
400
|
+
if ledger_path.exists():
|
|
401
|
+
try:
|
|
402
|
+
ledger_stats = _analyze_ledger(ledger_path)
|
|
403
|
+
result["ledger_stats"] = ledger_stats
|
|
404
|
+
except Exception as e:
|
|
405
|
+
result["ledger_stats"] = {"error": str(e), "data": "DATA-UNAVAILABLE"}
|
|
406
|
+
else:
|
|
407
|
+
result["ledger_stats"] = {"data": "DATA-UNAVAILABLE", "note": "ledger not found"}
|
|
408
|
+
else:
|
|
409
|
+
result["ledger_stats"] = {"data": "DATA-UNAVAILABLE", "note": "no ledger provided"}
|
|
410
|
+
|
|
411
|
+
# Summary
|
|
412
|
+
flagged_count = sum(1 for item in result["items"] if item["flags"])
|
|
413
|
+
result["summary"] = f"Scanned {len(todo_items)} todo items; {flagged_count} have risk flags"
|
|
414
|
+
|
|
415
|
+
return result
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _analyze_ledger(ledger_path):
|
|
419
|
+
"""Extract repair/retry statistics from ledger.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
dict with overall repair rate and stats
|
|
423
|
+
"""
|
|
424
|
+
ledger_path = Path(ledger_path)
|
|
425
|
+
try:
|
|
426
|
+
content = ledger_path.read_text(encoding="utf-8")
|
|
427
|
+
except IOError:
|
|
428
|
+
return {"data": "DATA-UNAVAILABLE", "error": "unreadable"}
|
|
429
|
+
|
|
430
|
+
lines = content.split("\n")
|
|
431
|
+
entries = []
|
|
432
|
+
|
|
433
|
+
for line in lines:
|
|
434
|
+
# Skip header and separator lines
|
|
435
|
+
if not line.strip() or "---|" in line or not line.startswith("|"):
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
# Parse markdown table row
|
|
439
|
+
cells = [c.strip() for c in line.split("|")[1:-1]]
|
|
440
|
+
if len(cells) < 7:
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# Skip header row (first cell is "ISO ts" or similar header name)
|
|
444
|
+
if cells[0] in ("ISO ts", "iso_ts") or cells[0].startswith("ISO"):
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
# Columns: ISO ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict, phase, wave
|
|
449
|
+
verdict = cells[6] if len(cells) > 6 else "OK"
|
|
450
|
+
phase = cells[7].strip() if len(cells) > 7 else None
|
|
451
|
+
entries.append({"verdict": verdict, "phase": phase})
|
|
452
|
+
except (ValueError, IndexError):
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
if not entries:
|
|
456
|
+
return {"data": "DATA-UNAVAILABLE", "reason": "no ledger entries"}
|
|
457
|
+
|
|
458
|
+
# Calculate retry rate: entries with FAILED verdict / total
|
|
459
|
+
failed_count = sum(1 for e in entries if e.get("verdict") == "FAILED")
|
|
460
|
+
total_count = len(entries)
|
|
461
|
+
repair_count = sum(1 for e in entries if e.get("phase") == "repair")
|
|
462
|
+
|
|
463
|
+
retry_rate = failed_count / total_count if total_count > 0 else 0
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
"total_entries": total_count,
|
|
467
|
+
"failed_count": failed_count,
|
|
468
|
+
"repair_count": repair_count,
|
|
469
|
+
"retry_rate": round(retry_rate, 3),
|
|
470
|
+
"data": "AVAILABLE"
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
262
474
|
def run_checks(root_dir=None, state_dir=None, config=None):
|
|
263
475
|
"""Run all preflight checks.
|
|
264
476
|
|
|
@@ -447,6 +659,8 @@ def main(argv=None):
|
|
|
447
659
|
|
|
448
660
|
root_dir = None
|
|
449
661
|
state_dir = None
|
|
662
|
+
tracker_path = None
|
|
663
|
+
ledger_path = None
|
|
450
664
|
output_format = "text"
|
|
451
665
|
|
|
452
666
|
# Parse arguments
|
|
@@ -469,6 +683,22 @@ def main(argv=None):
|
|
|
469
683
|
elif arg.startswith("--state-root="):
|
|
470
684
|
state_dir = arg[len("--state-root="):]
|
|
471
685
|
i += 1
|
|
686
|
+
elif arg == "--tracker":
|
|
687
|
+
i += 1
|
|
688
|
+
if i < len(argv):
|
|
689
|
+
tracker_path = argv[i]
|
|
690
|
+
i += 1
|
|
691
|
+
elif arg.startswith("--tracker="):
|
|
692
|
+
tracker_path = arg[len("--tracker="):]
|
|
693
|
+
i += 1
|
|
694
|
+
elif arg == "--ledger":
|
|
695
|
+
i += 1
|
|
696
|
+
if i < len(argv):
|
|
697
|
+
ledger_path = argv[i]
|
|
698
|
+
i += 1
|
|
699
|
+
elif arg.startswith("--ledger="):
|
|
700
|
+
ledger_path = arg[len("--ledger="):]
|
|
701
|
+
i += 1
|
|
472
702
|
elif arg == "--json":
|
|
473
703
|
output_format = "json"
|
|
474
704
|
i += 1
|
|
@@ -487,25 +717,62 @@ def main(argv=None):
|
|
|
487
717
|
else:
|
|
488
718
|
state_dir = Path(state_dir)
|
|
489
719
|
|
|
490
|
-
|
|
720
|
+
# If --tracker provided, run backlog analysis instead of repo readiness checks
|
|
721
|
+
if tracker_path:
|
|
722
|
+
work_dir = root_dir # Use root_dir as the working directory for file resolution
|
|
723
|
+
result = scan_backlog_items(tracker_path, work_dir, ledger_path)
|
|
491
724
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
else:
|
|
495
|
-
# Text format: numbered list
|
|
496
|
-
print("Wave preflight checks:")
|
|
497
|
-
for i, check in enumerate(result["checks"], 1):
|
|
498
|
-
status = "PASS" if check["ok"] else "FAIL"
|
|
499
|
-
print(f"{i}. {check['name']}: {status}")
|
|
500
|
-
if check["detail"]:
|
|
501
|
-
print(f" {check['detail']}")
|
|
502
|
-
|
|
503
|
-
if result["ready"]:
|
|
504
|
-
print("\nPASS: Ready for wave")
|
|
725
|
+
if output_format == "json":
|
|
726
|
+
print(json.dumps(result, indent=2))
|
|
505
727
|
else:
|
|
506
|
-
|
|
728
|
+
# Text format: summary + items with flags
|
|
729
|
+
print(f"Backlog Preflight: {result['summary']}")
|
|
730
|
+
print()
|
|
731
|
+
|
|
732
|
+
if result["ledger_stats"]:
|
|
733
|
+
stats = result["ledger_stats"]
|
|
734
|
+
if stats.get("data") == "AVAILABLE":
|
|
735
|
+
print(f"Ledger History: {stats['total_entries']} entries, "
|
|
736
|
+
f"{stats['failed_count']} failed, "
|
|
737
|
+
f"{stats['repair_count']} repairs, "
|
|
738
|
+
f"retry rate: {stats['retry_rate']}")
|
|
739
|
+
else:
|
|
740
|
+
print(f"Ledger History: DATA-UNAVAILABLE")
|
|
741
|
+
print()
|
|
742
|
+
|
|
743
|
+
if result["items"]:
|
|
744
|
+
print("Flagged Items:")
|
|
745
|
+
for item in result["items"]:
|
|
746
|
+
if item["flags"]:
|
|
747
|
+
print(f" [{item['id']}] {item['title']}")
|
|
748
|
+
for flag in item["flags"]:
|
|
749
|
+
print(f" - {flag['type']}: {flag['detail']}")
|
|
750
|
+
else:
|
|
751
|
+
print("No risky items detected.")
|
|
752
|
+
|
|
753
|
+
# Exit 0 always (advisory tool, never blocks)
|
|
754
|
+
return 0
|
|
755
|
+
else:
|
|
756
|
+
# Standard repo readiness checks
|
|
757
|
+
result = run_checks(root_dir, state_dir, config)
|
|
507
758
|
|
|
508
|
-
|
|
759
|
+
if output_format == "json":
|
|
760
|
+
print(json.dumps(result, indent=2))
|
|
761
|
+
else:
|
|
762
|
+
# Text format: numbered list
|
|
763
|
+
print("Wave preflight checks:")
|
|
764
|
+
for i, check in enumerate(result["checks"], 1):
|
|
765
|
+
status = "PASS" if check["ok"] else "FAIL"
|
|
766
|
+
print(f"{i}. {check['name']}: {status}")
|
|
767
|
+
if check["detail"]:
|
|
768
|
+
print(f" {check['detail']}")
|
|
769
|
+
|
|
770
|
+
if result["ready"]:
|
|
771
|
+
print("\nPASS: Ready for wave")
|
|
772
|
+
else:
|
|
773
|
+
print("\nFAIL: Not ready for wave (see failures above)")
|
|
774
|
+
|
|
775
|
+
return 0 if result["ready"] else 1
|
|
509
776
|
|
|
510
777
|
|
|
511
778
|
if __name__ == "__main__":
|
package/tools/wave_templates.py
CHANGED
|
@@ -29,7 +29,7 @@ import argparse
|
|
|
29
29
|
import json
|
|
30
30
|
import sys
|
|
31
31
|
from pathlib import Path
|
|
32
|
-
from typing import Dict, Any, List, Optional
|
|
32
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
33
33
|
|
|
34
34
|
# Presets directory (relative to this file's location).
|
|
35
35
|
TOOLS_DIR = Path(__file__).resolve().parent
|
|
@@ -101,20 +101,29 @@ def instantiate_template(
|
|
|
101
101
|
return manifest
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def validate_manifest(
|
|
104
|
+
def validate_manifest(
|
|
105
|
+
manifest: Dict[str, Any],
|
|
106
|
+
allow_placeholders: bool = True,
|
|
107
|
+
require_testcmd: bool = True
|
|
108
|
+
) -> None:
|
|
105
109
|
"""Validate manifest schema and invariants.
|
|
106
110
|
|
|
107
111
|
Checks:
|
|
108
112
|
- items array exists and is non-empty
|
|
109
|
-
- each item has required fields (slug, prompt, ownsFiles)
|
|
110
|
-
- no file ownership overlap
|
|
111
|
-
- no placeholder strings remain
|
|
113
|
+
- each item has required fields (slug, prompt, ownsFiles, and optionally testCmd)
|
|
114
|
+
- no file ownership overlap (per-manifest)
|
|
115
|
+
- optionally: no placeholder strings remain (for instantiated manifests)
|
|
112
116
|
|
|
113
117
|
Args:
|
|
114
118
|
manifest: the manifest dict to validate
|
|
119
|
+
allow_placeholders: if False, reject unsubstituted placeholders (default: True,
|
|
120
|
+
for backward compatibility with presets). Set to False for
|
|
121
|
+
instantiated manifests.
|
|
122
|
+
require_testcmd: if True, require testCmd field (default: True, since the wave
|
|
123
|
+
engine needs it). Set to False to validate presets only.
|
|
115
124
|
|
|
116
125
|
Raises:
|
|
117
|
-
ValueError: if validation fails
|
|
126
|
+
ValueError: if validation fails with detailed error per item
|
|
118
127
|
"""
|
|
119
128
|
if "items" not in manifest:
|
|
120
129
|
raise ValueError("Manifest missing 'items' array")
|
|
@@ -123,65 +132,160 @@ def validate_manifest(manifest: Dict[str, Any]) -> None:
|
|
|
123
132
|
if not isinstance(items, list) or len(items) == 0:
|
|
124
133
|
raise ValueError("'items' must be a non-empty list")
|
|
125
134
|
|
|
126
|
-
#
|
|
127
|
-
|
|
135
|
+
# Core required fields (always required).
|
|
136
|
+
required_core_fields = {"slug", "prompt", "ownsFiles"}
|
|
137
|
+
# Optional fields for instantiated manifests.
|
|
138
|
+
optional_fields = {"testCmd", "workDir"} if require_testcmd else set()
|
|
139
|
+
required_fields = required_core_fields | ({"testCmd"} if require_testcmd else set())
|
|
140
|
+
|
|
128
141
|
owner_map = {}
|
|
129
142
|
conflicts = []
|
|
143
|
+
errors = []
|
|
130
144
|
|
|
131
|
-
for item in items:
|
|
145
|
+
for item_idx, item in enumerate(items):
|
|
132
146
|
if not isinstance(item, dict):
|
|
133
|
-
|
|
147
|
+
errors.append(f"Item {item_idx}: must be a dict, got {type(item)}")
|
|
148
|
+
continue
|
|
134
149
|
|
|
135
|
-
# Check required fields.
|
|
136
|
-
for field in
|
|
150
|
+
# Check required core fields.
|
|
151
|
+
for field in required_core_fields:
|
|
137
152
|
if field not in item:
|
|
138
|
-
|
|
153
|
+
errors.append(f"Item {item_idx} ({item.get('slug', 'unknown')}): missing required field '{field}'")
|
|
154
|
+
|
|
155
|
+
# Check required testCmd if flag is set.
|
|
156
|
+
if require_testcmd and "testCmd" not in item:
|
|
157
|
+
errors.append(f"Item {item_idx} ({item.get('slug', 'unknown')}): missing required field 'testCmd'")
|
|
139
158
|
|
|
140
|
-
slug = item
|
|
141
|
-
if
|
|
142
|
-
|
|
159
|
+
slug = item.get("slug")
|
|
160
|
+
if slug is not None:
|
|
161
|
+
if not isinstance(slug, str) or not slug:
|
|
162
|
+
errors.append(f"Item {item_idx}: slug must be non-empty string, got {slug}")
|
|
143
163
|
|
|
144
164
|
# Check ownsFiles.
|
|
145
165
|
owned_files = item.get("ownsFiles", [])
|
|
146
|
-
if not isinstance(owned_files, list)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
if not isinstance(owned_files, list):
|
|
167
|
+
errors.append(f"Item {item_idx} ({slug}): ownsFiles must be a list")
|
|
168
|
+
continue
|
|
169
|
+
if len(owned_files) == 0:
|
|
170
|
+
errors.append(f"Item {item_idx} ({slug}): ownsFiles must be non-empty")
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# Track file ownership (within this manifest only).
|
|
150
174
|
for f in owned_files:
|
|
151
175
|
if f in owner_map:
|
|
152
176
|
conflicts.append((f, owner_map[f], slug))
|
|
153
177
|
else:
|
|
154
178
|
owner_map[f] = slug
|
|
155
179
|
|
|
180
|
+
# Report all collected errors first.
|
|
181
|
+
if errors:
|
|
182
|
+
error_msg = "Manifest validation failed:\n " + "\n ".join(errors)
|
|
183
|
+
raise ValueError(error_msg)
|
|
184
|
+
|
|
156
185
|
if conflicts:
|
|
157
|
-
|
|
186
|
+
conflict_msg = "File ownership overlap (within manifest):\n " + "\n ".join(
|
|
187
|
+
[f"{f!r}: owned by both {o1!r} and {o2!r}" for f, o1, o2 in conflicts]
|
|
188
|
+
)
|
|
189
|
+
raise ValueError(conflict_msg)
|
|
190
|
+
|
|
191
|
+
# Check no placeholders remain (if instantiated manifest).
|
|
192
|
+
if not allow_placeholders:
|
|
193
|
+
manifest_str = json.dumps(manifest)
|
|
194
|
+
if "{project_name}" in manifest_str or "{base_dir}" in manifest_str:
|
|
195
|
+
raise ValueError("Manifest contains unsubstituted placeholders (not fully instantiated)")
|
|
196
|
+
|
|
158
197
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
198
|
+
def validate_presets(preset_names: List[str], output_json: bool = False) -> Tuple[bool, List[str]]:
|
|
199
|
+
"""Validate one or more presets.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
preset_names: list of preset names to validate (e.g., ["saas", "data", "library"])
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
tuple (success: bool, errors: List[str])
|
|
206
|
+
- success is True if all presets validate clean
|
|
207
|
+
- errors is a list of formatted error messages per preset/item
|
|
208
|
+
"""
|
|
209
|
+
errors = []
|
|
210
|
+
all_valid = True
|
|
211
|
+
results = {}
|
|
212
|
+
|
|
213
|
+
for preset_name in preset_names:
|
|
214
|
+
try:
|
|
215
|
+
preset = load_preset(preset_name)
|
|
216
|
+
# Validate the preset: allow placeholders (presets have them), require testCmd (wave engine needs it)
|
|
217
|
+
validate_manifest(preset, allow_placeholders=True, require_testcmd=True)
|
|
218
|
+
results[preset_name] = {"valid": True, "errors": []}
|
|
219
|
+
print(f"✓ {preset_name}: valid", file=sys.stderr)
|
|
220
|
+
except FileNotFoundError as e:
|
|
221
|
+
errors.append(f"✗ {preset_name}: {e}")
|
|
222
|
+
all_valid = False
|
|
223
|
+
results[preset_name] = {"valid": False, "errors": [str(e)]}
|
|
224
|
+
except ValueError as e:
|
|
225
|
+
errors.append(f"✗ {preset_name}: {e}")
|
|
226
|
+
all_valid = False
|
|
227
|
+
results[preset_name] = {"valid": False, "errors": [str(e)]}
|
|
228
|
+
except Exception as e:
|
|
229
|
+
errors.append(f"✗ {preset_name}: unexpected error: {e}")
|
|
230
|
+
all_valid = False
|
|
231
|
+
results[preset_name] = {"valid": False, "errors": [str(e)]}
|
|
232
|
+
|
|
233
|
+
if output_json:
|
|
234
|
+
print(json.dumps({"ok": all_valid, "templates": results}))
|
|
235
|
+
else:
|
|
236
|
+
# Print errors if any
|
|
237
|
+
for error in errors:
|
|
238
|
+
print(error, file=sys.stderr)
|
|
239
|
+
|
|
240
|
+
return all_valid, errors
|
|
163
241
|
|
|
164
242
|
|
|
165
243
|
def main():
|
|
166
|
-
"""CLI entry point: load preset
|
|
244
|
+
"""CLI entry point: load preset, instantiate, validate, or validate presets."""
|
|
167
245
|
parser = argparse.ArgumentParser(
|
|
168
|
-
description="
|
|
246
|
+
description="Wave manifest preset manager: load, instantiate, and validate presets"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Create subparsers for different commands
|
|
250
|
+
subparsers = parser.add_subparsers(dest="command", help="command to run")
|
|
251
|
+
|
|
252
|
+
# Subcommand: validate
|
|
253
|
+
validate_parser = subparsers.add_parser(
|
|
254
|
+
"validate",
|
|
255
|
+
help="validate preset template(s) for completeness"
|
|
256
|
+
)
|
|
257
|
+
validate_parser.add_argument(
|
|
258
|
+
"--template",
|
|
259
|
+
choices=["saas", "data", "library", "all"],
|
|
260
|
+
default="all",
|
|
261
|
+
help="which preset(s) to validate (default: all)"
|
|
262
|
+
)
|
|
263
|
+
validate_parser.add_argument(
|
|
264
|
+
"--json",
|
|
265
|
+
action="store_true",
|
|
266
|
+
help="output results in JSON format"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Subcommand: instantiate
|
|
270
|
+
inst_parser = subparsers.add_parser(
|
|
271
|
+
"instantiate",
|
|
272
|
+
help="load and instantiate a preset manifest"
|
|
169
273
|
)
|
|
170
|
-
|
|
274
|
+
inst_parser.add_argument(
|
|
171
275
|
"preset",
|
|
172
276
|
help="preset name: saas, data, or library"
|
|
173
277
|
)
|
|
174
|
-
|
|
278
|
+
inst_parser.add_argument(
|
|
175
279
|
"--project-name",
|
|
176
280
|
required=True,
|
|
177
281
|
help="project/app name (e.g., 'payment-api')"
|
|
178
282
|
)
|
|
179
|
-
|
|
283
|
+
inst_parser.add_argument(
|
|
180
284
|
"--base-dir",
|
|
181
285
|
required=True,
|
|
182
286
|
help="base working directory (e.g., '/workspace/my-app')"
|
|
183
287
|
)
|
|
184
|
-
|
|
288
|
+
inst_parser.add_argument(
|
|
185
289
|
"--output",
|
|
186
290
|
default=None,
|
|
187
291
|
help="output file (default: stdout)"
|
|
@@ -190,21 +294,42 @@ def main():
|
|
|
190
294
|
args = parser.parse_args()
|
|
191
295
|
|
|
192
296
|
try:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
297
|
+
if args.command == "validate":
|
|
298
|
+
# Validate preset(s)
|
|
299
|
+
if args.template == "all":
|
|
300
|
+
presets = ["saas", "data", "library"]
|
|
301
|
+
else:
|
|
302
|
+
presets = [args.template]
|
|
303
|
+
|
|
304
|
+
success, errors = validate_presets(presets, output_json=args.json)
|
|
305
|
+
|
|
306
|
+
if not success:
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
else:
|
|
309
|
+
print(f"\nAll {len(presets)} preset(s) validated successfully.", file=sys.stderr)
|
|
310
|
+
sys.exit(0)
|
|
311
|
+
|
|
312
|
+
elif args.command == "instantiate":
|
|
313
|
+
# Instantiate preset
|
|
314
|
+
preset = load_preset(args.preset)
|
|
315
|
+
manifest = instantiate_template(preset, args.project_name, args.base_dir)
|
|
316
|
+
|
|
317
|
+
# Validate the instantiated manifest (disallow placeholders, require testCmd)
|
|
318
|
+
validate_manifest(manifest, allow_placeholders=False, require_testcmd=True)
|
|
319
|
+
|
|
320
|
+
# Output.
|
|
321
|
+
output_json = json.dumps(manifest, indent=2)
|
|
322
|
+
if args.output:
|
|
323
|
+
output_path = Path(args.output)
|
|
324
|
+
output_path.write_text(output_json, encoding="utf-8")
|
|
325
|
+
print(f"Manifest written to {output_path}", file=sys.stderr)
|
|
326
|
+
else:
|
|
327
|
+
print(output_json)
|
|
328
|
+
|
|
206
329
|
else:
|
|
207
|
-
print
|
|
330
|
+
# No command specified - print help
|
|
331
|
+
parser.print_help()
|
|
332
|
+
sys.exit(0)
|
|
208
333
|
|
|
209
334
|
except Exception as e:
|
|
210
335
|
print(f"Error: {e}", file=sys.stderr)
|