"""Action runner — legacy entry point for single-action execution. F-05 remediation: The original implementation treated ``action["cmd"]`` as a callable and invoked it directly, which is undefined behavior for normal shell-command actions. This module now delegates to :func:`backend.executor.runtime_router.execute_action`, which is the same path the pipeline engine uses — so a target routed here gets the same real subprocess + runtime-selection logic as a target routed through the engine. """ from __future__ import annotations import logging from typing import Any, Mapping, Optional from backend.events.bus import EventBus from backend.executor.runtime_router import execute_action log = logging.getLogger(__name__) def run_action( action: Mapping[str, Any], node: Mapping[str, Any], bus: EventBus, ) -> int: """Execute one action on the chosen node and emit bus events. Emits ``task_update(running)`` before dispatch and ``task_update(done|failed)`` after. Returns the subprocess exit code (0 = success). """ action_name = action.get("name", "") node_name = node.get("name", "localhost") if isinstance(node, Mapping) else "localhost" bus.emit( "task_update", node=node_name, action=action_name, state="running", ) # Normalise the action dict so runtime_router can consume it. # Accept either ``cmd`` (legacy) or ``command`` (runtime_router # convention). runtime_router looks at action["command"]. normalised = dict(action) if "command" not in normalised and "cmd" in normalised: normalised["command"] = normalised["cmd"] workspace: Optional[str] = normalised.get("dir") or normalised.get("cwd") or "/tmp" try: rc = execute_action(normalised, workspace, dict(node) if isinstance(node, Mapping) else {"name": node_name}) state = "done" if rc == 0 else "failed" except Exception as e: log.exception("action %s crashed", action_name) bus.emit( "failure", node=node_name, action=action_name, state="failed", reason=f"{type(e).__name__}: {e}", ) return 1 bus.emit( "task_update", node=node_name, action=action_name, state=state, ) return 0 if state == "done" else 1