Blacksite/scripts/codex/summarize_command.py

470 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run one command, retain its complete log, and print only a bounded result."""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
import time
import tomllib
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Sequence
WORKFLOW_PATH = Path(".codex/workflow.toml")
ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
ACTIONABLE_PATTERNS = (
re.compile(r"^\s*error(?:\[[A-Z0-9]+\])?:", re.IGNORECASE),
re.compile(r"^\s*error\b", re.IGNORECASE),
re.compile(r"\bpanicked at\b", re.IGNORECASE),
re.compile(r"^\s*thread .+ panicked", re.IGNORECASE),
re.compile(r"^\s*Traceback \(most recent call last\):"),
re.compile(r"^\s*(?:AssertionError|RuntimeError|ValueError|TypeError):"),
re.compile(r"^\s*failures:\s*$", re.IGNORECASE),
re.compile(r"^\s*test result: FAILED", re.IGNORECASE),
re.compile(r"^\s*FAILED(?:\s|$)", re.IGNORECASE),
re.compile(r"^\s*FAIL(?:\s|$)", re.IGNORECASE),
re.compile(r"^\s*Caused by:\s*\S", re.IGNORECASE),
)
TEST_RESULT_RE = re.compile(
r"test result: ok\.\s*(\d+) passed;\s*(\d+) failed;\s*(\d+) ignored",
re.IGNORECASE,
)
SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
class SummaryError(RuntimeError):
"""The wrapper cannot locate its configuration or output paths."""
def find_repository(start: Path | None = None) -> Path:
override = os.environ.get("BLACKSITE_WORKSPACE_ROOT")
configured = None
config_path = (start or Path.cwd()) / ".codex/config.toml"
if not override and config_path.is_file():
with config_path.open("rb") as handle:
configured = tomllib.load(handle).get("workspace_root")
candidates = [Path(override or configured)] if (override or configured) else [start or Path.cwd()]
if start is None and not override:
candidates.append(Path(__file__).parent)
visited: set[Path] = set()
for candidate in candidates:
candidate = candidate.expanduser().absolute()
for current in (candidate, *candidate.parents):
canonical = current.resolve(strict=False)
if canonical in visited:
continue
visited.add(canonical)
if (current / WORKFLOW_PATH).is_file():
return current
searched = ", ".join(str(path) for path in candidates)
raise SummaryError(f"could not locate {WORKFLOW_PATH} from {searched}")
def load_workflow(root: Path) -> dict[str, Any]:
path = root / WORKFLOW_PATH
try:
with path.open("rb") as handle:
workflow = tomllib.load(handle)
except OSError as error:
raise SummaryError(f"cannot read workflow configuration {path}: {error}") from error
except tomllib.TOMLDecodeError as error:
raise SummaryError(f"invalid workflow configuration {path}: {error}") from error
if workflow.get("version") != 1:
raise SummaryError(f"unsupported or missing workflow version in {path}")
return workflow
def output_settings(workflow: dict[str, Any]) -> tuple[str, int]:
output = workflow.get("output", {})
if not isinstance(output, dict):
raise SummaryError("[output] must be a TOML table")
log_dir = output.get("log_dir", ".codex/logs")
max_lines = output.get("max_failure_lines", 120)
if not isinstance(log_dir, str) or not log_dir:
raise SummaryError("output.log_dir must be a non-empty path string")
if not isinstance(max_lines, int) or not 1 <= max_lines <= 1000:
raise SummaryError("output.max_failure_lines must be an integer from 1 to 1000")
return log_dir, max_lines
def resolve_path(root: Path, raw: str) -> Path:
path = Path(os.path.expandvars(os.path.expanduser(raw)))
if not path.is_absolute():
path = root / path
return path.absolute()
def relative_display(root: Path, path: Path) -> str:
try:
return path.relative_to(root.resolve(strict=False)).as_posix()
except ValueError:
return str(path)
def safe_name(value: str) -> str:
cleaned = SAFE_NAME_RE.sub("-", value.strip()).strip("-._")
return cleaned[:80] or "command"
def default_log_path(root: Path, log_dir: str, gate: str) -> Path:
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
return resolve_path(root, str(Path(log_dir) / f"{timestamp}-{safe_name(gate)}.log"))
def strip_ansi(value: str) -> str:
return ANSI_RE.sub("", value).rstrip("\r\n")
def duration_text(seconds: float) -> str:
if seconds < 10:
return f"{seconds:.2f}s"
if seconds < 60:
return f"{seconds:.1f}s"
minutes, remainder = divmod(seconds, 60)
return f"{int(minutes)}m {remainder:.0f}s"
def is_actionable(line: str) -> bool:
plain = strip_ansi(line)
return any(pattern.search(plain) for pattern in ACTIONABLE_PATTERNS)
def failure_excerpt(path: Path, limit: int) -> tuple[list[str], str | None]:
"""Return bounded context around the first actionable failure.
The log is streamed rather than loaded wholesale, keeping the wrapper useful
for very large compiler output.
"""
before_count = min(12, max(0, limit // 4))
before: deque[str] = deque(maxlen=before_count)
excerpt: list[str] = []
first_actionable: str | None = None
matched = False
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = strip_ansi(raw_line)
if not matched:
if is_actionable(line):
matched = True
first_actionable = line.strip() or None
excerpt.extend(before)
excerpt.append(line)
else:
before.append(line)
elif len(excerpt) < limit:
excerpt.append(line)
else:
break
if not matched:
# With no recognizable error, the command's tail is normally the most
# useful bounded evidence (signal termination, tool-specific errors).
tail: deque[str] = deque(maxlen=limit)
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
tail.append(strip_ansi(raw_line))
excerpt = list(tail)
first_actionable = next((line.strip() for line in excerpt if line.strip()), None)
while excerpt and not excerpt[0].strip():
excerpt.pop(0)
while excerpt and not excerpt[-1].strip():
excerpt.pop()
return excerpt[:limit], first_actionable
def success_summary(path: Path) -> str:
last_nonempty = ""
test_result: tuple[str, str, str] | None = None
explicit_pass = ""
with path.open("r", encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = strip_ansi(raw_line).strip()
if not line:
continue
last_nonempty = line
match = TEST_RESULT_RE.search(line)
if match:
test_result = match.groups()
if line.startswith("PASS "):
explicit_pass = line
if test_result is not None:
passed, failed, ignored = test_result
return f"{passed} passed, {failed} failed, {ignored} ignored"
if explicit_pass:
return explicit_pass[:240]
if last_nonempty.startswith("Finished "):
return last_nonempty[:240]
return "exit 0"
def write_result(path: Path, result: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
json.dump(result, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
temporary = Path(handle.name)
os.replace(temporary, path)
finally:
if temporary is not None and temporary.exists():
temporary.unlink()
def normalized_exit_code(returncode: int) -> int:
return 128 + abs(returncode) if returncode < 0 else returncode
def execute(
*,
root: Path,
workflow: dict[str, Any],
gate: str,
command: Sequence[str],
log: Path | None = None,
json_result: Path | None = None,
cwd: Path | None = None,
) -> int:
if not command:
raise SummaryError("a command is required after --")
configured_log_dir, max_lines = output_settings(workflow)
log_path = log or default_log_path(root, configured_log_dir, gate)
result_path = json_result or log_path.with_suffix(log_path.suffix + ".json")
if result_path == log_path:
raise SummaryError("the JSON result path must differ from the full log path")
command_cwd = cwd or Path.cwd()
if not command_cwd.is_dir():
raise SummaryError(f"command working directory does not exist: {command_cwd}")
log_path.parent.mkdir(parents=True, exist_ok=True)
started_wall = datetime.now(timezone.utc)
started = time.monotonic()
returncode = 127
launch_error: str | None = None
interrupted = False
with log_path.open("wb") as log_handle:
try:
process = subprocess.Popen(
list(command),
cwd=command_cwd,
stdout=log_handle,
stderr=subprocess.STDOUT,
shell=False,
)
except OSError as error:
launch_error = f"error: unable to execute {command[0]!r}: {error}"
log_handle.write((launch_error + "\n").encode("utf-8", errors="replace"))
else:
try:
returncode = normalized_exit_code(process.wait())
except KeyboardInterrupt:
interrupted = True
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
returncode = 130
log_handle.write(b"\nerror: command interrupted by user\n")
duration = time.monotonic() - started
status = "PASS" if returncode == 0 else "FAIL"
summary = success_summary(log_path) if returncode == 0 else "command failed"
excerpt: list[str] = []
actionable: str | None = launch_error
if returncode != 0:
excerpt, detected = failure_excerpt(log_path, max_lines)
actionable = actionable or detected
result = {
"schema_version": 1,
"gate": gate,
"status": status,
"exit_code": returncode,
"duration_seconds": round(duration, 6),
"log": relative_display(root, log_path),
"result": relative_display(root, result_path),
"command": list(command),
"command_display": shlex.join(command),
"cwd": str(command_cwd.resolve(strict=False)),
"started_at": started_wall.isoformat(timespec="seconds"),
"finished_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"summary": summary,
"first_actionable_failure": actionable,
"interrupted": interrupted,
}
write_result(result_path, result)
if returncode == 0:
print(f"PASS {gate}{duration_text(duration)}{summary}")
else:
print(f"FAIL {gate}{duration_text(duration)}")
for line in excerpt:
print(line)
print(f"Full log: {relative_display(root, log_path)}")
return returncode
def parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--gate", required=True, help="short verification gate name")
parser.add_argument("--json-result", help="machine-readable result path")
parser.add_argument("--log", help="complete combined-output log path")
parser.add_argument("--cwd", help="command working directory; defaults to the invocation directory")
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args(argv)
if args.command and args.command[0] == "--":
args.command = args.command[1:]
if not args.command:
parser.error("a command is required after --")
return args
def self_test() -> int:
with tempfile.TemporaryDirectory(prefix="blacksite-summary-self-test-") as temporary:
root = Path(temporary)
(root / ".codex").mkdir()
(root / ".codex/workflow.toml").write_text(
"""version = 1
[output]
log_dir = ".codex/logs"
max_failure_lines = 4
""",
encoding="utf-8",
)
nested = root / "nested"
nested.mkdir()
found = find_repository(nested)
workflow = load_workflow(found)
parsed = parse_arguments(
[
"--gate",
"parser-fixture",
"--json-result",
".codex/logs/parser.json",
"--",
sys.executable,
"--version",
]
)
if parsed.command != [sys.executable, "--version"]:
raise AssertionError("command arguments after -- were not preserved")
success_log = root / ".codex/logs/success.log"
success_json = root / ".codex/logs/success.json"
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
success = execute(
root=root,
workflow=workflow,
gate="self-test-pass",
command=[
sys.executable,
"-c",
"print('test result: ok. 3 passed; 0 failed; 1 ignored')",
],
log=success_log,
json_result=success_json,
cwd=root,
)
if success != 0:
raise AssertionError("successful fixture returned a failure")
if not captured.getvalue().startswith("PASS self-test-pass"):
raise AssertionError("successful fixture did not emit a compact PASS summary")
success_data = json.loads(success_json.read_text(encoding="utf-8"))
for key in ("status", "exit_code", "duration_seconds", "log"):
if key not in success_data:
raise AssertionError(f"result JSON omitted required key {key}")
if success_data["status"] != "PASS" or success_data["exit_code"] != 0:
raise AssertionError("successful result JSON is incorrect")
failure_log = root / ".codex/logs/failure.log"
failure_json = root / ".codex/logs/failure.json"
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
failure = execute(
root=root,
workflow=workflow,
gate="self-test-fail",
command=[
sys.executable,
"-c",
"print('noise'); print('error[E0001]: actionable'); print('detail'); raise SystemExit(7)",
],
log=failure_log,
json_result=failure_json,
cwd=root,
)
if failure != 7:
raise AssertionError("failing fixture did not preserve exit code 7")
visible_lines = captured.getvalue().splitlines()
if not visible_lines or not visible_lines[0].startswith("FAIL self-test-fail"):
raise AssertionError("failing fixture did not emit a compact FAIL summary")
if len(visible_lines[1:-1]) > 4:
raise AssertionError("failure excerpt exceeded output.max_failure_lines")
failure_data = json.loads(failure_json.read_text(encoding="utf-8"))
if failure_data["status"] != "FAIL" or failure_data["exit_code"] != 7:
raise AssertionError("failure result JSON is incorrect")
if "error[E0001]" not in failure_log.read_text(encoding="utf-8"):
raise AssertionError("full failure output was not captured")
print("PASS summarize-command-self-test — logs, bounded failure, JSON, exit preservation")
return 0
def main(argv: Sequence[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments in (["--self-test"], ["self-test"]):
try:
return self_test()
except (AssertionError, OSError, SummaryError, subprocess.SubprocessError, json.JSONDecodeError) as error:
print(f"FAIL summarize-command-self-test — {error}", file=sys.stderr)
return 1
try:
args = parse_arguments(arguments)
root = find_repository()
workflow = load_workflow(root)
log = resolve_path(root, args.log) if args.log else None
result = resolve_path(root, args.json_result) if args.json_result else None
cwd = resolve_path(root, args.cwd) if args.cwd else Path.cwd()
return execute(
root=root,
workflow=workflow,
gate=args.gate,
command=args.command,
log=log,
json_result=result,
cwd=cwd,
)
except (OSError, SummaryError) as error:
print(f"FAIL summarize-command — {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())