Files

247 lines
8.6 KiB
Python

"""Execution, check-command, and cancellation handlers."""
from __future__ import annotations
import os
from pathlib import Path
from .. import execution, ptt, state
from .base import (
_check_command_internal,
_eng_path,
_json,
_result,
_serialise_errors,
)
_MAX_COMMAND_FILE_BYTES = 64 * 1024
def _commands_from_file(eng_dir: str, value: str) -> list[str]:
"""Load a bounded engagement-local command file without following symlinks."""
relative = Path(value)
if relative.is_absolute():
raise ValueError("commands_file must be engagement-relative")
engagement = _eng_path(eng_dir).resolve()
candidate = engagement / relative
if candidate.is_symlink():
raise ValueError("commands_file must not be a symlink")
resolved = candidate.resolve()
if not resolved.is_relative_to(engagement):
raise ValueError("commands_file escapes the engagement directory")
current = candidate
while current != engagement:
if current.is_symlink():
raise ValueError("commands_file must not traverse symlinked directories")
current = current.parent
if not resolved.exists():
raise ValueError(f"commands file not found: {value}")
if not resolved.is_file():
raise ValueError("commands_file must be a regular file")
if resolved.stat().st_size > _MAX_COMMAND_FILE_BYTES:
raise ValueError(f"commands_file exceeds {_MAX_COMMAND_FILE_BYTES} bytes")
return [
line.strip() for line in resolved.read_text(encoding="utf-8").splitlines() if line.strip()
]
@_serialise_errors
def handle_check_command(a, **kwargs):
r = _check_command_internal(a)
status_name = "ok" if r.exit_code() == 0 else "review" if r.exit_code() == 2 else "block"
return _json(status_name, **_result(r))
@_serialise_errors
def handle_heartbeat_done(a, **kwargs):
state.clear_heartbeat_pending(a["eng_dir"])
return _json("ok")
@_serialise_errors
def handle_exec(a, *, _internal_argv=None, _internal_background=None, **kwargs):
r = _check_command_internal(a)
exit_code = r.exit_code()
status_name = "ok" if exit_code == 0 else "review" if exit_code == 2 else "block"
if status_name not in ("ok",) and not (
status_name == "review" and os.environ.get("HERMES_YOLO_MODE") == "1"
):
sync_status = (
"sync_required"
if any("sync-credit" in str(x) or "not synced" in str(x) for x in r.errors)
else "denied"
)
return _json(sync_status, executed=False, **_result(r))
try:
active_task = ptt.find_active_task(
ptt.parse_ptt(_eng_path(a["eng_dir"]) / "state" / "ptt.md")
)
res = execution.execute(
command=a["command"],
eng_dir=a["eng_dir"],
phase=a["phase"],
backend=a.get("backend", "auto"),
timeout_seconds=a.get("timeout_seconds", 180),
cwd=a.get("cwd", ""),
label=a.get("label", ""),
ptt_task_id=active_task.id if active_task else "",
argv=_internal_argv,
background=(
bool(a.get("background", False))
if _internal_background is None
else bool(_internal_background)
),
)
execution_status = res.pop("status", None)
if not res.get("executed"):
return _json(
"execution_failed",
execution_status=execution_status,
error=res.get("stderr_preview") or "process failed to start",
**res,
)
return _json("ok", execution_status=execution_status, **res)
except Exception as e:
return _json("execution_failed", error=str(e), executed=False)
@_serialise_errors
def handle_exec_status(a, **kwargs):
return _json("ok", **execution.status(a.get("eng_dir"), a.get("execution_id")))
@_serialise_errors
def handle_exec_cancel(a, **kwargs):
return _json("ok", **execution.cancel(a.get("eng_dir"), a.get("execution_id")))
@_serialise_errors
def handle_exec_burst(a, **kwargs):
"""Single-approval bounded command batch with real burst semantics."""
eng_dir = a.get("eng_dir", "")
phase = a.get("phase", "")
scope = a.get("scope", "")
session_id = a.get("session_id", "")
label = a.get("label", "")
backend = a.get("backend", "auto")
timeout_seconds = a.get("timeout_seconds", 180)
cwd = a.get("cwd", "")
continue_on_error = bool(a.get("continue_on_error", False))
cmds = list(a.get("commands") or [])
commands_file = a.get("commands_file")
if commands_file:
try:
cmds.extend(_commands_from_file(eng_dir, str(commands_file)))
except ValueError as exc:
return _json("error", error=str(exc))
if not cmds:
return _json("error", error="no commands provided (inline or commands_file)")
if len(cmds) > state.MAX_BURST_COMMANDS:
return _json("error", error=f"burst limit is {state.MAX_BURST_COMMANDS}")
active_task = ptt.find_active_task(ptt.parse_ptt(_eng_path(eng_dir) / "state" / "ptt.md"))
active_task_id = active_task.id if active_task else ""
preflight = []
required_slots = 0
for idx, cmd in enumerate(cmds):
cmd_args = {
"command": cmd,
"phase": phase,
"eng_dir": eng_dir,
"scope": scope,
"session_id": session_id,
"target": a.get("target"),
}
r = _check_command_internal(cmd_args)
exit_code = r.exit_code()
status_name = "ok" if exit_code == 0 else "review" if exit_code == 2 else "block"
if status_name == "block":
return _json(
"denied",
executed=0,
results=[
{
"index": idx + 1,
"command": cmd,
"status": "blocked",
"errors": r.errors,
}
],
reason=f"command [{idx + 1}] blocked: {r.errors[0] if r.errors else 'blocked'}",
)
review_warnings = r.warnings if status_name == "review" else []
local = state.is_local_bookkeeping_command(cmd)
if not local:
required_slots += 1
preflight.append(
{
"index": idx + 1,
"command": cmd,
"review_warnings": review_warnings,
"local": local,
}
)
reservation_id = None
if required_slots:
try:
reservation_id = state.reserve_sync_credit(eng_dir, phase, required_slots)
except ValueError as exc:
return _json("denied", executed=0, results=[], reason=str(exc))
results = []
executed = 0
for item in preflight:
idx = item["index"]
cmd = item["command"]
review_warnings = item["review_warnings"]
try:
res = execution.execute(
command=cmd,
eng_dir=eng_dir,
phase=phase,
backend=backend,
timeout_seconds=timeout_seconds,
cwd=cwd,
label=label,
ptt_task_id=active_task_id,
sync_reservation=None if item["local"] else reservation_id,
)
execution_status = res.pop("status", None)
entry = {
"index": idx,
"command": cmd,
"execution_status": execution_status,
**res,
}
if review_warnings:
entry["review_required"] = True
entry["warnings"] = review_warnings
results.append(entry)
if res.get("executed"):
executed += 1
if res.get("exit_code", 0) != 0 and not continue_on_error:
break
except Exception as e: # noqa: BLE001
if not continue_on_error:
if reservation_id:
state.release_reserved_sync_credit(eng_dir, reservation_id)
return _json(
"execution_failed",
executed=executed,
results=results + [{"index": idx, "command": cmd, "error": str(e)}],
error=str(e),
)
results.append({"index": idx, "command": cmd, "error": str(e)})
if reservation_id:
state.release_reserved_sync_credit(eng_dir, reservation_id)
return _json(
"batch_complete",
executed=executed,
results=results,
review_required=any(item.get("review_required") for item in results),
)