@matt82198/aesop 0.2.0 → 0.3.2
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 +39 -1
- package/README.md +60 -263
- package/bin/cli.js +7 -3
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +48 -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 +6 -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/driver/wave_loop.py
CHANGED
|
@@ -6,7 +6,7 @@ that mirrors the phase sequence from wave-flat-dispatch.template.mjs but runs
|
|
|
6
6
|
offline against AgentDriver backends (Claude Code, Codex, open-model, etc.).
|
|
7
7
|
|
|
8
8
|
Phases (mirror the template):
|
|
9
|
-
1. Preflight ownership guard: check no two items share an ownsFiles path
|
|
9
|
+
1. Preflight ownership guard: check no two items share an ownsFiles path (per-repo)
|
|
10
10
|
2. Resolve policy ONCE: call verification_policy(caps) and use the returned
|
|
11
11
|
knobs for repair_cap, spot_check_frac, require_adversarial_review
|
|
12
12
|
3. Cost-ceiling gate (fail-closed): before build and before each repair round,
|
|
@@ -16,7 +16,18 @@ Phases (mirror the template):
|
|
|
16
16
|
5. Bounded repair: for failed items, retry with test output appended to prompt,
|
|
17
17
|
up to policy's repair_cap rounds
|
|
18
18
|
6. Adversarial review: if required, dispatch a review per item or mark deferred
|
|
19
|
-
7.
|
|
19
|
+
7. Per-repo ship: if git config given, group items by repo and run the git
|
|
20
|
+
sequence (add [repo-relative files], commit, push) separately for each repo,
|
|
21
|
+
with expectTopLevel guard verified PER REPO before any write
|
|
22
|
+
|
|
23
|
+
PHASE 1 (CROSS-REPO) SCOPE:
|
|
24
|
+
- Manifest items support optional `repo` field (absolute path, must exist, resolved)
|
|
25
|
+
- Preflight validates repo exists and rejects non-absolute paths (fail-closed)
|
|
26
|
+
- Ownership disjointness is per-repo (same file in different repos is NOT a conflict)
|
|
27
|
+
- Ship phase respects repo boundaries: one commit per repo, each repo's cwd
|
|
28
|
+
- Journal keys include repo context (safe-slug of repo basename + item slug)
|
|
29
|
+
- Shipped items reported per-repo in the Report JSON
|
|
30
|
+
- Phase-2 (future): per-repo secret-scan gate, per-repo branch rules, multi-box state
|
|
20
31
|
|
|
21
32
|
HONESTY GUARANTEE:
|
|
22
33
|
- Verified = True ONLY if the item's test passed (exit code 0 from run_command).
|
|
@@ -28,6 +39,7 @@ FAIL-SAFE:
|
|
|
28
39
|
- Cost-ceiling check: if exceeded, ABORT the wave immediately (return early).
|
|
29
40
|
- Disjoint ownership: any overlap -> ABORT with structured error, no dispatch.
|
|
30
41
|
- Repair cap bounded: never infinite retry loop.
|
|
42
|
+
- Ship phase: per-repo expectTopLevel guard aborts THAT REPO's ship without corrupting others.
|
|
31
43
|
|
|
32
44
|
stdlib-only, ASCII-only, Windows + Linux safe.
|
|
33
45
|
"""
|
|
@@ -111,6 +123,64 @@ def _quote_arg(s: str) -> str:
|
|
|
111
123
|
return shlex.quote(s)
|
|
112
124
|
|
|
113
125
|
|
|
126
|
+
def _validate_repo_path(repo: str) -> str:
|
|
127
|
+
"""Validate and normalize a repo path to absolute.
|
|
128
|
+
|
|
129
|
+
Rejects relative paths, symlink escapes, and non-existent paths (fail-closed).
|
|
130
|
+
Returns the absolute, normalized path if valid.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
repo: the repo path to validate
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
str: absolute, normalized repo path
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: if repo is relative, contains .. escape, or path issues
|
|
140
|
+
"""
|
|
141
|
+
repo_path = Path(repo).resolve()
|
|
142
|
+
|
|
143
|
+
# Ensure the resolved path is absolute (should always be true after resolve())
|
|
144
|
+
if not repo_path.is_absolute():
|
|
145
|
+
raise ValueError(f"repo path must be absolute: {repo}")
|
|
146
|
+
|
|
147
|
+
# Verify the path exists (fail-closed)
|
|
148
|
+
if not repo_path.exists():
|
|
149
|
+
raise ValueError(f"repo path does not exist: {repo}")
|
|
150
|
+
|
|
151
|
+
return str(repo_path)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_file_path(file_path: str, repo_root: str) -> None:
|
|
155
|
+
"""Validate a file path for safety before git operations.
|
|
156
|
+
|
|
157
|
+
Ensures the path is:
|
|
158
|
+
1. Relative (not absolute)
|
|
159
|
+
2. After joining with repo root and resolving, still inside that repo root (no traversal)
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
file_path: the path to validate (should be repo-relative)
|
|
163
|
+
repo_root: the absolute repo root path
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: if path is absolute or escapes the repo root
|
|
167
|
+
"""
|
|
168
|
+
# Check if path is absolute (reject).
|
|
169
|
+
if Path(file_path).is_absolute():
|
|
170
|
+
raise ValueError(f"file path must be relative, got absolute: {file_path}")
|
|
171
|
+
|
|
172
|
+
# Join with repo root and resolve to get the absolute path.
|
|
173
|
+
repo_root_path = Path(repo_root).resolve()
|
|
174
|
+
full_path = (repo_root_path / file_path).resolve()
|
|
175
|
+
|
|
176
|
+
# Verify the resolved path is still inside the repo root (reject traversal).
|
|
177
|
+
try:
|
|
178
|
+
# This will raise ValueError if full_path is not relative to repo_root_path
|
|
179
|
+
full_path.relative_to(repo_root_path)
|
|
180
|
+
except ValueError:
|
|
181
|
+
raise ValueError(f"file path escapes repo root: {file_path} (resolved to {full_path}, outside {repo_root_path})")
|
|
182
|
+
|
|
183
|
+
|
|
114
184
|
def _safe_slug(slug: str) -> str:
|
|
115
185
|
"""Sanitize a slug to prevent path traversal attacks and enforce filesystem limits.
|
|
116
186
|
|
|
@@ -170,11 +240,44 @@ def _safe_slug(slug: str) -> str:
|
|
|
170
240
|
return sanitized
|
|
171
241
|
|
|
172
242
|
|
|
243
|
+
def _journal_key_for_item(item: Dict[str, Any]) -> str:
|
|
244
|
+
"""Generate a collision-free journal key for an item, including repo context.
|
|
245
|
+
|
|
246
|
+
For items with a `repo` field, the key is:
|
|
247
|
+
safe-slug(repo-basename) + '--' + safe-slug(item-slug)
|
|
248
|
+
For items without a repo field, the key is:
|
|
249
|
+
safe-slug(item-slug)
|
|
250
|
+
|
|
251
|
+
This ensures same-slug items across repos don't collide.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
item: dict with slug and optional repo field
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
str: sanitized journal key
|
|
258
|
+
"""
|
|
259
|
+
slug = item.get("slug", "unknown")
|
|
260
|
+
repo = item.get("repo")
|
|
261
|
+
|
|
262
|
+
if repo:
|
|
263
|
+
try:
|
|
264
|
+
# Get the basename of the repo (last component of the path)
|
|
265
|
+
repo_basename = Path(repo).resolve().name
|
|
266
|
+
repo_key = _safe_slug(repo_basename)
|
|
267
|
+
except Exception:
|
|
268
|
+
# Fallback: use the full repo path hashed
|
|
269
|
+
repo_key = _safe_slug(Path(repo).name or "repo")
|
|
270
|
+
item_key = _safe_slug(slug)
|
|
271
|
+
return f"{repo_key}--{item_key}"
|
|
272
|
+
else:
|
|
273
|
+
return _safe_slug(slug)
|
|
274
|
+
|
|
275
|
+
|
|
173
276
|
# ========================================================================
|
|
174
277
|
# Wave Recovery: Journal and Resume Support
|
|
175
278
|
# ========================================================================
|
|
176
279
|
|
|
177
|
-
def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str, Any]) -> None:
|
|
280
|
+
def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str, Any], repo: str = None) -> None:
|
|
178
281
|
"""Write a journal entry for an item's progress.
|
|
179
282
|
|
|
180
283
|
Args:
|
|
@@ -182,24 +285,29 @@ def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str,
|
|
|
182
285
|
slug: item slug (identifier)
|
|
183
286
|
phase: phase name (e.g., "verified", "failed", "dispatched")
|
|
184
287
|
data: dict with outcome data (verified, testExit, repairs, etc.)
|
|
288
|
+
repo: optional repo path for repo-aware journal keying
|
|
185
289
|
|
|
186
|
-
Journal is stored as: state_dir/journal/<
|
|
187
|
-
|
|
290
|
+
Journal is stored as: state_dir/journal/<journal-key>.json with timestamp.
|
|
291
|
+
Journal key includes repo context if provided, to prevent collisions.
|
|
188
292
|
"""
|
|
189
293
|
state_path = Path(state_dir)
|
|
190
294
|
journal_dir = state_path / "journal"
|
|
191
295
|
journal_dir.mkdir(parents=True, exist_ok=True)
|
|
192
296
|
|
|
193
|
-
#
|
|
297
|
+
# Generate repo-aware journal key.
|
|
194
298
|
try:
|
|
195
|
-
|
|
299
|
+
item_stub = {"slug": slug}
|
|
300
|
+
if repo:
|
|
301
|
+
item_stub["repo"] = repo
|
|
302
|
+
journal_key = _journal_key_for_item(item_stub)
|
|
196
303
|
except ValueError:
|
|
197
|
-
# Fail-closed: if
|
|
304
|
+
# Fail-closed: if key generation fails, skip journaling.
|
|
198
305
|
return
|
|
199
306
|
|
|
200
|
-
journal_file = journal_dir / f"{
|
|
307
|
+
journal_file = journal_dir / f"{journal_key}.json"
|
|
201
308
|
entry = {
|
|
202
309
|
"slug": slug,
|
|
310
|
+
"repo": repo,
|
|
203
311
|
"phase": phase,
|
|
204
312
|
"timestamp": time.time(),
|
|
205
313
|
**data,
|
|
@@ -216,10 +324,12 @@ def _load_journal_state(state_dir: str) -> Dict[str, Dict[str, Any]]:
|
|
|
216
324
|
"""Load journal state from state_dir.
|
|
217
325
|
|
|
218
326
|
Reads all JSON files from state_dir/journal/ and returns a dict
|
|
219
|
-
mapping slug -> journal_entry.
|
|
327
|
+
mapping journal_key (repo--slug or just slug) -> journal_entry.
|
|
328
|
+
|
|
329
|
+
The entry is looked up by (repo, slug) tuple for resume matching.
|
|
220
330
|
|
|
221
331
|
Returns:
|
|
222
|
-
dict mapping
|
|
332
|
+
dict mapping journal_key -> {phase, verified, testExit, repo, ...}
|
|
223
333
|
Returns empty dict if journal dir doesn't exist.
|
|
224
334
|
"""
|
|
225
335
|
state_path = Path(state_dir)
|
|
@@ -235,8 +345,12 @@ def _load_journal_state(state_dir: str) -> Dict[str, Dict[str, Any]]:
|
|
|
235
345
|
try:
|
|
236
346
|
entry = json.loads(journal_file.read_text())
|
|
237
347
|
slug = entry.get("slug")
|
|
348
|
+
repo = entry.get("repo")
|
|
238
349
|
if slug:
|
|
239
|
-
|
|
350
|
+
# Use (repo, slug) as a composite key for lookup.
|
|
351
|
+
# If repo is None, key is just slug.
|
|
352
|
+
key = (repo, slug)
|
|
353
|
+
journal_state[key] = entry
|
|
240
354
|
except Exception:
|
|
241
355
|
# Skip malformed entries.
|
|
242
356
|
pass
|
|
@@ -364,6 +478,19 @@ def run_wave(
|
|
|
364
478
|
- Any exception in an item's dispatch -> verified=False for that item.
|
|
365
479
|
- Cost ceiling: if check() says exceeded, abort immediately with no more dispatch.
|
|
366
480
|
- Disjoint ownership: any overlap -> abort with structured error, no dispatch.
|
|
481
|
+
|
|
482
|
+
Explicit-repo preflight (deprecate expectTopLevel default):
|
|
483
|
+
When git shipping is configured AND a manifest contains ANY item with an
|
|
484
|
+
explicit 'repo' field, items WITHOUT 'repo' are REJECTED at preflight
|
|
485
|
+
with a clear error. This prevents silent mismatches caused by mixed
|
|
486
|
+
manifests (some items use expectTopLevel default, others are explicit).
|
|
487
|
+
|
|
488
|
+
Pure-legacy manifests (NO item has 'repo') keep the expectTopLevel
|
|
489
|
+
default unchanged (backward compat). Fully-explicit manifests (ALL items
|
|
490
|
+
have 'repo') work unchanged. Mixed manifests are fail-closed.
|
|
491
|
+
|
|
492
|
+
Error: abort_reason="mixed_repo_manifest", with items_with_repo and
|
|
493
|
+
items_without_repo counts in the result.
|
|
367
494
|
"""
|
|
368
495
|
result = {
|
|
369
496
|
"preflight_ok": False,
|
|
@@ -392,14 +519,108 @@ def run_wave(
|
|
|
392
519
|
result["resume_stats"] = resume_stats
|
|
393
520
|
|
|
394
521
|
# ========================================================================
|
|
395
|
-
# PHASE 1: Preflight ownership guard
|
|
522
|
+
# PHASE 1: Preflight ownership guard (with per-repo validation)
|
|
396
523
|
# ========================================================================
|
|
397
|
-
|
|
524
|
+
|
|
525
|
+
# IMPORTANT: When git is configured (ship phase enabled), all items MUST have an
|
|
526
|
+
# absolute resolved `repo` field after preflight. This ensures manifests behave
|
|
527
|
+
# identically regardless of process cwd.
|
|
528
|
+
#
|
|
529
|
+
# Default for items without explicit `repo`:
|
|
530
|
+
# - If git config has expectTopLevel, use that (legacy behavior anchor)
|
|
531
|
+
# - If git config NOT present: no repo validation needed (non-ship-phase wave)
|
|
532
|
+
# - If git config present but NO expectTopLevel: error (can't default repo for shipping)
|
|
533
|
+
|
|
534
|
+
# Determine the default repo from git config (legacy anchor for byte-identical behavior).
|
|
535
|
+
# Only used when git config is present (i.e., shipping is enabled).
|
|
536
|
+
default_repo = None
|
|
537
|
+
if git is not None:
|
|
538
|
+
default_repo = git.get("expectTopLevel")
|
|
539
|
+
|
|
540
|
+
# EXPLICIT-REPO PREFLIGHT: Detect mixed manifests (some items with repo, some without).
|
|
541
|
+
# Contract:
|
|
542
|
+
# - Pure-legacy manifest (NO item has repo) → use expectTopLevel default (backward compat)
|
|
543
|
+
# - Fully-explicit manifest (ALL items have repo) → use explicit repos (unchanged)
|
|
544
|
+
# - Mixed manifest (SOME items have repo) → REJECT with clear error (fail-closed)
|
|
545
|
+
#
|
|
546
|
+
# This ensures:
|
|
547
|
+
# 1. Legacy manifests continue to work (no breaking change)
|
|
548
|
+
# 2. Explicit manifests work correctly (no confusion)
|
|
549
|
+
# 3. Mixed manifests are caught early, preventing subtle bugs
|
|
550
|
+
if git is not None:
|
|
551
|
+
# Count items with and without explicit repo field
|
|
552
|
+
items_with_repo = sum(1 for item in items if item.get("repo"))
|
|
553
|
+
items_without_repo = len(items) - items_with_repo
|
|
554
|
+
|
|
555
|
+
if items_with_repo > 0 and items_without_repo > 0:
|
|
556
|
+
# Mixed manifest: some items have repo, some don't
|
|
557
|
+
result["aborted"] = True
|
|
558
|
+
result["abort_reason"] = "mixed_repo_manifest"
|
|
559
|
+
result["error"] = (
|
|
560
|
+
"mixed manifest detected: some items have explicit 'repo' field, others don't. "
|
|
561
|
+
"Manifests must be fully explicit (all items have 'repo') or fully implicit (none have 'repo'). "
|
|
562
|
+
"To fix: either add 'repo' field to all items or remove it from all items (use expectTopLevel instead)."
|
|
563
|
+
)
|
|
564
|
+
result["items_with_repo"] = items_with_repo
|
|
565
|
+
result["items_without_repo"] = items_without_repo
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
# Validate and resolve all repos; populate default for missing items (only if shipping).
|
|
569
|
+
repo_paths = set()
|
|
570
|
+
|
|
571
|
+
for item in items:
|
|
572
|
+
repo = item.get("repo")
|
|
573
|
+
|
|
574
|
+
if not repo:
|
|
575
|
+
# No explicit repo field.
|
|
576
|
+
if git is not None:
|
|
577
|
+
# Ship phase is configured; must have a default (repo field must be missing on ALL items, per mixed check above).
|
|
578
|
+
if default_repo:
|
|
579
|
+
repo = default_repo
|
|
580
|
+
else:
|
|
581
|
+
# Shipping enabled but can't default repo for this item.
|
|
582
|
+
result["aborted"] = True
|
|
583
|
+
result["abort_reason"] = "repo_field_missing_no_default"
|
|
584
|
+
result["error"] = "item requires 'repo' field when git shipping is configured (set expectTopLevel or add repo field)"
|
|
585
|
+
result["item_slug"] = item.get("slug", "unknown")
|
|
586
|
+
return result
|
|
587
|
+
else:
|
|
588
|
+
# No shipping phase; skip repo validation for this item.
|
|
589
|
+
# This allows backward-compatible non-shipping waves without repo fields.
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
# Validate and resolve the repo path to absolute.
|
|
593
|
+
try:
|
|
594
|
+
repo_resolved = _validate_repo_path(repo)
|
|
595
|
+
repo_paths.add(repo_resolved)
|
|
596
|
+
# Update item with resolved path for later use (ensures byte-identical cwd).
|
|
597
|
+
item["repo"] = repo_resolved
|
|
598
|
+
except ValueError as e:
|
|
599
|
+
result["aborted"] = True
|
|
600
|
+
result["abort_reason"] = "invalid_repo_path"
|
|
601
|
+
result["invalid_repo"] = repo
|
|
602
|
+
result["error"] = str(e)
|
|
603
|
+
return result
|
|
604
|
+
# Future: also validate is_git_worktree, has_secret_scan_gate
|
|
605
|
+
|
|
606
|
+
# Per-repo ownership guard: track ownership within each repo separately.
|
|
607
|
+
# Structure: {repo: {normalized_file: slug}}
|
|
608
|
+
repo_owner_map = {} # repo -> (normalized file -> slug)
|
|
398
609
|
conflicts = []
|
|
399
610
|
|
|
400
611
|
for item in items:
|
|
401
612
|
slug = item.get("slug", "unknown")
|
|
402
613
|
owned_files = item.get("ownsFiles", [])
|
|
614
|
+
repo = item.get("repo", ".") # Default to cwd if no repo specified
|
|
615
|
+
|
|
616
|
+
# Normalize repo path
|
|
617
|
+
repo_normalized = str(Path(repo).resolve()).lower()
|
|
618
|
+
|
|
619
|
+
if repo_normalized not in repo_owner_map:
|
|
620
|
+
repo_owner_map[repo_normalized] = {}
|
|
621
|
+
|
|
622
|
+
owner_map = repo_owner_map[repo_normalized]
|
|
623
|
+
|
|
403
624
|
for f in owned_files:
|
|
404
625
|
# Platform-independent path normalization: handle separators and case uniformly.
|
|
405
626
|
# Replace all backslashes with forward slashes, normalize with posixpath,
|
|
@@ -410,6 +631,7 @@ def run_wave(
|
|
|
410
631
|
{
|
|
411
632
|
"file": f,
|
|
412
633
|
"normalized": normalized,
|
|
634
|
+
"repo": repo,
|
|
413
635
|
"items": [owner_map[normalized], slug],
|
|
414
636
|
}
|
|
415
637
|
)
|
|
@@ -462,11 +684,13 @@ def run_wave(
|
|
|
462
684
|
"""Build one item and return (index, result_dict)."""
|
|
463
685
|
slug = item.get("slug", f"item-{item_index}")
|
|
464
686
|
workdir = item.get("workDir", ".")
|
|
687
|
+
repo = item.get("repo")
|
|
465
688
|
|
|
466
689
|
# ====================================================================
|
|
467
690
|
# RESUME CHECK: If in journal and verified, skip dispatch and trust-verify
|
|
468
691
|
# ====================================================================
|
|
469
|
-
|
|
692
|
+
journal_key = (repo, slug)
|
|
693
|
+
journal_entry = journal_state.get(journal_key)
|
|
470
694
|
skipped_from_journal = False
|
|
471
695
|
if journal_entry and _should_skip_from_journal(journal_entry):
|
|
472
696
|
skipped_from_journal = True
|
|
@@ -608,7 +832,7 @@ def run_wave(
|
|
|
608
832
|
"verified": item_result["verified"],
|
|
609
833
|
"testExit": item_result["testExit"],
|
|
610
834
|
"instance_id": instance_id,
|
|
611
|
-
})
|
|
835
|
+
}, repo=repo)
|
|
612
836
|
|
|
613
837
|
return (item_index, item_result)
|
|
614
838
|
|
|
@@ -620,7 +844,7 @@ def run_wave(
|
|
|
620
844
|
"testExit": None,
|
|
621
845
|
"instance_id": instance_id,
|
|
622
846
|
"error": str(exc),
|
|
623
|
-
})
|
|
847
|
+
}, repo=repo)
|
|
624
848
|
|
|
625
849
|
return (
|
|
626
850
|
item_index,
|
|
@@ -721,11 +945,12 @@ def run_wave(
|
|
|
721
945
|
|
|
722
946
|
# Write journal entry for repair outcome.
|
|
723
947
|
if state_dir:
|
|
948
|
+
repo = item.get("repo")
|
|
724
949
|
_write_journal_entry(state_dir, slug, "repaired", {
|
|
725
950
|
"verified": item_result["verified"],
|
|
726
951
|
"testExit": item_result["testExit"],
|
|
727
952
|
"repairs": item_result["repairs"],
|
|
728
|
-
})
|
|
953
|
+
}, repo=repo)
|
|
729
954
|
|
|
730
955
|
# If still failed, mark for next round.
|
|
731
956
|
if not item_result["verified"]:
|
|
@@ -737,12 +962,13 @@ def run_wave(
|
|
|
737
962
|
|
|
738
963
|
# Write journal entry for repair failure.
|
|
739
964
|
if state_dir:
|
|
965
|
+
repo = item.get("repo")
|
|
740
966
|
_write_journal_entry(state_dir, slug, "repair_failed", {
|
|
741
967
|
"verified": False,
|
|
742
968
|
"testExit": None,
|
|
743
969
|
"repairs": item_result["repairs"],
|
|
744
970
|
"error": str(exc),
|
|
745
|
-
})
|
|
971
|
+
}, repo=repo)
|
|
746
972
|
|
|
747
973
|
next_failed.append((item_index, item, item_result))
|
|
748
974
|
|
|
@@ -795,7 +1021,7 @@ def run_wave(
|
|
|
795
1021
|
item_result["adversarial_review"] = "deferred"
|
|
796
1022
|
|
|
797
1023
|
# ========================================================================
|
|
798
|
-
# PHASE 7:
|
|
1024
|
+
# PHASE 7: Per-repo ship (git operations, if configured)
|
|
799
1025
|
# ========================================================================
|
|
800
1026
|
if git is not None:
|
|
801
1027
|
# Verify expectTopLevel guard: MUST be a non-empty string matching actual toplevel.
|
|
@@ -806,59 +1032,200 @@ def run_wave(
|
|
|
806
1032
|
result["abort_reason"] = "git_toplevel_missing_or_empty"
|
|
807
1033
|
return result
|
|
808
1034
|
|
|
809
|
-
toplevel_result = driver.run_command("git rev-parse --show-toplevel")
|
|
810
|
-
if toplevel_result.exit_code != 0:
|
|
811
|
-
result["aborted"] = True
|
|
812
|
-
result["abort_reason"] = "git_toplevel_check_failed"
|
|
813
|
-
return result
|
|
814
|
-
|
|
815
|
-
toplevel = toplevel_result.stdout.strip()
|
|
816
|
-
if toplevel != expect_top_level:
|
|
817
|
-
result["aborted"] = True
|
|
818
|
-
result["abort_reason"] = "git_toplevel_mismatch"
|
|
819
|
-
return result
|
|
820
|
-
|
|
821
1035
|
# Only ship items that verified green.
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if item_result.get("verified", False)
|
|
825
|
-
]
|
|
826
|
-
|
|
827
|
-
if verified_items:
|
|
828
|
-
# Stage and commit.
|
|
829
|
-
files_to_add = []
|
|
830
|
-
for item_result in verified_items:
|
|
831
|
-
files_to_add.extend(item_result.get("filesWritten", []))
|
|
832
|
-
|
|
833
|
-
if files_to_add:
|
|
834
|
-
# Add files. Escape each filename to prevent shell injection.
|
|
835
|
-
# Use _quote_arg for platform-safe quoting (Windows + POSIX).
|
|
836
|
-
escaped_files = [_quote_arg(f) for f in files_to_add]
|
|
837
|
-
add_cmd = "git add " + " ".join(escaped_files)
|
|
838
|
-
add_result = driver.run_command(add_cmd)
|
|
839
|
-
if add_result.exit_code != 0:
|
|
840
|
-
result["aborted"] = True
|
|
841
|
-
result["abort_reason"] = "git_add_failed"
|
|
842
|
-
return result
|
|
843
|
-
|
|
844
|
-
# Commit. Escape the message to prevent shell injection.
|
|
845
|
-
# Use _quote_arg for platform-safe quoting.
|
|
846
|
-
commit_msg = f"Wave: {len(verified_items)} items verified"
|
|
847
|
-
commit_cmd = f"git commit -m {_quote_arg(commit_msg)}"
|
|
848
|
-
commit_result = driver.run_command(commit_cmd)
|
|
849
|
-
if commit_result.exit_code != 0:
|
|
850
|
-
# Might be "nothing to commit"; not necessarily a failure.
|
|
851
|
-
pass
|
|
1036
|
+
# Build a slug -> original_item mapping for lookup.
|
|
1037
|
+
slug_to_item = {item.get("slug"): (i, item) for i, item in enumerate(items)}
|
|
852
1038
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1039
|
+
verified_items = []
|
|
1040
|
+
for item_result in result["built"]:
|
|
1041
|
+
if item_result.get("verified", False):
|
|
1042
|
+
slug = item_result.get("slug")
|
|
1043
|
+
if slug in slug_to_item:
|
|
1044
|
+
item_index, original_item = slug_to_item[slug]
|
|
1045
|
+
verified_items.append((item_index, original_item, item_result))
|
|
859
1046
|
|
|
860
|
-
|
|
861
|
-
|
|
1047
|
+
if verified_items:
|
|
1048
|
+
# Group verified items by their resolved repo.
|
|
1049
|
+
repo_to_items = {} # {repo_path: [(item_index, original_item, item_result), ...]}
|
|
1050
|
+
for item_index, original_item, item_result in verified_items:
|
|
1051
|
+
repo = original_item.get("repo", ".")
|
|
1052
|
+
# Resolve and validate repo path.
|
|
1053
|
+
try:
|
|
1054
|
+
repo_resolved = _validate_repo_path(repo)
|
|
1055
|
+
except ValueError:
|
|
1056
|
+
# Fail-closed: this repo is invalid, mark as error but continue.
|
|
1057
|
+
item_result["ship_error"] = f"invalid repo path: {repo}"
|
|
1058
|
+
continue
|
|
1059
|
+
|
|
1060
|
+
if repo_resolved not in repo_to_items:
|
|
1061
|
+
repo_to_items[repo_resolved] = []
|
|
1062
|
+
repo_to_items[repo_resolved].append((item_index, original_item, item_result))
|
|
1063
|
+
|
|
1064
|
+
# Ship each repo separately.
|
|
1065
|
+
shipped_items = []
|
|
1066
|
+
repo_ship_results = [] # {repo, committed, sha, files_count, error}
|
|
1067
|
+
|
|
1068
|
+
for repo_path, repo_items in repo_to_items.items():
|
|
1069
|
+
# Verify expectTopLevel guard PER REPO:
|
|
1070
|
+
# Each repo's toplevel must equal the global expectTopLevel OR the repo's own root.
|
|
1071
|
+
# First, verify the repo is actually a git repo with the right toplevel.
|
|
1072
|
+
toplevel_result = driver.run_command(
|
|
1073
|
+
"git rev-parse --show-toplevel",
|
|
1074
|
+
cwd=repo_path
|
|
1075
|
+
)
|
|
1076
|
+
if toplevel_result.exit_code != 0:
|
|
1077
|
+
# This repo's git is broken; abort THIS repo's ship but continue others.
|
|
1078
|
+
repo_ship_results.append({
|
|
1079
|
+
"repo": repo_path,
|
|
1080
|
+
"committed": False,
|
|
1081
|
+
"error": "git_toplevel_check_failed",
|
|
1082
|
+
"files_count": len(repo_items),
|
|
1083
|
+
})
|
|
1084
|
+
# Mark items from this repo as shipped_error.
|
|
1085
|
+
for _, _, item_result in repo_items:
|
|
1086
|
+
item_result["ship_error"] = "git_toplevel_check_failed"
|
|
1087
|
+
continue
|
|
1088
|
+
|
|
1089
|
+
toplevel = toplevel_result.stdout.strip()
|
|
1090
|
+
# Normalize paths for comparison (git may return with / on Windows)
|
|
1091
|
+
toplevel_normalized = str(Path(toplevel).resolve())
|
|
1092
|
+
repo_path_normalized = str(Path(repo_path).resolve())
|
|
1093
|
+
|
|
1094
|
+
# Per-repo guard: the repo's toplevel must match that repo's own root.
|
|
1095
|
+
# This ensures we're not operating on a subdirectory or symlink escaping.
|
|
1096
|
+
if toplevel_normalized != repo_path_normalized:
|
|
1097
|
+
# Top-level mismatch; abort THIS repo's ship but continue others.
|
|
1098
|
+
repo_ship_results.append({
|
|
1099
|
+
"repo": repo_path,
|
|
1100
|
+
"committed": False,
|
|
1101
|
+
"error": "git_toplevel_mismatch",
|
|
1102
|
+
"files_count": len(repo_items),
|
|
1103
|
+
"expected_repo_root": repo_path_normalized,
|
|
1104
|
+
"actual_toplevel": toplevel_normalized,
|
|
1105
|
+
})
|
|
1106
|
+
# Mark items from this repo as shipped_error.
|
|
1107
|
+
for _, _, item_result in repo_items:
|
|
1108
|
+
item_result["ship_error"] = "git_toplevel_mismatch"
|
|
1109
|
+
continue
|
|
1110
|
+
|
|
1111
|
+
# Collect files for this repo (repo-relative).
|
|
1112
|
+
files_to_add = []
|
|
1113
|
+
for _, _, item_result in repo_items:
|
|
1114
|
+
files_to_add.extend(item_result.get("filesWritten", []))
|
|
1115
|
+
|
|
1116
|
+
if files_to_add:
|
|
1117
|
+
# VALIDATION P2: Validate all filesWritten paths before git operations.
|
|
1118
|
+
# Ensure they are relative and don't escape the repo root.
|
|
1119
|
+
invalid_files = []
|
|
1120
|
+
for file_path in files_to_add:
|
|
1121
|
+
try:
|
|
1122
|
+
_validate_file_path(file_path, repo_path)
|
|
1123
|
+
except ValueError as e:
|
|
1124
|
+
invalid_files.append((file_path, str(e)))
|
|
1125
|
+
|
|
1126
|
+
if invalid_files:
|
|
1127
|
+
# Path validation failed; fail this item explicitly.
|
|
1128
|
+
repo_ship_results.append({
|
|
1129
|
+
"repo": repo_path,
|
|
1130
|
+
"committed": False,
|
|
1131
|
+
"error": "invalid_file_paths",
|
|
1132
|
+
"files_count": len(repo_items),
|
|
1133
|
+
"invalid_files": invalid_files,
|
|
1134
|
+
})
|
|
1135
|
+
# Mark items from this repo as shipped_error.
|
|
1136
|
+
for _, _, item_result in repo_items:
|
|
1137
|
+
item_result["ship_error"] = f"invalid file paths: {invalid_files}"
|
|
1138
|
+
continue
|
|
1139
|
+
|
|
1140
|
+
# Add files. Escape each filename to prevent shell injection.
|
|
1141
|
+
escaped_files = [_quote_arg(f) for f in files_to_add]
|
|
1142
|
+
add_cmd = "git add " + " ".join(escaped_files)
|
|
1143
|
+
add_result = driver.run_command(add_cmd, cwd=repo_path)
|
|
1144
|
+
if add_result.exit_code != 0:
|
|
1145
|
+
# GIT ADD FAILURE P1: git add may have partially succeeded,
|
|
1146
|
+
# leaving staged residue. Run git reset to clean the index.
|
|
1147
|
+
reset_result = driver.run_command("git reset", cwd=repo_path)
|
|
1148
|
+
unstage_ok = reset_result.exit_code == 0
|
|
1149
|
+
|
|
1150
|
+
repo_ship_results.append({
|
|
1151
|
+
"repo": repo_path,
|
|
1152
|
+
"committed": False,
|
|
1153
|
+
"error": "git_add_failed",
|
|
1154
|
+
# Truncated stderr/stdout for diagnostics (same as commit failure path)
|
|
1155
|
+
"error_detail": ((add_result.stderr or "") + " | " + (add_result.stdout or ""))[:300],
|
|
1156
|
+
"files_count": len(repo_items),
|
|
1157
|
+
"files_unstaged": unstage_ok,
|
|
1158
|
+
"unstage_error": None if unstage_ok else reset_result.stderr,
|
|
1159
|
+
})
|
|
1160
|
+
# Mark items from this repo as shipped_error.
|
|
1161
|
+
for _, _, item_result in repo_items:
|
|
1162
|
+
item_result["ship_error"] = "git_add_failed"
|
|
1163
|
+
continue
|
|
1164
|
+
|
|
1165
|
+
# Commit. Escape the message to prevent shell injection.
|
|
1166
|
+
commit_msg = f"Wave: {len(repo_items)} items verified"
|
|
1167
|
+
commit_cmd = f"git commit -m {_quote_arg(commit_msg)}"
|
|
1168
|
+
commit_result = driver.run_command(commit_cmd, cwd=repo_path)
|
|
1169
|
+
if commit_result.exit_code != 0:
|
|
1170
|
+
# UNSTAGE P3: Commit failed; run git reset to unstage the files.
|
|
1171
|
+
# This prevents staged-files residue on partial failure.
|
|
1172
|
+
reset_result = driver.run_command("git reset", cwd=repo_path)
|
|
1173
|
+
unstage_ok = reset_result.exit_code == 0
|
|
1174
|
+
|
|
1175
|
+
repo_ship_results.append({
|
|
1176
|
+
"repo": repo_path,
|
|
1177
|
+
"committed": False,
|
|
1178
|
+
"error": "git_commit_failed",
|
|
1179
|
+
# Truncated stderr/stdout: a bare label is undiagnosable
|
|
1180
|
+
# from the Report (identity, hooks, lock contention all
|
|
1181
|
+
# land here with different remedies).
|
|
1182
|
+
"error_detail": ((commit_result.stderr or "") + " | " + (commit_result.stdout or ""))[:300],
|
|
1183
|
+
"files_count": len(repo_items),
|
|
1184
|
+
"files_unstaged": unstage_ok,
|
|
1185
|
+
"unstage_error": None if unstage_ok else reset_result.stderr,
|
|
1186
|
+
})
|
|
1187
|
+
# Mark items from this repo as shipped_error.
|
|
1188
|
+
for _, _, item_result in repo_items:
|
|
1189
|
+
item_result["ship_error"] = "git_commit_failed"
|
|
1190
|
+
continue
|
|
1191
|
+
|
|
1192
|
+
# Get the commit SHA.
|
|
1193
|
+
sha_result = driver.run_command("git rev-parse HEAD", cwd=repo_path)
|
|
1194
|
+
commit_sha = sha_result.stdout.strip() if sha_result.exit_code == 0 else None
|
|
1195
|
+
|
|
1196
|
+
# Push.
|
|
1197
|
+
push_result = driver.run_command("git push", cwd=repo_path)
|
|
1198
|
+
if push_result.exit_code != 0:
|
|
1199
|
+
# Push failed; abort THIS repo's push but continue others.
|
|
1200
|
+
repo_ship_results.append({
|
|
1201
|
+
"repo": repo_path,
|
|
1202
|
+
"committed": True,
|
|
1203
|
+
"sha": commit_sha,
|
|
1204
|
+
"error": "git_push_failed",
|
|
1205
|
+
"files_count": len(repo_items),
|
|
1206
|
+
})
|
|
1207
|
+
# Mark items from this repo as shipped (commit succeeded even if push failed).
|
|
1208
|
+
for _, _, item_result in repo_items:
|
|
1209
|
+
item_result["ship_warning"] = "git_push_failed"
|
|
1210
|
+
shipped_items.append(item_result["slug"])
|
|
1211
|
+
continue
|
|
1212
|
+
|
|
1213
|
+
# Success: record this repo's ship.
|
|
1214
|
+
repo_ship_results.append({
|
|
1215
|
+
"repo": repo_path,
|
|
1216
|
+
"committed": True,
|
|
1217
|
+
"sha": commit_sha,
|
|
1218
|
+
"files_count": len(repo_items),
|
|
1219
|
+
})
|
|
1220
|
+
# Mark items from this repo as shipped.
|
|
1221
|
+
for _, _, item_result in repo_items:
|
|
1222
|
+
shipped_items.append(item_result["slug"])
|
|
1223
|
+
|
|
1224
|
+
# Record shipped items and per-repo results.
|
|
1225
|
+
if shipped_items:
|
|
1226
|
+
result["shipped"] = shipped_items
|
|
1227
|
+
if repo_ship_results:
|
|
1228
|
+
result["shipped_repos"] = repo_ship_results
|
|
862
1229
|
|
|
863
1230
|
return result
|
|
864
1231
|
|