defmodule ExecutionPlane.Process.Transport.Surface do @moduledoc """ Legacy compatibility shell for the public execution-surface contract. Execution Plane now owns the `execution_surface` contract. This module keeps the historical public shape so existing callers can continue to reason about narrow placement without leaking command or provider semantics. This contract is intentionally narrow: - `surface_kind` selects the runtime surface - `transport_options` carries transport-only data - `target_id`, `lease_ref`, `surface_ref`, and `boundary_class` stay typed - `observability` remains an opaque metadata bag Provider family, command selection, and process launch arguments do not belong in `ExecutionSurface`. """ alias ExecutionPlane.Process.Transport.Surface.{Capabilities, Registry} @contract_version "execution_surface.v1" @default_surface_kind :local_subprocess @reserved_keys [ :contract_version, :surface_kind, :transport_options, :target_id, :lease_ref, :surface_ref, :boundary_class, :observability ] @forbidden_transport_option_keys [:command, :args, :cwd, :env, :clear_env?, :user] @transport_option_keys [ :args, :attach_token, :bridge_profile, :bridge_ref, :clear_env?, :command, :connect_timeout_ms, :cwd, :destination, :endpoint, :env, :exit, :exit_code, :extensions, :identity_file, :port, :request_timeout_ms, :scenario_ref, :ssh_args, :ssh_options, :ssh_path, :ssh_user, :stderr, :stderr_frames, :stdout, :stdout_frames, :supported_protocol_versions, :user ] @transport_option_key_aliases Map.new(@transport_option_keys, fn key -> {Atom.to_string(key), key} end) defstruct contract_version: @contract_version, surface_kind: @default_surface_kind, transport_options: [], target_id: nil, lease_ref: nil, surface_ref: nil, boundary_class: nil, observability: %{} @type contract_version :: String.t() @type surface_kind :: :local_subprocess | :ssh_exec | :guest_bridge @type adapter_surface_kind :: atom() @type boundary_class :: atom() | String.t() | nil @type reserved_key :: :contract_version | :surface_kind | :transport_options | :target_id | :lease_ref | :surface_ref | :boundary_class | :observability @type t :: %__MODULE__{ contract_version: contract_version(), surface_kind: surface_kind(), transport_options: keyword(), target_id: String.t() | nil, lease_ref: String.t() | nil, surface_ref: String.t() | nil, boundary_class: boundary_class(), observability: map() } @type projected_t :: %{ required(:contract_version) => contract_version(), required(:surface_kind) => surface_kind(), required(:transport_options) => map(), required(:target_id) => String.t() | nil, required(:lease_ref) => String.t() | nil, required(:surface_ref) => String.t() | nil, required(:boundary_class) => boundary_class(), required(:observability) => map() } @type validation_error :: {:invalid_contract_version, term()} | {:invalid_surface_kind, term()} | {:invalid_transport_options, term()} | {:invalid_execution_surface, term()} | {:invalid_target_id, term()} | {:invalid_lease_ref, term()} | {:invalid_surface_ref, term()} | {:invalid_boundary_class, term()} | {:invalid_observability, term()} | {:adapter_not_loaded, module()} @type resolution_error :: {:unsupported_surface_kind, surface_kind()} @type dispatch :: %{ start: function(), start_link: function(), run: function() } @type resolved :: %{ adapter_capabilities: Capabilities.t(), dispatch: dispatch(), adapter_options: keyword(), surface: t() } @spec default_surface_kind() :: :local_subprocess def default_surface_kind, do: @default_surface_kind @spec contract_version() :: String.t() def contract_version, do: @contract_version @spec reserved_keys() :: [reserved_key(), ...] def reserved_keys, do: @reserved_keys @spec supported_surface_kinds() :: [adapter_surface_kind(), ...] def supported_surface_kinds, do: Registry.supported_surface_kinds() @spec remote_surface_kind?(surface_kind()) :: boolean() def remote_surface_kind?(surface_kind) when is_atom(surface_kind) do case capabilities(surface_kind) do {:ok, %Capabilities{remote?: remote?}} -> remote? {:error, _reason} -> false end end @spec capabilities(t() | surface_kind() | keyword() | map() | nil) :: {:ok, Capabilities.t()} | {:error, term()} def capabilities(%__MODULE__{surface_kind: surface_kind}), do: capabilities(surface_kind) def capabilities(surface_kind) when is_atom(surface_kind), do: adapter_capabilities(surface_kind) def capabilities(opts) when is_list(opts) do with {:ok, attrs} <- execution_surface_attrs(opts), surface_kind when is_atom(surface_kind) <- Keyword.get(attrs, :surface_kind) do capabilities(surface_kind) else nil -> {:error, {:invalid_execution_surface, opts}} {:error, _reason} = error -> error _other -> {:error, {:invalid_execution_surface, opts}} end end def capabilities(%{} = surface) do case Map.get(surface, :__struct__) do __MODULE__ -> capabilities(Map.get(surface, :surface_kind)) _other -> capabilities(Map.get(surface, :surface_kind, Map.get(surface, "surface_kind"))) end end def capabilities(_other), do: {:error, {:invalid_execution_surface, nil}} @spec path_semantics(t() | surface_kind() | keyword() | map() | nil) :: Capabilities.path_semantics() | nil def path_semantics(surface) do case capabilities(surface) do {:ok, %Capabilities{path_semantics: path_semantics}} -> path_semantics {:error, _reason} -> nil end end @spec nonlocal_path_surface?(t() | surface_kind() | keyword() | map() | nil) :: boolean() def nonlocal_path_surface?(surface) do path_semantics(surface) in [:remote, :guest] end @spec remote_surface?(t() | surface_kind() | keyword() | map() | nil) :: boolean() def remote_surface?(surface) do case capabilities(surface) do {:ok, %Capabilities{remote?: remote?}} -> remote? {:error, _reason} -> false end end @spec new(keyword()) :: {:ok, t()} | {:error, validation_error()} def new(opts) when is_list(opts) do with {:ok, attrs} <- execution_surface_attrs(opts), :ok <- validate_contract_version(Keyword.get(attrs, :contract_version)), {:ok, surface_kind} <- normalize_surface_kind(Keyword.get(attrs, :surface_kind)), {:ok, transport_options} <- normalize_transport_options(Keyword.get(attrs, :transport_options)), :ok <- validate_optional_binary(Keyword.get(attrs, :target_id), :target_id), :ok <- validate_optional_binary(Keyword.get(attrs, :lease_ref), :lease_ref), :ok <- validate_optional_binary(Keyword.get(attrs, :surface_ref), :surface_ref), :ok <- validate_boundary_class(Keyword.get(attrs, :boundary_class)), :ok <- validate_observability(Keyword.get(attrs, :observability, %{})) do {:ok, %__MODULE__{ contract_version: @contract_version, surface_kind: surface_kind, transport_options: Keyword.drop(transport_options, @forbidden_transport_option_keys), target_id: Keyword.get(attrs, :target_id), lease_ref: Keyword.get(attrs, :lease_ref), surface_ref: Keyword.get(attrs, :surface_ref), boundary_class: Keyword.get(attrs, :boundary_class), observability: Keyword.get(attrs, :observability, %{}) }} end end @spec resolve(keyword()) :: {:ok, resolved()} | {:error, validation_error() | resolution_error()} def resolve(opts) when is_list(opts) do with {:ok, %__MODULE__{} = surface} <- new(opts), {:ok, adapter} <- Registry.fetch(surface.surface_kind), :ok <- ensure_adapter_loaded(adapter), {:ok, %Capabilities{} = adapter_capabilities} <- normalize_adapter_capabilities(adapter), {:ok, transport_options} <- adapter.normalize_transport_options(surface.transport_options) do {:ok, %{ adapter_capabilities: adapter_capabilities, dispatch: adapter_dispatch(adapter), adapter_options: build_adapter_options(opts, surface, transport_options, adapter_capabilities), surface: surface }} else {:error, _reason} = error -> error end end @spec normalize_surface_kind(term()) :: {:ok, surface_kind()} | {:error, {:invalid_surface_kind, term()}} def normalize_surface_kind(nil), do: {:ok, @default_surface_kind} def normalize_surface_kind(surface_kind) when is_atom(surface_kind) do if Registry.registered?(surface_kind) do {:ok, surface_kind} else {:error, {:invalid_surface_kind, surface_kind}} end end def normalize_surface_kind(surface_kind), do: {:error, {:invalid_surface_kind, surface_kind}} @spec normalize_transport_options(term()) :: {:ok, keyword()} | {:error, {:invalid_transport_options, term()}} def normalize_transport_options(nil), do: {:ok, []} def normalize_transport_options(options) when is_list(options) do if Keyword.keyword?(options) do {:ok, options} else {:error, {:invalid_transport_options, options}} end end def normalize_transport_options(options) when is_map(options) do case Enum.reduce_while(options, [], &normalize_transport_option_pair/2) do :error -> {:error, {:invalid_transport_options, options}} normalized -> {:ok, Enum.reverse(normalized)} end end def normalize_transport_options(options), do: {:error, {:invalid_transport_options, options}} @spec surface_metadata(t()) :: keyword() def surface_metadata(%__MODULE__{} = surface) do [ surface_kind: surface.surface_kind, target_id: surface.target_id, lease_ref: surface.lease_ref, surface_ref: surface.surface_ref, boundary_class: surface.boundary_class, observability: surface.observability ] end @spec to_map(t()) :: projected_t() def to_map(%__MODULE__{} = surface) do %{ contract_version: surface.contract_version, surface_kind: surface.surface_kind, transport_options: mapify(surface.transport_options), target_id: surface.target_id, lease_ref: surface.lease_ref, surface_ref: surface.surface_ref, boundary_class: surface.boundary_class, observability: mapify(surface.observability) } end defp adapter_dispatch(adapter) when is_atom(adapter) do %{ start: &adapter.start/1, start_link: &adapter.start_link/1, run: &adapter.run/2 } end defp ensure_adapter_loaded(adapter) when is_atom(adapter) do if Code.ensure_loaded?(adapter) do :ok else {:error, {:adapter_not_loaded, adapter}} end end defp normalize_adapter_capabilities(adapter) when is_atom(adapter) do capabilities = adapter.capabilities() case Capabilities.new(capabilities) do {:ok, %Capabilities{} = normalized} -> {:ok, normalized} {:error, reason} -> {:error, {:invalid_transport_options, {:invalid_adapter_capabilities, reason}}} end end defp build_adapter_options( opts, %__MODULE__{} = surface, transport_options, adapter_capabilities ) when is_list(opts) and is_list(transport_options) and is_struct(adapter_capabilities, Capabilities) do opts |> Keyword.drop(@reserved_keys) |> Keyword.put(:transport_options, transport_options) |> Keyword.merge(surface_metadata(surface)) |> Keyword.put(:adapter_capabilities, adapter_capabilities) |> maybe_put_effective_capabilities(surface.surface_kind, adapter_capabilities) end defp maybe_put_effective_capabilities(opts, :guest_bridge, _adapter_capabilities), do: opts defp maybe_put_effective_capabilities( opts, _surface_kind, %Capabilities{} = adapter_capabilities ) do Keyword.put(opts, :effective_capabilities, adapter_capabilities) end defp validate_optional_binary(nil, _field), do: :ok defp validate_optional_binary(value, _field) when is_binary(value) and value != "", do: :ok defp validate_optional_binary(value, :target_id), do: {:error, {:invalid_target_id, value}} defp validate_optional_binary(value, :lease_ref), do: {:error, {:invalid_lease_ref, value}} defp validate_optional_binary(value, :surface_ref), do: {:error, {:invalid_surface_ref, value}} defp validate_contract_version(nil), do: :ok defp validate_contract_version(@contract_version), do: :ok defp validate_contract_version(value), do: {:error, {:invalid_contract_version, value}} defp validate_boundary_class(nil), do: :ok defp validate_boundary_class(boundary_class) when is_atom(boundary_class), do: :ok defp validate_boundary_class(boundary_class) when is_binary(boundary_class) and boundary_class != "", do: :ok defp validate_boundary_class(boundary_class), do: {:error, {:invalid_boundary_class, boundary_class}} defp validate_observability(observability) when is_map(observability), do: :ok defp validate_observability(observability), do: {:error, {:invalid_observability, observability}} defp normalize_transport_option_key(key) when is_binary(key), do: Map.fetch(@transport_option_key_aliases, key) defp normalize_transport_option_pair({key, value}, acc) when is_atom(key) do {:cont, [{key, value} | acc]} end defp normalize_transport_option_pair({key, value}, acc) when is_binary(key) do case normalize_transport_option_key(key) do {:ok, normalized_key} -> {:cont, [{normalized_key, value} | acc]} :error -> {:halt, :error} end end defp normalize_transport_option_pair(_other, _acc), do: {:halt, :error} defp execution_surface_attrs(opts) when is_list(opts) do case Keyword.get(opts, :execution_surface) do nil -> {:ok, Keyword.take(opts, @reserved_keys)} execution_surface -> normalize_execution_surface(execution_surface) end end defp normalize_execution_surface(%__MODULE__{} = surface) do {:ok, [ contract_version: surface.contract_version, transport_options: surface.transport_options ] ++ surface_metadata(surface)} end defp normalize_execution_surface(attrs) when is_list(attrs) do if Keyword.keyword?(attrs) do {:ok, Keyword.take(attrs, @reserved_keys)} else {:error, {:invalid_execution_surface, attrs}} end end defp normalize_execution_surface(attrs) when is_map(attrs) do {:ok, [ contract_version: Map.get(attrs, :contract_version, Map.get(attrs, "contract_version")), surface_kind: Map.get(attrs, :surface_kind, Map.get(attrs, "surface_kind")), transport_options: Map.get(attrs, :transport_options, Map.get(attrs, "transport_options")), target_id: Map.get(attrs, :target_id, Map.get(attrs, "target_id")), lease_ref: Map.get(attrs, :lease_ref, Map.get(attrs, "lease_ref")), surface_ref: Map.get(attrs, :surface_ref, Map.get(attrs, "surface_ref")), boundary_class: Map.get(attrs, :boundary_class, Map.get(attrs, "boundary_class")), observability: Map.get(attrs, :observability, Map.get(attrs, "observability", %{})) ]} end defp normalize_execution_surface(attrs), do: {:error, {:invalid_execution_surface, attrs}} defp adapter_capabilities(surface_kind) when is_atom(surface_kind) do with {:ok, adapter} <- Registry.fetch(surface_kind), :ok <- ensure_adapter_loaded(adapter), {:ok, %Capabilities{} = capabilities} <- normalize_adapter_capabilities(adapter) do {:ok, capabilities} end end defp mapify(value) when is_list(value) do if Keyword.keyword?(value) do value |> Enum.into(%{}, fn {key, nested_value} -> {key, mapify(nested_value)} end) else Enum.map(value, &mapify/1) end end defp mapify(value) when is_map(value) do Enum.into(value, %{}, fn {key, nested_value} -> {key, mapify(nested_value)} end) end defp mapify(value), do: value end