Current section
Files
Jump to
Current section
Files
lib/sw4rm/worktree_state.ex
defmodule Sw4rm.WorktreeState do
@moduledoc """
Worktree state machine per SW4RM spec S16.
GenServer managing 5-state worktree binding FSM:
unbound -> bound_home -> switch_pending -> bound_non_home, bind_failed
"""
use GenServer
@transition_matrix %{
unbound: [:bound_home, :bind_failed],
bound_home: [:switch_pending, :unbound],
switch_pending: [:bound_non_home, :bound_home],
bound_non_home: [:bound_home, :unbound],
bind_failed: [:unbound, :bound_home]
}
defmodule BindingInfo do
@moduledoc false
defstruct [:worktree_id, :repo_id, :branch, :bound_at, :expires_at]
end
defmodule TransitionEntry do
@moduledoc false
defstruct [:from_state, :to_state, :timestamp, :metadata]
end
# -- Client API --
@doc "Start the worktree state machine. Options: `:switch_ttl` (default 3600s)."
def start_link(opts \\ []) do
name = Keyword.get(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc "Return the current FSM state atom."
def current_state(server), do: GenServer.call(server, :current_state)
@doc "Bind to a worktree. Transitions unbound/bind_failed -> bound_home."
def bind(server, worktree_id, repo_id, branch) do
GenServer.call(server, {:bind, worktree_id, repo_id, branch})
end
@doc "Unbind from current worktree."
def unbind(server), do: GenServer.call(server, :unbind)
@doc "Request switch to non-home worktree. bound_home -> switch_pending."
def request_switch(server, worktree_id, repo_id, branch) do
GenServer.call(server, {:request_switch, worktree_id, repo_id, branch})
end
@doc "Approve pending switch. switch_pending -> bound_non_home."
def approve_switch(server), do: GenServer.call(server, :approve_switch)
@doc "Reject pending switch. switch_pending -> bound_home."
def reject_switch(server), do: GenServer.call(server, :reject_switch)
@doc "Revert from non-home to home binding."
def revert_to_home(server), do: GenServer.call(server, :revert_to_home)
@doc "Return the current `%BindingInfo{}` or `nil`."
def get_current_binding(server), do: GenServer.call(server, :get_current_binding)
@doc "Return the home `%BindingInfo{}` or `nil`."
def get_home_binding(server), do: GenServer.call(server, :get_home_binding)
@doc "Return the pending switch `%BindingInfo{}` or `nil`."
def get_pending_switch(server), do: GenServer.call(server, :get_pending_switch)
@doc "Return the list of `%TransitionEntry{}` records."
def history(server), do: GenServer.call(server, :history)
# -- Callbacks --
@impl true
def init(opts) do
switch_ttl = Keyword.get(opts, :switch_ttl, 3600)
{:ok,
%{
state: :unbound,
home_binding: nil,
current_binding: nil,
pending_switch: nil,
switch_ttl: switch_ttl,
history: []
}}
end
@impl true
def handle_call(:current_state, _from, s), do: {:reply, s.state, s}
def handle_call({:bind, wt_id, repo_id, branch}, _from, s) do
if s.state in [:unbound, :bind_failed] do
case do_transition(s, :bound_home) do
{:ok, s} ->
binding = %BindingInfo{
worktree_id: wt_id,
repo_id: repo_id,
branch: branch,
bound_at: System.system_time(:second)
}
{:reply, {:ok, :bound_home}, %{s | home_binding: binding, current_binding: binding}}
{:error, _} = err ->
{:reply, err, s}
end
else
{:reply,
{:error,
Sw4rm.Error.Worktree.exception(
message: "bind is only valid from unbound or bind_failed, current state: #{s.state}",
worktree_id: (s.current_binding && s.current_binding.worktree_id) || "",
state: s.state
)}, s}
end
end
def handle_call(:unbind, _from, s) do
case do_transition(s, :unbound) do
{:ok, s} ->
{:reply, {:ok, :unbound}, %{s | current_binding: nil, pending_switch: nil}}
{:error, _} = err ->
{:reply, err, s}
end
end
def handle_call({:request_switch, wt_id, repo_id, branch}, _from, s) do
case do_transition(s, :switch_pending) do
{:ok, s} ->
pending = %BindingInfo{worktree_id: wt_id, repo_id: repo_id, branch: branch}
{:reply, {:ok, :switch_pending}, %{s | pending_switch: pending}}
{:error, _} = err ->
{:reply, err, s}
end
end
def handle_call(:approve_switch, _from, s) do
case s.pending_switch do
nil ->
{:reply, {:error, :no_pending_switch}, s}
pending ->
case do_transition(s, :bound_non_home) do
{:ok, s} ->
now = System.system_time(:second)
binding = %{
pending
| bound_at: now,
expires_at: now + s.switch_ttl
}
{:reply, {:ok, :bound_non_home}, %{s | current_binding: binding, pending_switch: nil}}
{:error, _} = err ->
{:reply, err, s}
end
end
end
def handle_call(:reject_switch, _from, s) do
case do_transition(s, :bound_home) do
{:ok, s} ->
{:reply, {:ok, :bound_home}, %{s | current_binding: s.home_binding, pending_switch: nil}}
{:error, _} = err ->
{:reply, err, s}
end
end
def handle_call(:revert_to_home, _from, s) do
case do_transition(s, :bound_home) do
{:ok, s} ->
{:reply, {:ok, :bound_home}, %{s | current_binding: s.home_binding}}
{:error, _} = err ->
{:reply, err, s}
end
end
def handle_call(:get_current_binding, _from, s), do: {:reply, s.current_binding, s}
def handle_call(:get_home_binding, _from, s), do: {:reply, s.home_binding, s}
def handle_call(:get_pending_switch, _from, s), do: {:reply, s.pending_switch, s}
def handle_call(:history, _from, s), do: {:reply, s.history, s}
# -- Helpers --
defp do_transition(s, to_state) do
allowed = Map.get(@transition_matrix, s.state, [])
if to_state in allowed do
entry = %TransitionEntry{
from_state: s.state,
to_state: to_state,
timestamp: System.system_time(:second)
}
{:ok, %{s | state: to_state, history: [entry | s.history]}}
else
{:error,
Sw4rm.Error.Worktree.exception(
message: "Invalid worktree transition from #{s.state} to #{to_state}",
worktree_id: (s.current_binding && s.current_binding.worktree_id) || "",
state: s.state
)}
end
end
end