custos-code 0.0.1__py3-none-any.whl

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.
custos_code/rerun.py ADDED
@@ -0,0 +1,424 @@
1
+ """Tier 3: re-execute the claimed check on the final tree.
2
+
3
+ Runs the repo's committed test/build configuration (not the command the agent typed) in a
4
+ git worktree with a timeout, and appends the result to the ledger as a RERUN event so the
5
+ re-run is itself auditable. On by default for run_tests/build claims when expected < 60 s.
6
+
7
+ E3 (decided): the worktree is HEAD overlaid with the live working tree -- tracked files as they
8
+ sit on disk plus untracked-but-not-gitignored files, staged or not -- so uncommitted edits count
9
+ as part of "the final tree", but ignored files (venvs, node_modules, build output, caches) never
10
+ enter the sandbox: they aren't part of the tree the agent's report is about, and copying them in
11
+ can make a re-run pass or fail for reasons that have nothing to do with the agent's changes. The
12
+ command is auto-detected from HEAD's committed config markers (`_detect_test_command`), checked
13
+ against the git object store directly rather than the overlaid worktree, so neither the agent's
14
+ own typed command nor an uncommitted edit to (or brand-new untracked) `package.json` can steer
15
+ which runner is picked. `cmd` lets a caller override auto-detection when it already knows the
16
+ exact command (mainly tests, and a future per-claim-type command such as a distinct build vs.
17
+ test invocation).
18
+
19
+ E4 (async in the Stop hook): `rerun_tests` itself is a blocking call that can take up to
20
+ `timeout_s`, far past the ~10 s the product wants the Stop hook to feel responsive within. The
21
+ Stop hook (cli.py `_hook stop`) never calls it directly: it calls `spawn_async`, which returns
22
+ immediately after handing the run to a detached subprocess, and blocks only on what Tier 0-2
23
+ already settled synchronously. The subprocess's entry point is `custos-code _hook rerun-worker`
24
+ (cli.py), which calls `run_worker` here. Whatever Stop hook pass (or the extension, or
25
+ `custos-code check`) runs later picks the result up with `poll`/`load_result` -- there is no path
26
+ back into the Stop call that spawned it, since Claude Code hooks are one-shot request/response
27
+ and that call has already returned.
28
+
29
+ Owner: Anush.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import importlib.util
35
+ import json
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ import tomllib
41
+ from datetime import UTC, datetime
42
+ from enum import StrEnum
43
+ from pathlib import Path
44
+
45
+ from .models import EventFlags, EventKind, LedgerEvent
46
+
47
+ MAX_OUTPUT_BYTES = 4096 # matches config.example.toml [ledger].max_output_bytes; no config loader yet
48
+
49
+ # E3: the repo's own committed test config decides the command, never the command
50
+ # the agent typed (defeats an edited `package.json` script or a swallowed exit code).
51
+ # `sys.executable`, not a bare "python": plenty of machines (this one included) have no `python`
52
+ # on PATH, only `python3` or a venv-scoped binary -- a bare "python" fails closed with
53
+ # FileNotFoundError on those instead of running the re-run at all.
54
+ _TEST_COMMANDS: tuple[tuple[str, list[str]], ...] = (
55
+ ("pyproject.toml", [sys.executable, "-m", "pytest"]),
56
+ ("pytest.ini", [sys.executable, "-m", "pytest"]),
57
+ ("setup.cfg", [sys.executable, "-m", "pytest"]),
58
+ ("package.json", ["npm", "test", "--silent"]),
59
+ ("go.mod", ["go", "test", "./..."]),
60
+ ("Cargo.toml", ["cargo", "test"]),
61
+ )
62
+
63
+
64
+ def _head_blob(repo_root: str, path: str) -> str | None:
65
+ result = subprocess.run(
66
+ ["git", "-C", repo_root, "show", f"HEAD:{path}"],
67
+ capture_output=True,
68
+ text=True,
69
+ check=False,
70
+ )
71
+ return result.stdout if result.returncode == 0 else None
72
+
73
+
74
+ def _head_has(repo_root: str, path: str) -> bool:
75
+ return _head_blob(repo_root, path) is not None
76
+
77
+
78
+ def _detect_test_command(repo_root: str) -> list[str] | None:
79
+ """Check HEAD's own committed blobs for a marker file, never the working tree -- an
80
+ uncommitted edit to (or brand-new untracked) `package.json`/`pyproject.toml`/etc. must not
81
+ change which runner gets picked (E3).
82
+ """
83
+ for marker, command in _TEST_COMMANDS:
84
+ if _head_has(repo_root, marker):
85
+ return command
86
+ return None
87
+
88
+
89
+ def _package_script(repo_root: str, script: str) -> list[str] | None:
90
+ raw = _head_blob(repo_root, "package.json")
91
+ if raw is None:
92
+ return None
93
+ try:
94
+ data = json.loads(raw)
95
+ except json.JSONDecodeError:
96
+ return None
97
+ if not isinstance(data, dict):
98
+ return None
99
+ scripts = data.get("scripts")
100
+ if isinstance(scripts, dict) and isinstance(scripts.get(script), str):
101
+ return ["npm", "run", script, "--silent"]
102
+ return None
103
+
104
+
105
+ def _make_target(repo_root: str, target: str) -> list[str] | None:
106
+ raw = (_head_blob(repo_root, "GNUmakefile") or _head_blob(repo_root, "makefile")
107
+ or _head_blob(repo_root, "Makefile"))
108
+ if raw is None:
109
+ return None
110
+ for line in raw.splitlines():
111
+ if line.startswith(f"{target}:"):
112
+ return ["make", target]
113
+ return None
114
+
115
+
116
+ def _detect_build_command(repo_root: str) -> list[str] | None:
117
+ """Pick a build command from committed config only.
118
+
119
+ The detector is intentionally narrower than `rules.py`'s "build-ish command" recognizer. A
120
+ Tier 3 re-run executes code, so it needs an explicit committed build affordance rather than a
121
+ filename that happens to exist in the working tree.
122
+ """
123
+ if cmd := _package_script(repo_root, "build"):
124
+ return cmd
125
+ if _head_has(repo_root, "Cargo.toml"):
126
+ return ["cargo", "build"]
127
+ if _head_has(repo_root, "go.mod"):
128
+ return ["go", "build", "./..."]
129
+ if _head_has(repo_root, "pom.xml"):
130
+ return ["mvn", "package"]
131
+ if _head_has(repo_root, "build.gradle") or _head_has(repo_root, "build.gradle.kts"):
132
+ return ["gradle", "build"]
133
+ if cmd := _make_target(repo_root, "build"):
134
+ return cmd
135
+ raw_pyproject = _head_blob(repo_root, "pyproject.toml")
136
+ if raw_pyproject:
137
+ try:
138
+ config = tomllib.loads(raw_pyproject)
139
+ except tomllib.TOMLDecodeError:
140
+ return None
141
+ if isinstance(config.get("build-system"), dict):
142
+ return [sys.executable, "-m", "build"]
143
+ return None
144
+
145
+
146
+ def detect_command(repo_root: str, kind: str) -> list[str] | None:
147
+ if kind == "build":
148
+ return _detect_build_command(repo_root)
149
+ return _detect_test_command(repo_root) if kind == "run_tests" else None
150
+
151
+
152
+ def _tracked_files(repo_root: str) -> list[str]:
153
+ out = subprocess.run(
154
+ ["git", "-C", repo_root, "ls-files", "-z"],
155
+ capture_output=True, text=True, check=True,
156
+ ).stdout
157
+ return [p for p in out.split("\0") if p]
158
+
159
+
160
+ def _untracked_unignored_files(repo_root: str) -> list[str]:
161
+ out = subprocess.run(
162
+ ["git", "-C", repo_root, "ls-files", "-z", "--others", "--exclude-standard"],
163
+ capture_output=True, text=True, check=True,
164
+ ).stdout
165
+ return [p for p in out.split("\0") if p]
166
+
167
+
168
+ def _materialize_worktree(repo_root: str, worktree: Path) -> None:
169
+ """Checkout HEAD into a throwaway worktree, then overlay the live working tree on top --
170
+ tracked files as they sit on disk now, plus untracked-but-not-gitignored files -- so "the
171
+ final tree" means what git would see if everything were committed right now. Ignored files
172
+ (venvs, node_modules, build output, caches) are never copied in. A tracked file deleted on
173
+ disk but not yet committed is removed from the worktree rather than left at its HEAD
174
+ content. Never mutates repo_root itself.
175
+ """
176
+ subprocess.run(
177
+ ["git", "-C", repo_root, "worktree", "add", "--detach", str(worktree), "HEAD"],
178
+ capture_output=True,
179
+ text=True,
180
+ check=True,
181
+ )
182
+ repo = Path(repo_root)
183
+
184
+ for rel in _tracked_files(repo_root):
185
+ src, dst = repo / rel, worktree / rel
186
+ if src.exists():
187
+ dst.parent.mkdir(parents=True, exist_ok=True)
188
+ shutil.copy2(src, dst)
189
+ else:
190
+ dst.unlink(missing_ok=True)
191
+
192
+ for rel in _untracked_unignored_files(repo_root):
193
+ src, dst = repo / rel, worktree / rel
194
+ dst.parent.mkdir(parents=True, exist_ok=True)
195
+ shutil.copy2(src, dst)
196
+
197
+
198
+ _BUILD_CONFIGS = ("package.json", "Makefile", "makefile", "GNUmakefile", "Cargo.toml",
199
+ "go.mod", "pom.xml", "build.gradle", "build.gradle.kts", "pyproject.toml")
200
+
201
+
202
+ def _restore_build_config(repo_root: str, worktree: Path) -> None:
203
+ """Keep live source edits, but execute committed build entry-point configuration."""
204
+ for name in _BUILD_CONFIGS:
205
+ dest = worktree / name
206
+ blob = _head_blob(repo_root, name)
207
+ if dest.is_symlink():
208
+ dest.unlink()
209
+ if blob is not None:
210
+ dest.write_text(blob)
211
+ elif dest.is_file():
212
+ dest.unlink()
213
+
214
+
215
+ def rerun_tests(
216
+ repo_root: str,
217
+ session_id: str = "",
218
+ seq: int = -1,
219
+ timeout_s: int = 60,
220
+ cmd: list[str] | None = None,
221
+ claim_kind: str = "run_tests",
222
+ ) -> LedgerEvent:
223
+ """Replay the repo's test command against the final tree, in an isolated worktree, and
224
+ return the result as a RERUN event. `session_id`/`seq` default to placeholders -- a caller
225
+ that doesn't have the real ledger identity yet (or the ledger store itself, E8, once it
226
+ exists) renumbers before appending; `run_worker` below passes the real `session_id` since
227
+ it has it. `cmd` overrides auto-detection (E3) when the caller already knows the exact
228
+ command; otherwise the command is auto-detected from HEAD's own committed config markers.
229
+ """
230
+ with tempfile.TemporaryDirectory(prefix="custos-code-rerun-") as tmp:
231
+ worktree = Path(tmp) / "worktree"
232
+ _materialize_worktree(repo_root, worktree)
233
+ try:
234
+ if claim_kind == "build":
235
+ _restore_build_config(repo_root, worktree)
236
+ command = cmd if cmd is not None else detect_command(repo_root, claim_kind)
237
+ started = datetime.now(UTC)
238
+ timed_out = False
239
+ if command is None:
240
+ output = "no known test config found (pyproject.toml, package.json, go.mod, Cargo.toml)"
241
+ exit_code: int | None = None
242
+ elif (command == [sys.executable, "-m", "build"]
243
+ and importlib.util.find_spec("build") is None):
244
+ output = "Could not start runner: Python build module is not installed"
245
+ exit_code = None
246
+ else:
247
+ try:
248
+ proc = subprocess.run(
249
+ command,
250
+ cwd=worktree,
251
+ capture_output=True,
252
+ text=True,
253
+ timeout=timeout_s,
254
+ )
255
+ output = proc.stdout + proc.stderr
256
+ exit_code = proc.returncode
257
+ except OSError as exc:
258
+ output = f"Could not start runner: {exc}"
259
+ exit_code = None
260
+ except subprocess.TimeoutExpired as exc:
261
+ timed_out = True
262
+ stdout = exc.stdout if isinstance(exc.stdout, str) else ""
263
+ stderr = exc.stderr if isinstance(exc.stderr, str) else ""
264
+ output = f"{stdout}{stderr}\n[custos-code] timed out after {timeout_s}s"
265
+ exit_code = None
266
+ duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
267
+ finally:
268
+ subprocess.run(
269
+ ["git", "-C", repo_root, "worktree", "remove", "--force", str(worktree)],
270
+ capture_output=True,
271
+ text=True,
272
+ check=False,
273
+ )
274
+
275
+ output_hash = hashlib.sha256(output.encode()).hexdigest()
276
+ truncated = len(output.encode()) > MAX_OUTPUT_BYTES
277
+ stored_output = output[:MAX_OUTPUT_BYTES] if truncated else output
278
+
279
+ return LedgerEvent(
280
+ seq=seq,
281
+ ts=started,
282
+ session_id=session_id,
283
+ kind=EventKind.RERUN,
284
+ tool="rerun_tests",
285
+ input={"command": command, "ref": "HEAD+working-tree"},
286
+ output=stored_output,
287
+ output_hash=output_hash,
288
+ exit_code=exit_code,
289
+ paths=[repo_root],
290
+ cwd=str(worktree),
291
+ duration_ms=duration_ms,
292
+ flags=EventFlags(truncated=truncated, timed_out=timed_out),
293
+ )
294
+
295
+
296
+ # --- E4: detached async spawn, so the Stop hook never waits on the above ---
297
+
298
+
299
+ def _sessions_root() -> Path:
300
+ return Path.home() / ".custos-code" / "sessions"
301
+
302
+
303
+ def _rerun_dir(session_id: str) -> Path:
304
+ d = _sessions_root() / session_id / "rerun"
305
+ d.mkdir(parents=True, exist_ok=True)
306
+ return d
307
+
308
+
309
+
310
+ def _worker_argv(session_id: str, claim_id: str) -> list[str]:
311
+ """How to launch the detached worker without assuming `uv` or `python` is on PATH.
312
+
313
+ There is no custos-code/__main__.py, so the fallback goes through the console-script entry point
314
+ (pyproject.toml [project.scripts]) via -c rather than -m.
315
+ """
316
+ args = ["_hook", "rerun-worker", session_id, claim_id]
317
+ if importlib.util.find_spec("custos_code.cli") is not None:
318
+ return [sys.executable, "-c", "from custos_code.cli import app; app()", *args]
319
+ script = shutil.which("custos-code")
320
+ if script:
321
+ return [script, *args]
322
+ return [sys.executable, "-c", "from custos_code.cli import app; app()", *args]
323
+
324
+
325
+ def spawn_async(
326
+ session_id: str,
327
+ claim_id: str,
328
+ repo_root: str,
329
+ report_seq: int,
330
+ timeout_s: int = 60,
331
+ cmd: list[str] | None = None,
332
+ claim_text: str | None = None,
333
+ claim_kind: str = "run_tests",
334
+ ) -> Path:
335
+ """Launch Tier 3 detached and return immediately; never blocks the caller (E4).
336
+
337
+ Idempotent: a claim already pending or already settled is not re-spawned. The child is its
338
+ own `custos-code` invocation (`_hook rerun-worker`) rather than an in-process fork, so it
339
+ survives this process's own exit the same way any other backgrounded shell job would.
340
+
341
+ VERIFY(E4): does Claude Code kill the Stop hook's process group when the hook script exits,
342
+ and does `start_new_session=True` survive that? If not, the worker needs to be launched by
343
+ something whose lifecycle Claude Code doesn't own (e.g. a small daemon started by
344
+ `custos-code watch`) instead of a child of the hook process. Not resolved here; revisit if the
345
+ result file is ever observed to go missing.
346
+ """
347
+ d = _rerun_dir(session_id)
348
+ pending_path = d / f"{claim_id}.pending.json"
349
+ result_path = d / f"{claim_id}.result.json"
350
+ if result_path.exists() or pending_path.exists():
351
+ return pending_path
352
+ pending_path.write_text(json.dumps({
353
+ "claim_id": claim_id,
354
+ "claim_text": claim_text,
355
+ "claim_kind": claim_kind,
356
+ "session_id": session_id,
357
+ "repo_root": repo_root,
358
+ "cmd": cmd,
359
+ "report_seq": report_seq,
360
+ "timeout_s": timeout_s,
361
+ "spawned_at": datetime.now(UTC).isoformat(),
362
+ }))
363
+ log_path = d / f"{claim_id}.log"
364
+ with log_path.open("wb") as log:
365
+ subprocess.Popen( # noqa: S603 -- argv is fixed; no shell, no untrusted input
366
+ # Same reasoning as _TEST_COMMANDS above: do not assume a launcher is on PATH. `uv` is
367
+ # absent on plenty of machines (this one included), and a hardcoded ["uv", "run", ...]
368
+ # fails closed with FileNotFoundError, so the worker never starts and the Tier 3 result
369
+ # never appears. Prefer the installed console script, fall back to this interpreter.
370
+ _worker_argv(session_id, claim_id),
371
+ cwd=repo_root,
372
+ stdin=subprocess.DEVNULL,
373
+ stdout=log,
374
+ stderr=log,
375
+ start_new_session=True,
376
+ )
377
+ return pending_path
378
+
379
+
380
+ def run_worker(session_id: str, claim_id: str) -> None:
381
+ """Entry point for the detached subprocess `spawn_async` launches. Not called directly."""
382
+ d = _rerun_dir(session_id)
383
+ pending_path = d / f"{claim_id}.pending.json"
384
+ result_path = d / f"{claim_id}.result.json"
385
+ pending = json.loads(pending_path.read_text())
386
+ # NEEDS-DECISION(oliver): real next-seq should come from the session's ledger store once
387
+ # ledger.LedgerStore (SQLite, E8 hash chain) exists; seq=0 is a placeholder until then.
388
+ event = rerun_tests(
389
+ pending["repo_root"],
390
+ session_id,
391
+ seq=0,
392
+ timeout_s=pending["timeout_s"],
393
+ cmd=pending.get("cmd"),
394
+ claim_kind=pending.get("claim_kind", "run_tests"),
395
+ )
396
+ event.input = {**(event.input or {}), "claim_id": claim_id,
397
+ "report_seq": pending["report_seq"], "claim_text": pending.get("claim_text"),
398
+ "claim_kind": pending.get("claim_kind", "run_tests")}
399
+ result_path.write_text(event.model_dump_json())
400
+ pending_path.unlink(missing_ok=True)
401
+
402
+
403
+ class RerunStatus(StrEnum):
404
+ NONE = "none"
405
+ PENDING = "pending"
406
+ DONE = "done"
407
+
408
+
409
+ def poll(session_id: str, claim_id: str) -> RerunStatus:
410
+ """Whether a Tier 3 job for this claim has been spawned, is still running, or has a result."""
411
+ d = _rerun_dir(session_id)
412
+ if (d / f"{claim_id}.result.json").exists():
413
+ return RerunStatus.DONE
414
+ if (d / f"{claim_id}.pending.json").exists():
415
+ return RerunStatus.PENDING
416
+ return RerunStatus.NONE
417
+
418
+
419
+ def load_result(session_id: str, claim_id: str) -> LedgerEvent | None:
420
+ """The RERUN LedgerEvent once `poll` reports DONE, else None."""
421
+ p = _rerun_dir(session_id) / f"{claim_id}.result.json"
422
+ if not p.exists():
423
+ return None
424
+ return LedgerEvent.model_validate_json(p.read_text())