Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: agent sends realtime deployment status feedback

Add lightweight channel messages so the server has real-time visibility
into deployment progress. Deployment.state gains :acknowledged (sent
when the agent receives a deployment), and SeedDeployment gets a new
state column tracking per-seed phases: pending → downloading →
activating → completed.

sow-70

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+165 -8
+2
AGENTS.md
··· 22 22 23 23 ## Code conventions 24 24 25 + - Prefer red/green TDD. If unsure what style of testing, stop and ask. 25 26 - Always read code for project elixir dependencies from `deps`. Never query hexdocs or hex. 27 + - SowerClient schemas must *always* be added to `sower_client.ex` 26 28 27 29 ## Testing 28 30
+19 -7
apps/sower/lib/sower/orchestration/deployment.ex
··· 34 34 field :result, Ecto.Enum, values: [:success, :failure, :partial] 35 35 36 36 field :state, Ecto.Enum, 37 - values: [:created, :dispatched, :completed, :stale], 37 + values: [:created, :dispatched, :acknowledged, :completed, :stale], 38 38 default: :created 39 39 40 40 field :last_dispatched_at, :utc_datetime_usec ··· 97 97 98 98 query = 99 99 from(d in __MODULE__, 100 - where: d.agent_id == ^agent.id and d.state in [:created, :dispatched], 100 + where: d.agent_id == ^agent.id and d.state in [:created, :dispatched, :acknowledged], 101 101 order_by: [ 102 102 asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), 103 103 asc: d.inserted_at ··· 193 193 retry_in_progress? = 194 194 from(d in __MODULE__, 195 195 where: 196 - d.parent_deployment_id == ^deployment.id and d.state in [:created, :dispatched], 196 + d.parent_deployment_id == ^deployment.id and 197 + d.state in [:created, :dispatched, :acknowledged], 197 198 limit: 1, 198 199 select: d.id 199 200 ) ··· 373 374 {:ok, request_id} 374 375 end 375 376 377 + def record_deployment_status(%SowerClient.Orchestration.DeploymentStatus{} = status) do 378 + case get_deployment_sid(status.deployment_sid) do 379 + nil -> 380 + {:error, :deployment_not_found} 381 + 382 + deploy -> 383 + update_deployment(deploy, %{state: status.status}) 384 + end 385 + end 386 + 376 387 def record_deployment(%SowerClient.Orchestration.DeploymentResult{} = result) do 377 388 case get_deployment_sid(result.deployment_sid) do 378 389 nil -> ··· 401 412 402 413 stale_deployments = 403 414 from(d in __MODULE__, 404 - where: d.state in [:created, :dispatched], 415 + where: d.state in [:created, :dispatched, :acknowledged], 405 416 where: fragment("COALESCE(?, ?) <= ?", d.last_dispatched_at, d.inserted_at, ^cutoff), 406 417 order_by: [ 407 418 asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), ··· 586 597 where: 587 598 d.agent_id == ^agent_id and 588 599 d.content_hash == ^content_hash and 589 - (d.result == :success or d.state in [:created, :dispatched]), 600 + (d.result == :success or d.state in [:created, :dispatched, :acknowledged]), 590 601 order_by: [desc: d.inserted_at], 591 602 limit: 1 592 603 ) ··· 632 643 now = DateTime.utc_now() 633 644 634 645 from(d in __MODULE__, 635 - where: d.id in ^ids and d.state in [:created, :dispatched] 646 + where: d.id in ^ids and d.state in [:created, :dispatched, :acknowledged] 636 647 ) 637 648 |> Repo.update_all( 638 649 set: [last_dispatched_at: dispatched_at, state: :dispatched, updated_at: now] ··· 650 661 nil -> 651 662 :ignore 652 663 653 - %__MODULE__{state: state} = unresolved when state in [:created, :dispatched] -> 664 + %__MODULE__{state: state} = unresolved 665 + when state in [:created, :dispatched, :acknowledged] -> 654 666 update_deployment(unresolved, %{deployed_at: now, result: :failure, state: :stale}) 655 667 656 668 %__MODULE__{} ->
+22 -1
apps/sower/lib/sower/orchestration/seed_deployment.ex
··· 12 12 field :log, :string 13 13 field :result, Ecto.Enum, values: [:success, :failure] 14 14 15 + field :state, Ecto.Enum, 16 + values: [:pending, :downloading, :activating, :completed], 17 + default: :pending 18 + 15 19 timestamps() 16 20 end 17 21 18 22 @doc false 19 23 def changeset(seed_deployment, attrs) do 20 24 seed_deployment 21 - |> cast(attrs, [:log, :result]) 25 + |> cast(attrs, [:log, :result, :state]) 26 + end 27 + 28 + def record_seed_status( 29 + %SowerClient.Orchestration.SeedDeploymentStatus{} = status, 30 + %Sower.Orchestration.Agent{} = agent 31 + ) do 32 + with {:ok, deployment} <- fetch_deployment(status.deployment_sid), 33 + :ok <- verify_ownership(deployment, agent), 34 + {:ok, seed_deployment} <- fetch_seed_deployment(deployment.id, status.seed_sid) do 35 + seed_deployment 36 + |> changeset(%{state: status.status}) 37 + |> Repo.update(skip_org_id: true) 38 + |> case do 39 + {:ok, _} -> {:ok, %{}} 40 + error -> error 41 + end 42 + end 22 43 end 23 44 24 45 def record_seed_result(
+9
apps/sower/lib/sower_web/agent_channel.ex
··· 131 131 end) 132 132 133 133 handle_schema( 134 + SowerClient.Orchestration.DeploymentStatus, 135 + &Sower.Orchestration.Deployment.record_deployment_status/1 136 + ) 137 + 138 + handle_schema( 134 139 SowerClient.Orchestration.DeploymentResult, 135 140 &Sower.Orchestration.record_deployment/1 136 141 ) 142 + 143 + handle_schema(SowerClient.Orchestration.SeedDeploymentStatus, fn req, socket -> 144 + Sower.Orchestration.SeedDeployment.record_seed_status(req, socket.assigns.agent) 145 + end) 137 146 138 147 handle_schema(SowerClient.Orchestration.SeedDeploymentResult, fn req, socket -> 139 148 Sower.Orchestration.SeedDeployment.record_seed_result(req, socket.assigns.agent)
+8
apps/sower/lib/sower_web/components/sower_components.ex
··· 209 209 </span> 210 210 Dispatched 211 211 </span> 212 + <% :acknowledged -> %> 213 + <span class="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400"> 214 + <span class="relative flex h-2.5 w-2.5"> 215 + <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75" /> 216 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-blue-500" /> 217 + </span> 218 + Acknowledged 219 + </span> 212 220 <% :completed -> %> 213 221 <.result result={@result} /> 214 222 <% :stale -> %>
+15
apps/sower/priv/repo/migrations/20260319120000_add_deployment_status_fields.exs
··· 1 + defmodule Sower.Repo.Migrations.AddDeploymentStatusFields do 2 + use Ecto.Migration 3 + 4 + def up do 5 + alter table(:seed_deployment) do 6 + add :state, :string, default: "pending", null: false 7 + end 8 + end 9 + 10 + def down do 11 + alter table(:seed_deployment) do 12 + remove :state 13 + end 14 + end 15 + end
+17
apps/sower_agent/lib/sower_agent/client.ex
··· 5 5 6 6 alias SowerAgent.Scheduler 7 7 alias SowerAgent.Storage 8 + alias SowerClient.Orchestration.DeploymentStatus 9 + alias SowerClient.Orchestration.SeedDeploymentStatus 8 10 9 11 def deploy(%SowerClient.Orchestration.Subscription{} = sub, opts \\ []) do 10 12 force? = Keyword.get(opts, :force, false) ··· 54 56 55 57 {:noreply, socket} 56 58 end 59 + end 60 + 61 + @impl Slipstream 62 + def handle_cast({:seed_status, %SeedDeploymentStatus{} = status}, socket) do 63 + {:ok, _} = push(socket, private_channel(socket), SeedDeploymentStatus.event(), status) 64 + {:noreply, socket} 57 65 end 58 66 59 67 @impl Slipstream ··· 287 295 {:noreply, socket} 288 296 289 297 deployment -> 298 + {:ok, _} = 299 + push_message( 300 + socket, 301 + DeploymentStatus.cast!(%{ 302 + deployment_sid: deployment.sid, 303 + status: :acknowledged 304 + }) 305 + ) 306 + 290 307 Task.Supervisor.start_child(SowerAgent.TaskSupervisor, fn -> 291 308 result = 292 309 try do
+18
apps/sower_agent/lib/sower_agent/deployer.ex
··· 9 9 alias SowerClient.Orchestration.DeploymentProfile 10 10 alias SowerClient.Orchestration.SeedDeployment 11 11 alias SowerClient.Orchestration.SeedDeploymentResult 12 + alias SowerClient.Orchestration.SeedDeploymentStatus 12 13 13 14 def run(%Deployment{} = deployment) do 14 15 run_with_opts(deployment, upgrade_opts: [], reboot_opts: []) ··· 74 75 report_seed_result_fun = 75 76 Keyword.get(opts, :report_seed_result_fun, &report_seed_result/4) 76 77 78 + report_seed_status_fun = 79 + Keyword.get(opts, :report_seed_status_fun, &report_seed_status/3) 80 + 77 81 async_stream_fun.(deployment.seed_deployments, fn %{seed: seed} = seed_deploy -> 78 82 Logger.debug( 79 83 msg: "Realizing seed", ··· 83 87 artifact: seed.artifact 84 88 ) 85 89 90 + report_seed_status_fun.(deployment, seed, :downloading) 86 91 realize_seed_fun.(seed_deploy) 87 92 end) 88 93 |> async_stream_fun.(fn ··· 105 110 activation_args: get_in(profile.activation_args) 106 111 ) 107 112 113 + report_seed_status_fun.(deployment, seed, :activating) 108 114 result = activate_seed_fun.(seed, profile) 109 115 110 116 case result do ··· 115 121 seed_sid: seed.sid 116 122 ) 117 123 124 + report_seed_status_fun.(deployment, seed, :completed) 118 125 report_seed_result_fun.(deployment, seed, :success, preamble ++ output) 119 126 120 127 {:error, _code, output} -> ··· 402 409 true -> 403 410 nil 404 411 end 412 + end 413 + 414 + defp report_seed_status(%Deployment{} = deployment, seed, status) do 415 + seed_status = 416 + SeedDeploymentStatus.cast!(%{ 417 + deployment_sid: deployment.sid, 418 + seed_sid: seed.sid, 419 + status: status 420 + }) 421 + 422 + Client.cast(:seed_status, seed_status) 405 423 end 406 424 407 425 defp report_seed_result(%Deployment{} = deployment, seed, result, output_lines) do
+7
apps/sower_agent/test/sower_agent/deployer_test.exs
··· 138 138 {:ok, []} 139 139 end, 140 140 activation_enabled_fun: fn -> true end, 141 + report_seed_status_fun: fn _, _, _ -> :ok end, 141 142 report_seed_result_fun: fn _, _, _, _ -> :ok end 142 143 ) == :ok 143 144 ··· 155 156 end, 156 157 reboot_fun: fn _ -> flunk("reboot should not be requested") end, 157 158 activation_enabled_fun: fn -> true end, 159 + report_seed_status_fun: fn _, _, _ -> :ok end, 158 160 report_seed_result_fun: fn _, _, _, _ -> :ok end 159 161 ) == :ok 160 162 ··· 174 176 {:ok, ["ok"]} 175 177 end, 176 178 activation_enabled_fun: fn -> true end, 179 + report_seed_status_fun: fn _, _, _ -> :ok end, 177 180 report_seed_result_fun: fn _, _, _, _ -> :ok end 178 181 ) == :ok 179 182 ··· 287 290 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy} end, 288 291 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 289 292 activate_seed_fun: fn _seed, _profile -> {:error, :activator_unavailable} end, 293 + report_seed_status_fun: fn _, _, _ -> :ok end, 290 294 report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 291 295 send(test_pid, {:seed_result, result, output_lines}) 292 296 end ··· 318 322 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy} end, 319 323 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 320 324 activate_seed_fun: fn _seed, _profile -> {:ok, ["activation complete"]} end, 325 + report_seed_status_fun: fn _, _, _ -> :ok end, 321 326 report_seed_result_fun: fn _deployment, _seed, result, _output_lines -> 322 327 send(test_pid, {:seed_result, result}) 323 328 end ··· 347 352 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy} end, 348 353 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 349 354 activate_seed_fun: fn _seed, _profile -> {:error, :cmd_not_found} end, 355 + report_seed_status_fun: fn _, _, _ -> :ok end, 350 356 report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 351 357 send(test_pid, {:seed_result, result, output_lines}) 352 358 end ··· 517 523 Keyword.get(opts, :activate_seed_fun, fn _seed, _profile -> 518 524 {:ok, ["activation output"]} 519 525 end), 526 + report_seed_status_fun: fn _, _, _ -> :ok end, 520 527 report_seed_result_fun: fn _deployment, _seed, _result, output_lines -> 521 528 send(test_pid, {:seed_result_lines, output_lines}) 522 529 end
+2
apps/sower_client/lib/sower_client.ex
··· 18 18 SowerClient.Orchestration.DeploymentProfile, 19 19 SowerClient.Orchestration.DeploymentResult, 20 20 SowerClient.Orchestration.DeploymentRequest, 21 + SowerClient.Orchestration.DeploymentStatus, 21 22 SowerClient.Orchestration.SeedDeployment, 22 23 SowerClient.Orchestration.SeedDeploymentResult, 24 + SowerClient.Orchestration.SeedDeploymentStatus, 23 25 SowerClient.Orchestration.Subscription, 24 26 SowerClient.Orchestration.SubscriptionSync, 25 27 SowerClient.Storage.PresignedUploadReply,
+21
apps/sower_client/lib/sower_client/orchestration/deployment_status.ex
··· 1 + defmodule SowerClient.Orchestration.DeploymentStatus do 2 + use SowerClient.Schema 3 + use SowerClient.ChannelMessage, event: "deployment:status" 4 + 5 + OpenApiSpex.schema(%{ 6 + title: "DeploymentStatus", 7 + type: :object, 8 + properties: %{ 9 + deployment_sid: %Schema{ 10 + type: :string, 11 + description: "deployment sid which is being reported on" 12 + }, 13 + status: %Schema{ 14 + type: :string, 15 + description: "deployment-level status", 16 + enum: [:acknowledged] 17 + } 18 + }, 19 + required: [:deployment_sid, :status] 20 + }) 21 + end
+25
apps/sower_client/lib/sower_client/orchestration/seed_deployment_status.ex
··· 1 + defmodule SowerClient.Orchestration.SeedDeploymentStatus do 2 + use SowerClient.Schema 3 + use SowerClient.ChannelMessage, event: "deployment:seed_status" 4 + 5 + OpenApiSpex.schema(%{ 6 + title: "SeedDeploymentStatus", 7 + type: :object, 8 + properties: %{ 9 + deployment_sid: %Schema{ 10 + type: :string, 11 + description: "deployment sid which is being reported on" 12 + }, 13 + seed_sid: %Schema{ 14 + type: :string, 15 + description: "seed sid which is being reported on" 16 + }, 17 + status: %Schema{ 18 + type: :string, 19 + description: "seed deployment progress status", 20 + enum: [:downloading, :activating, :completed] 21 + } 22 + }, 23 + required: [:deployment_sid, :seed_sid, :status] 24 + }) 25 + end