470 lines
17 KiB
Python
Executable File
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())
|