generated from coulomb/repo-seed
first extension execution path
This commit is contained in:
162
src/guide_board/runners.py
Normal file
162
src/guide_board/runners.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Runner bridge for extension-provided checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable
|
||||
|
||||
from guide_board.errors import ValidationError
|
||||
from guide_board.io import load_json
|
||||
|
||||
|
||||
RunnerCallable = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
def run_step(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
runner_ref = step.get("runner_ref")
|
||||
if runner_ref is None:
|
||||
return _no_runner_result(step)
|
||||
|
||||
extension = _extension_snapshot(plan, step["extension_id"])
|
||||
extension_path = root / extension["path"]
|
||||
manifest = load_json(extension_path / "extension.json")
|
||||
entrypoint = _runner_entrypoint(manifest, runner_ref)
|
||||
if entrypoint["kind"] == "python_module":
|
||||
return _run_python_module(root, run_dir, run_id, plan, step, extension_path, entrypoint)
|
||||
if entrypoint["kind"] == "external":
|
||||
return {
|
||||
"result": "blocked",
|
||||
"observations": [
|
||||
f"Runner {runner_ref!r} is declared as an external runner and is not implemented by the core."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": runner_ref,
|
||||
"runner_kind": "external",
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
if entrypoint["kind"] == "command":
|
||||
return {
|
||||
"result": "blocked",
|
||||
"observations": [
|
||||
f"Runner {runner_ref!r} is declared as a command runner; command execution is not enabled yet."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": runner_ref,
|
||||
"runner_kind": "command",
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
raise ValidationError(f"{runner_ref}: unsupported runner kind {entrypoint['kind']!r}")
|
||||
|
||||
|
||||
def _no_runner_result(step: dict[str, Any]) -> dict[str, Any]:
|
||||
result = "manual" if step["kind"] == "check_group" else "skipped"
|
||||
return {
|
||||
"result": result,
|
||||
"observations": [
|
||||
"No runner is configured for this step in the baseline core."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": None,
|
||||
"runner_kind": None,
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
|
||||
|
||||
def _run_python_module(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
extension_path: Path,
|
||||
entrypoint: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
module_path = entrypoint.get("module_path")
|
||||
callable_name = entrypoint.get("callable")
|
||||
if not module_path or not callable_name:
|
||||
raise ValidationError(f"{entrypoint['id']}: python_module runners need module_path and callable")
|
||||
|
||||
module_file = (extension_path / module_path).resolve()
|
||||
try:
|
||||
module_file.relative_to(extension_path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"{entrypoint['id']}: module_path must stay inside the extension directory"
|
||||
) from exc
|
||||
|
||||
module = _load_module(module_file, entrypoint["id"])
|
||||
runner = getattr(module, callable_name, None)
|
||||
if not callable(runner):
|
||||
raise ValidationError(f"{entrypoint['id']}: callable {callable_name!r} was not found")
|
||||
|
||||
context = {
|
||||
"root": str(root),
|
||||
"run_dir": str(run_dir),
|
||||
"run_id": run_id,
|
||||
"plan": plan,
|
||||
"step": step,
|
||||
"target_profile": plan["target_profile_snapshot"],
|
||||
"assessment_profile": plan["assessment_profile_snapshot"],
|
||||
"extension_path": str(extension_path),
|
||||
"runner": entrypoint,
|
||||
}
|
||||
try:
|
||||
result = runner(context)
|
||||
except Exception as exc: # noqa: BLE001 - extension failures become evidence.
|
||||
return {
|
||||
"result": "infrastructure_error",
|
||||
"observations": [
|
||||
f"Runner {entrypoint['id']!r} failed before producing evidence: {exc}"
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "python_module",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
if not isinstance(result, dict):
|
||||
raise ValidationError(f"{entrypoint['id']}: runner must return an object")
|
||||
return {
|
||||
"result": result.get("result", "unknown"),
|
||||
"observations": result.get("observations", []),
|
||||
"facts": result.get("facts", {}),
|
||||
"artifact_refs": result.get("artifact_refs", []),
|
||||
}
|
||||
|
||||
|
||||
def _load_module(path: Path, runner_id: str) -> ModuleType:
|
||||
if not path.exists():
|
||||
raise ValidationError(f"{runner_id}: module not found: {path}")
|
||||
module_name = f"_guide_board_runner_{runner_id.replace('-', '_')}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValidationError(f"{runner_id}: unable to load module from {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _extension_snapshot(plan: dict[str, Any], extension_id: str) -> dict[str, Any]:
|
||||
for extension in plan["extension_snapshots"]:
|
||||
if extension["id"] == extension_id:
|
||||
return extension
|
||||
raise ValidationError(f"step references unknown extension {extension_id!r}")
|
||||
|
||||
|
||||
def _runner_entrypoint(manifest: dict[str, Any], runner_ref: str) -> dict[str, Any]:
|
||||
for entrypoint in manifest.get("runner_entrypoints", []):
|
||||
if entrypoint["id"] == runner_ref:
|
||||
return entrypoint
|
||||
raise ValidationError(f"{manifest['id']}: runner {runner_ref!r} is not declared")
|
||||
Reference in New Issue
Block a user