Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"""Verification runner: execute a Recipe's phases and smoke-test the app.
|
||||
|
||||
Scoped port of the execution flow grok-cli's verify sub-agent performs
|
||||
(install/bootstrap -> build -> test -> start in background -> curl-style
|
||||
readiness loop -> teardown), reimplemented as a plain subprocess runner.
|
||||
|
||||
Commands come from the project's own recipe (its package.json scripts,
|
||||
Makefile targets, etc.) and are executed with ``shell=True`` on purpose:
|
||||
this is a developer tool running the project's own build commands in the
|
||||
project's own checkout — the same trust level as the terminal tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from agent.verify.recipes import Recipe
|
||||
|
||||
DEFAULT_PHASE_TIMEOUT = 600.0
|
||||
DEFAULT_READY_TIMEOUT = 60.0
|
||||
_TAIL_CHARS = 2000
|
||||
PHASE_ORDER = ("bootstrap", "build", "test")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseResult:
|
||||
phase: str
|
||||
command: str
|
||||
exit_code: int | None
|
||||
duration: float
|
||||
output_tail: str
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.exit_code == 0 and not self.timed_out
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"command": self.command,
|
||||
"exitCode": self.exit_code,
|
||||
"duration": round(self.duration, 3),
|
||||
"ok": self.ok,
|
||||
"timedOut": self.timed_out,
|
||||
"outputTail": self.output_tail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadinessResult:
|
||||
url: str
|
||||
ready: bool
|
||||
status_code: int | None
|
||||
duration: float
|
||||
error: str | None = None
|
||||
output_tail: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"url": self.url,
|
||||
"ready": self.ready,
|
||||
"statusCode": self.status_code,
|
||||
"duration": round(self.duration, 3),
|
||||
"error": self.error,
|
||||
"outputTail": self.output_tail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyResult:
|
||||
recipe_name: str
|
||||
phases: list[PhaseResult] = field(default_factory=list)
|
||||
readiness: ReadinessResult | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
phases_ok = all(p.ok for p in self.phases)
|
||||
readiness_ok = self.readiness.ready if self.readiness is not None else True
|
||||
return phases_ok and readiness_ok
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"recipe": self.recipe_name,
|
||||
"ok": self.ok,
|
||||
"phases": [p.to_dict() for p in self.phases],
|
||||
"readiness": self.readiness.to_dict() if self.readiness else None,
|
||||
}
|
||||
|
||||
|
||||
def _tail(text: str, limit: int = _TAIL_CHARS) -> str:
|
||||
return text[-limit:] if len(text) > limit else text
|
||||
|
||||
|
||||
def _run_phase_command(
|
||||
phase: str,
|
||||
command: str,
|
||||
root: Path,
|
||||
timeout: float,
|
||||
on_output: Callable[[str], None] | None = None,
|
||||
) -> PhaseResult:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
shell=True, # project-authored commands; see module docstring
|
||||
cwd=str(root),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=timeout,
|
||||
text=True,
|
||||
errors="replace",
|
||||
)
|
||||
output = proc.stdout or ""
|
||||
exit_code: int | None = proc.returncode
|
||||
timed_out = False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raw = exc.output
|
||||
if isinstance(raw, bytes):
|
||||
output = raw.decode("utf-8", errors="replace")
|
||||
else:
|
||||
output = raw or ""
|
||||
exit_code = None
|
||||
timed_out = True
|
||||
duration = time.monotonic() - started
|
||||
if on_output and output:
|
||||
on_output(output)
|
||||
return PhaseResult(
|
||||
phase=phase,
|
||||
command=command,
|
||||
exit_code=exit_code,
|
||||
duration=duration,
|
||||
output_tail=_tail(output),
|
||||
timed_out=timed_out,
|
||||
)
|
||||
|
||||
|
||||
def _poll_readiness(url: str, timeout: float, interval: float = 1.0) -> tuple[bool, int | None, str | None]:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error: str | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
return True, resp.status, None
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The server answered — it is up, even if it returned 4xx/5xx.
|
||||
return True, exc.code, None
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(interval)
|
||||
return False, None, last_error
|
||||
|
||||
|
||||
def _terminate_process_group(proc: subprocess.Popen) -> None:
|
||||
"""Terminate the started app and its whole process group cleanly.
|
||||
|
||||
On POSIX the child is spawned with ``start_new_session=True`` so we can
|
||||
signal the whole group; on Windows (no ``os.killpg``) we fall back to
|
||||
terminating just the direct child.
|
||||
"""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
killpg = getattr(os, "killpg", None)
|
||||
getpgid = getattr(os, "getpgid", None)
|
||||
pgid = None
|
||||
if killpg is not None and getpgid is not None:
|
||||
try:
|
||||
pgid = getpgid(proc.pid)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pgid = None
|
||||
try:
|
||||
if pgid is not None and killpg is not None:
|
||||
killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX-only branch (killpg checked above)
|
||||
else:
|
||||
proc.terminate()
|
||||
except (ProcessLookupError, PermissionError):
|
||||
return
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
if pgid is not None and killpg is not None:
|
||||
killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX-only branch (killpg checked above)
|
||||
else:
|
||||
proc.kill()
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
|
||||
def _run_start_phase(
|
||||
recipe: Recipe,
|
||||
root: Path,
|
||||
ready_timeout: float,
|
||||
port_override: int | None = None,
|
||||
) -> ReadinessResult:
|
||||
assert recipe.start is not None
|
||||
port = port_override or recipe.port or 8000
|
||||
url = f"http://127.0.0.1:{port}{recipe.readiness_path}"
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
recipe.start,
|
||||
shell=True, # project-authored command; see module docstring
|
||||
cwd=str(root),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True, # own process group for clean teardown
|
||||
text=True,
|
||||
errors="replace",
|
||||
)
|
||||
output = ""
|
||||
try:
|
||||
ready, status, error = _poll_readiness(url, ready_timeout)
|
||||
finally:
|
||||
_terminate_process_group(proc)
|
||||
try:
|
||||
if proc.stdout is not None:
|
||||
output = proc.stdout.read() or ""
|
||||
except (OSError, ValueError):
|
||||
output = ""
|
||||
return ReadinessResult(
|
||||
url=url,
|
||||
ready=ready,
|
||||
status_code=status,
|
||||
duration=time.monotonic() - started,
|
||||
error=error,
|
||||
output_tail=_tail(output),
|
||||
)
|
||||
|
||||
|
||||
def run_verify(
|
||||
root: Path,
|
||||
recipe: Recipe,
|
||||
phases: tuple[str, ...] | list[str] | None = None,
|
||||
phase_timeout: float = DEFAULT_PHASE_TIMEOUT,
|
||||
ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
||||
skip_start: bool = False,
|
||||
port_override: int | None = None,
|
||||
stop_on_failure: bool = True,
|
||||
on_output: Callable[[str], None] | None = None,
|
||||
) -> VerifyResult:
|
||||
"""Run a verify pass for ``recipe`` at project ``root``.
|
||||
|
||||
Executes the selected command phases sequentially, then (unless
|
||||
``skip_start`` or a phase failed) launches ``recipe.start`` in the
|
||||
background, polls the readiness URL, and tears the process group down.
|
||||
"""
|
||||
root = Path(root)
|
||||
selected = tuple(phases) if phases else PHASE_ORDER + ("start",)
|
||||
result = VerifyResult(recipe_name=recipe.name)
|
||||
|
||||
failed = False
|
||||
for phase in PHASE_ORDER:
|
||||
if phase not in selected:
|
||||
continue
|
||||
for command in getattr(recipe, phase):
|
||||
phase_result = _run_phase_command(phase, command, root, phase_timeout, on_output)
|
||||
result.phases.append(phase_result)
|
||||
if not phase_result.ok:
|
||||
failed = True
|
||||
if stop_on_failure:
|
||||
return result
|
||||
|
||||
if skip_start or "start" not in selected or failed or not recipe.start:
|
||||
return result
|
||||
|
||||
result.readiness = _run_start_phase(recipe, root, ready_timeout, port_override)
|
||||
return result
|
||||
Reference in New Issue
Block a user