# SPDX-FileCopyrightText: 2026 James Harton # SPDX-FileCopyrightText: 2026 Holden Oullette # # SPDX-License-Identifier: Apache-2.0 defmodule BB.Jido.Action.Command do @moduledoc """ Jido action that executes a Beam Bots command. Bridges Jido's action system to BB's command infrastructure: starts the named command on the robot, awaits its result with `BB.Command.await/2`, and maps the outcome into the canonical bb_jido error taxonomy. ## Schema - `:robot` — the robot module (required). - `:command` — the command name as an atom (required). - `:goal` — the goal map passed to the command (default `%{}`). - `:timeout` — millisecond timeout for `BB.Command.await/2` (default `30_000`). ## Returns - `{:ok, %{command: ..., goal: ..., outcome: ...}}` on success. - `{:error, :safety_disarmed}` if the robot rejected the command because it was disarmed, or the command process was stopped by a disarm. - `{:error, {:command_failed, reason}}` for any other command failure or process termination. `reason` is passed through exactly once — failures that `BB.Command.await/2` already reports as `{:command_failed, reason}` (crash, `:timeout`, `:noproc`) are not wrapped again. A disarm that happens *mid-flight* stops the command process, but the awaited value is still whatever the command's `result/1` callback returns — commands that want callers to see `:safety_disarmed` in that case should surface `:disarmed` (or a `{:shutdown, :disarmed}` reason) from `result/1`. When routed through an agent, the success map is merged into agent state by Jido's default strategy — result keys deliberately avoid the plugin's `:robot` state key. ## Agent-routed execution caveats When this action runs via a signal route (rather than a direct `run/2` call), it executes under `Jido.Exec`, which has two effects: - `Jido.Exec` enforces its own default 30s execution timeout regardless of the `:timeout` param, and routed signals cannot override it (routed modules become `{module, signal.data}` with no instruction opts). A `:timeout` above 30s only takes effect if the `:jido_action` `:default_timeout` config is raised, or the action is invoked through an explicit instruction whose `:opts` set a `Jido.Exec` `:timeout`. - Error tuples are normalised into `Jido.Action.Error` exception structs; the tags above then appear under the error's details rather than as bare tuples. """ use Jido.Action, name: "bb_command", description: "Execute a Beam Bots command", category: "robotics", tags: ["beam-bots", "robot", "command"], schema: [ robot: [type: :atom, required: true, doc: "Robot module"], command: [type: :atom, required: true, doc: "Command name"], goal: [type: :map, default: %{}, doc: "Command goal map"], timeout: [ type: :pos_integer, default: 30_000, doc: "Command timeout in milliseconds" ] ], output_schema: [ command: [type: :atom, required: true, doc: "The command that ran"], goal: [type: :map, required: true, doc: "The goal it was given"], outcome: [type: :any, required: true, doc: "The command's result value"] ] alias BB.Jido.Telemetry @impl Jido.Action def run(%{robot: robot, command: command} = params, _context) do goal = Map.get(params, :goal, %{}) timeout = Map.get(params, :timeout, 30_000) Telemetry.span( [:bb_jido, :action, :command], %{robot: robot, command: command}, fn -> case apply(robot, command, [goal]) do {:ok, pid} -> await_command(pid, command, goal, timeout) {:error, reason} -> {:error, command_error(reason)} end end ) end defp await_command(pid, command, goal, timeout) do case BB.Command.await(pid, timeout) do {:ok, outcome} -> {:ok, build_result(command, goal, outcome)} {:ok, outcome, _opts} -> {:ok, build_result(command, goal, outcome)} {:error, reason} -> {:error, command_error(reason)} end end # BB.Command.await/2 pre-wraps infrastructure failures (crash, :timeout, # :noproc) as {:command_failed, reason}; unwrap before mapping so those # reasons aren't wrapped twice. defp command_error({:command_failed, reason}), do: command_error(reason) defp command_error(:disarmed), do: :safety_disarmed defp command_error({:shutdown, :disarmed}), do: :safety_disarmed defp command_error(%BB.Error.State.NotAllowed{current_state: :disarmed}), do: :safety_disarmed defp command_error(reason), do: {:command_failed, reason} defp build_result(command, goal, outcome) do %{ command: command, goal: goal, outcome: outcome } end end