defmodule Graft.Plan do
@moduledoc """
A plan to transition a workspace from one snapshot to another.
## Model
A plan is:
* `current` — the workspace snapshot before the operation
* `desired` — the workspace snapshot after the operation (computed, not observed)
* `operations` — ordered steps to effect the transition
* `rollback` — ordered steps to revert if anything fails
* `preconditions` — assertions that must hold before execution
## Philosophy
Plans are **declarative**. They describe desired state, not imperative
commands. Execution is the job of `Graft.Runner` (not yet implemented).
## Source
Plans can be built from:
* A `Workspace.Diff` — "here's what changed, make it so"
* A direct manipulation — `link.on jason` produces a desired snapshot with links added
* A template — "workspace with all deps resolved to paths"
## Invariants
* Every operation in a plan has a unique index.
* Dependencies between operations are explicit (`depends_on`).
* Preconditions reference `current` only, never `desired`.
* Rollback steps are the inverse of forward steps, in reverse order.
"""
alias Graft.{Error, Workspace}
alias Graft.Workspace.Diff
defmodule Operation do
@moduledoc "A single step in a graft plan."
@type t :: %__MODULE__{
index: non_neg_integer(),
type:
:attach_repo
| :detach_repo
| :create_link
| :remove_link
| :rewrite_file
| :validate_repo
| :run_command,
target: String.t(),
params: map(),
depends_on: [non_neg_integer()],
status: :pending | :running | :completed | :failed,
result: any()
}
defstruct index: 0,
type: nil,
target: nil,
params: %{},
depends_on: [],
status: :pending,
result: nil
end
defmodule Precondition do
@moduledoc "An assertion that must hold before plan execution."
@type t :: %__MODULE__{
type: :repo_exists | :repo_clean | :file_unchanged | :lock_available,
target: String.t(),
expected: any(),
message: String.t()
}
defstruct [:type, :target, :expected, :message]
end
@type t :: %__MODULE__{
id: String.t(),
action: :attach | :detach | :repair | :migrate,
status:
:draft | :planned | :verified | :executing | :committed | :rolled_back | :failed,
current: Workspace.t() | nil,
desired: Workspace.t() | nil,
preconditions: [Precondition.t()],
operations: [Operation.t()],
rollback: [Operation.t()],
created_at: DateTime.t(),
started_at: DateTime.t() | nil,
completed_at: DateTime.t() | nil,
error: Error.t() | nil
}
defstruct [
:id,
:action,
:status,
:current,
:desired,
preconditions: [],
operations: [],
rollback: [],
created_at: nil,
started_at: nil,
completed_at: nil,
error: nil
]
@doc """
Build a plan from a workspace diff.
Given `current` and `desired` snapshots, compute the operations needed
to transition between them.
"""
@spec from_diff(Workspace.t(), Workspace.t()) :: t()
def from_diff(%Workspace{} = current, %Workspace{} = desired) do
delta = Diff.diff(current, desired)
operations = delta_to_operations(delta)
preconditions = derive_preconditions(current, operations)
rollback = derive_rollback(operations)
%__MODULE__{
id: generate_id(),
action: infer_action(delta),
status: :planned,
current: current,
desired: desired,
preconditions: preconditions,
operations: with_indices(operations),
rollback: with_indices(rollback),
created_at: DateTime.utc_now()
}
end
@doc """
Build a plan for a `link.on` operation.
Convenience: given a current snapshot and target app(s), compute the
desired snapshot (with path links established) and build the plan.
"""
@spec link_on(Workspace.t(), [atom()]) :: {:ok, t()} | {:error, Error.t()}
def link_on(%Workspace{} = current, target_apps) when is_list(target_apps) do
# Delegates to existing Link.Plan logic, wrapped in new shape
case Graft.Link.Plan.build(current, target_apps, operation: :link_on) do
{:ok, link_plan} ->
desired = apply_link_plan_to_snapshot(current, link_plan)
plan = from_diff(current, desired)
{:ok, %{plan | action: :attach}}
{:error, _} = err ->
err
end
end
@doc """
Verify that all preconditions hold against the current filesystem state.
"""
@spec verify(t()) :: {:ok, t()} | {:error, t()}
def verify(%__MODULE__{preconditions: preconditions, current: current} = plan) do
repo_paths =
if current do
Map.new(current.repos, &{Atom.to_string(&1.name), &1.absolute_path})
else
%{}
end
failed =
Enum.filter(preconditions, fn pc ->
not precondition_holds?(repo_paths, pc)
end)
if failed == [] do
{:ok, %{plan | status: :verified}}
else
messages = Enum.map(failed, & &1.message)
error =
Error.new(
:plan_precondition_failed,
"Precondition check failed: #{Enum.join(messages, "; ")}",
%{failed_preconditions: failed}
)
{:error, %{plan | status: :failed, error: error}}
end
end
@doc """
True if the plan contains no operations.
"""
@spec noop?(t()) :: boolean()
def noop?(%__MODULE__{operations: []}), do: true
def noop?(_), do: false
## ─── Diff → Operations ────────────────────────────────────────────
defp delta_to_operations(%Diff.Delta{} = delta) do
adds = Enum.map(delta.added, &entry_to_operation(&1, :add))
rems = Enum.map(delta.removed, &entry_to_operation(&1, :remove))
chgs = Enum.map(delta.changed, &entry_to_operation(&1, :change))
adds ++ chgs ++ rems
end
defp entry_to_operation(%{path: [:repos, name], before: nil, after: _}, :add) do
%Operation{type: :attach_repo, target: Atom.to_string(name)}
end
defp entry_to_operation(%{path: [:repos, name], before: _, after: nil}, :remove) do
%Operation{type: :detach_repo, target: Atom.to_string(name)}
end
defp entry_to_operation(%{path: [:links, {repo, dep}], before: nil, after: _}, :add) do
%Operation{type: :create_link, target: "#{repo}/#{dep}"}
end
defp entry_to_operation(%{path: [:links, {repo, dep}], before: _, after: nil}, :remove) do
%Operation{type: :remove_link, target: "#{repo}/#{dep}"}
end
defp entry_to_operation(
%{path: [:deps, {repo, _app}], before: before, after: after_val},
:change
) do
%Operation{
type: :rewrite_file,
target: Atom.to_string(repo),
params: %{file: "mix.exs", before: before, after: after_val}
}
end
defp entry_to_operation(%{path: path, before: before, after: after_val}, :change) do
%Operation{
type: :rewrite_file,
target: path_to_string(path),
params: %{before: before, after: after_val}
}
end
defp entry_to_operation(entry, kind) do
%Operation{
type: :run_command,
target: path_to_string(entry.path),
params: %{kind: kind, before: entry.before, after: entry.after}
}
end
## ─── Preconditions ────────────────────────────────────────────────
defp derive_preconditions(_current, operations) do
repo_ops =
Enum.filter(
operations,
&(&1.type in [:attach_repo, :detach_repo, :create_link, :remove_link])
)
affected_repos =
repo_ops
|> Enum.flat_map(&operation_repo_targets/1)
|> Enum.uniq()
Enum.map(affected_repos, fn repo ->
%Precondition{
type: :repo_exists,
target: repo,
expected: true,
message: "Repo #{repo} must exist in workspace"
}
end)
end
defp operation_repo_targets(%Operation{type: :attach_repo, target: t}), do: [t]
defp operation_repo_targets(%Operation{type: :detach_repo, target: t}), do: [t]
defp operation_repo_targets(%Operation{type: :create_link, target: t}),
do: String.split(t, "/", parts: 2)
defp operation_repo_targets(%Operation{type: :remove_link, target: t}),
do: String.split(t, "/", parts: 2)
defp operation_repo_targets(_), do: []
defp precondition_holds?(repo_paths, %Precondition{type: :repo_exists, target: repo}) do
path = Map.get(repo_paths, repo, repo)
File.dir?(path)
end
defp precondition_holds?(_repo_paths, _other), do: true
## ─── Rollback ───────────────────────────────────────────────────────
defp derive_rollback(operations) do
operations
|> Enum.reverse()
|> Enum.map(&invert_operation/1)
end
defp invert_operation(%Operation{type: :attach_repo, target: t}) do
%Operation{type: :detach_repo, target: t}
end
defp invert_operation(%Operation{type: :detach_repo, target: t}) do
%Operation{type: :attach_repo, target: t}
end
defp invert_operation(%Operation{type: :create_link, target: t}) do
%Operation{type: :remove_link, target: t}
end
defp invert_operation(%Operation{type: :remove_link, target: t}) do
%Operation{type: :create_link, target: t}
end
defp invert_operation(%Operation{type: :rewrite_file, target: t, params: p}) do
%Operation{type: :rewrite_file, target: t, params: %{p | before: p.after, after: p.before}}
end
defp invert_operation(op), do: op
## ─── Helpers ────────────────────────────────────────────────────────
defp infer_action(%Diff.Delta{added: [], removed: [_ | _]}), do: :detach
defp infer_action(%Diff.Delta{added: [_ | _], removed: []}), do: :attach
defp infer_action(%Diff.Delta{changed: [_ | _]}), do: :repair
defp infer_action(_), do: :migrate
defp with_indices(operations) do
Enum.with_index(operations, fn op, idx -> %{op | index: idx} end)
end
defp generate_id do
:crypto.strong_rand_bytes(8)
|> Base.encode16(case: :lower)
end
defp path_to_string(path) when is_list(path) do
path
|> Enum.map(&to_string/1)
|> Enum.join(".")
end
## ─── Link plan application ────────────────────────────────────────
defp apply_link_plan_to_snapshot(%Workspace{} = snapshot, link_plan) do
# Build a new snapshot with the links that would exist after link.on
new_links =
link_plan.changes
|> Enum.filter(& &1.changed?)
|> Enum.map(fn change ->
%Workspace.Link{
repo: change.repo,
dep: change.target_app,
mix_exs_sha256_before: change.mix_exs_before_hash,
mix_exs_sha256_after: change.proposed_mix_exs_after_hash,
preimage: change.dependency_source_before,
replacement: change.dependency_source_after
}
end)
# Merge with existing links (replacing matching entries)
existing = Map.new(snapshot.links, &{{&1.repo, &1.dep}, &1})
incoming = Map.new(new_links, &{{&1.repo, &1.dep}, &1})
merged = Map.merge(existing, incoming) |> Map.values()
%{snapshot | links: merged}
end
end