Show recent deployment history on /status

9df580910b9e · Devin AI · · parent d3aa922fd9f8

Show recent deployment history on /status

Each deployment receipt now records its lane (direct load, relup, or
rolling replacement) and the public status projection exposes the
short revision, result, lane, completion time, and start-to-completion
duration for recent receipts, newest first. Classification-only
receipts stay distinguishable: they carry no lane and no duration.
Rolling replacements now measure elapsed time across the full
operation so their receipts carry a real duration.

The projection stays bounded and content-free, and the /status
LiveView refreshes the list when Forge records a new receipt.

Closes #33

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#33

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents/forge/deploy_receipt.ex
  • modified lib/openagents/forge/hot_loader.ex
  • modified lib/openagents/forge/rolling_replacement.ex
  • modified lib/openagents/forge/targets.ex
  • modified lib/openagents/network_status.ex
  • modified lib/openagents_web/live/network_status_live.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823042207_add_forge_deploys_deployment_type.exs
  • modified test/openagents/network_status_test.exs
  • modified test/openagents_web/live/network_status_live_test.exs

Diff

10 files changed, +271 -12

lib/openagents/forge/deploy_receipt.ex modified +8

@@ -13,6 +13,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

13 13
  @timestamps_opts [type: :utc_datetime_usec]
14 14
15 15
  @results ~w(live reverted needs_rolling_replace failed)
16
  @deployment_types ~w(direct_load relup rolling_replacement)
16 17
17 18
  schema "forge_deploys" do
18 19
    field :repo, :string

@@ -26,6 +27,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

26 27
    field :expected_nodes, {:array, :string}, default: []
27 28
    field :node_results, :map, default: %{}
28 29
    field :result, :string
30
    field :deployment_type, :string
29 31
    field :canary, :string
30 32
    field :error_code, :string
31 33
    field :rollback_verified, :boolean

@@ -38,6 +40,9 @@ defmodule OpenAgents.Forge.DeployReceipt do

38 40
  @doc "All deploy results."
39 41
  def results, do: @results
40 42
43
  @doc "All deployment types."
44
  def deployment_types, do: @deployment_types
45
41 46
  def changeset(receipt, attrs) do
42 47
    now = DateTime.utc_now()
43 48

@@ -60,6 +65,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

60 65
      :expected_nodes,
61 66
      :node_results,
62 67
      :result,
68
      :deployment_type,
63 69
      :canary,
64 70
      :error_code,
65 71
      :rollback_verified,

@@ -77,6 +83,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

77 83
      :completed_at
78 84
    ])
79 85
    |> validate_inclusion(:result, @results)
86
    |> validate_inclusion(:deployment_type, @deployment_types)
80 87
    |> validate_format(:sha, ~r/^[0-9a-f]{40}$/)
81 88
    |> validate_format(:artifact_digest, ~r/^[0-9a-f]{64}$/)
82 89
    |> validate_format(:manifest_digest, ~r/^[0-9a-f]{64}$/)

@@ -88,6 +95,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

88 95
    |> validate_node_results()
89 96
    |> unique_constraint(:deployment_id)
90 97
    |> check_constraint(:result, name: :forge_deploys_result)
98
    |> check_constraint(:deployment_type, name: :forge_deploys_deployment_type)
91 99
    |> check_constraint(:artifact_digest, name: :forge_deploys_artifact_digest)
92 100
    |> check_constraint(:manifest_digest, name: :forge_deploys_manifest_digest)
93 101
    |> check_constraint(:node_results, name: :forge_deploys_node_bounds)
lib/openagents/forge/hot_loader.ex modified +17 -4

@@ -99,7 +99,7 @@ defmodule OpenAgents.Forge.HotLoader do

99 99
      message = "hot_load_failed code=" <> OpenAgents.OperationalLog.code(error)
100 100
      Logger.error("forge_hot_load_failed code=#{OpenAgents.OperationalLog.code(error)}")
101 101
      advance(target_id, "failed", %{"error" => message})
102
      insert_receipt(repo, sha, target_id, modules, [], "failed", nil, nil)
102
      insert_receipt(repo, sha, target_id, modules, [], "failed", "direct_load", nil, nil)
103 103
      broadcast_deploy(repo, sha, "failed")
104 104
  catch
105 105
    :refused -> :ok

@@ -133,7 +133,8 @@ defmodule OpenAgents.Forge.HotLoader do

133 133
           "reasons" => reasons
134 134
         }) do
135 135
      :ok ->
136
        insert_receipt(repo, sha, target_id, modules, [], "needs_rolling_replace", nil, nil)
136
        # Classification only — no deployment ran, so no deployment_type.
137
        insert_receipt(repo, sha, target_id, modules, [], "needs_rolling_replace", nil, nil, nil)
137 138
        broadcast_deploy(repo, sha, "needs_rolling_replace")
138 139
139 140
      :error ->

@@ -148,7 +149,7 @@ defmodule OpenAgents.Forge.HotLoader do

148 149
    message = "artifact_verification_failed code=" <> OpenAgents.OperationalLog.code(reason)
149 150
    Logger.error(message)
150 151
    advance(target_id, "failed", %{"error" => message})
151
    insert_receipt(repo, sha, target_id, modules, [], "failed", nil, nil)
152
    insert_receipt(repo, sha, target_id, modules, [], "failed", "direct_load", nil, nil)
152 153
    broadcast_deploy(repo, sha, "failed")
153 154
  end
154 155

@@ -312,7 +313,17 @@ defmodule OpenAgents.Forge.HotLoader do

312 313
    end
313 314
  end
314 315
315
  defp insert_receipt(repo, sha, target_id, modules, nodes, result, canary, push_ms) do
316
  defp insert_receipt(
317
         repo,
318
         sha,
319
         target_id,
320
         modules,
321
         nodes,
322
         result,
323
         deployment_type,
324
         canary,
325
         push_ms
326
       ) do
316 327
    %DeployReceipt{}
317 328
    |> DeployReceipt.changeset(%{
318 329
      repo: repo,

@@ -321,6 +332,7 @@ defmodule OpenAgents.Forge.HotLoader do

321 332
      modules: modules,
322 333
      nodes: nodes,
323 334
      result: result,
335
      deployment_type: deployment_type,
324 336
      canary: canary,
325 337
      push_to_live_ms: push_ms
326 338
    })

@@ -340,6 +352,7 @@ defmodule OpenAgents.Forge.HotLoader do

340 352
      nodes: outcome.nodes,
341 353
      expected_nodes: outcome.expected_nodes,
342 354
      node_results: outcome.node_results,
355
      deployment_type: "direct_load",
343 356
      canary: canary || outcome.canary,
344 357
      error_code: outcome.error_code,
345 358
      rollback_verified: outcome.rollback_verified,
lib/openagents/forge/rolling_replacement.ex modified +9 -1

@@ -18,14 +18,22 @@ defmodule OpenAgents.Forge.RollingReplacement do

18 18
19 19
  @doc "Roll an immutable image across the exact expected node set."
20 20
  def run(request, opts \\ []) do
21
    started = System.monotonic_time(:millisecond)
22
21 23
    with :ok <- validate_request(request),
22 24
         {:ok, _receipt} <- gate_verify(request.sha, opts),
23 25
         {:ok, provider} <- provider(opts),
24 26
         :ok <- initial_membership(request, provider, opts) do
25
      replace_nodes(request.expected_nodes, request, provider, opts, %{})
27
      request.expected_nodes
28
      |> replace_nodes(request, provider, opts, %{})
29
      |> put_duration(started)
26 30
    end
27 31
  end
28 32
33
  defp put_duration({verdict, result}, started) when is_map(result) do
34
    {verdict, Map.put(result, :duration_ms, System.monotonic_time(:millisecond) - started)}
35
  end
36
29 37
  defp replace_nodes([], request, _provider, _opts, results) do
30 38
    {:ok, public_result(request, "live", results, nil, nil)}
31 39
  end
lib/openagents/forge/targets.ex modified +12 -1

@@ -325,6 +325,7 @@ defmodule OpenAgents.Forge.Targets do

325 325
            artifact_digest: relup.artifact_digest,
326 326
            completed_at: now,
327 327
            deployment_id: deployment_id,
328
            deployment_type: "relup",
328 329
            error_code: relup.error_code,
329 330
            expected_nodes: relup.expected_nodes,
330 331
            manifest_digest: relup.package_manifest_digest,

@@ -437,6 +438,7 @@ defmodule OpenAgents.Forge.Targets do

437 438
            artifact_digest: build.artifact_digest,
438 439
            completed_at: now,
439 440
            deployment_id: deployment_id,
441
            deployment_type: "rolling_replacement",
440 442
            error_code: rolling.error_code,
441 443
            expected_nodes: rolling.expected_nodes,
442 444
            manifest_digest: manifest_digest,

@@ -448,7 +450,11 @@ defmodule OpenAgents.Forge.Targets do

448 450
            rollback_verified:
449 451
              rolling.status == "failed" and rolling.recovery == "last_known_good_restored",
450 452
            sha: target.sha,
451
            started_at: now,
453
            started_at:
454
              if(is_integer(rolling.duration_ms),
455
                do: DateTime.add(now, -rolling.duration_ms, :millisecond),
456
                else: now
457
              ),
452 458
            target_id: target.id
453 459
          }
454 460

@@ -548,6 +554,7 @@ defmodule OpenAgents.Forge.Targets do

548 554
    node_results = result_value(result, :node_results)
549 555
    error_code = result_value(result, :error_code)
550 556
    recovery = result_value(result, :recovery)
557
    duration_ms = result_value(result, :duration_ms)
551 558
552 559
    cond do
553 560
      schema != "openagents.rolling-replacement.v1" ->

@@ -568,6 +575,9 @@ defmodule OpenAgents.Forge.Targets do

568 575
      not valid_node_results?(node_results) ->
569 576
        {:error, :invalid_rolling_result}
570 577
578
      not (is_nil(duration_ms) or valid_duration?(duration_ms)) ->
579
        {:error, :invalid_rolling_result}
580
571 581
      status == "live" and
572 582
          (Enum.any?(node_results, fn {_node, node_status} -> node_status != "ready" end) or
573 583
             not is_nil(error_code) or not is_nil(recovery)) ->

@@ -582,6 +592,7 @@ defmodule OpenAgents.Forge.Targets do

582 592
      true ->
583 593
        {:ok,
584 594
         %{
595
           duration_ms: duration_ms,
585 596
           error_code: error_code,
586 597
           expected_nodes: node_results |> Map.keys() |> Enum.sort(),
587 598
           image_digest: image_digest,
lib/openagents/network_status.ex modified +17

@@ -173,16 +173,33 @@ defmodule OpenAgents.NetworkStatus do

173 173
    }
174 174
  end
175 175
176
  # Deployment history stays content-free: short sha, result, lane, module
177
  # count, and timings only. `duration_ms` measures deployment start through
178
  # completion; classification-only receipts (needs_rolling_replace) ran no
179
  # deployment, so they carry no type and no duration.
176 180
  defp public_deploy(deploy) do
177 181
    %{
178 182
      "sha" => short_sha(deploy.sha),
179 183
      "result" => deploy.result,
184
      "type" => deploy.deployment_type,
180 185
      "modules" => length(deploy.modules),
181 186
      "push_to_live_ms" => deploy.push_to_live_ms,
187
      "duration_ms" => deploy_duration_ms(deploy),
188
      "completed_at" => iso8601_or_nil(deploy.completed_at),
182 189
      "at" => DateTime.to_iso8601(deploy.inserted_at)
183 190
    }
184 191
  end
185 192
193
  defp deploy_duration_ms(%{result: "needs_rolling_replace"}), do: nil
194
195
  defp deploy_duration_ms(%{started_at: %DateTime{} = started, completed_at: %DateTime{} = done}),
196
    do: DateTime.diff(done, started, :millisecond)
197
198
  defp deploy_duration_ms(_deploy), do: nil
199
200
  defp iso8601_or_nil(%DateTime{} = at), do: DateTime.to_iso8601(at)
201
  defp iso8601_or_nil(_at), do: nil
202
186 203
  defp short_sha(sha) when is_binary(sha), do: String.slice(sha, 0, 12)
187 204
  defp short_sha(_sha), do: nil
188 205
lib/openagents_web/live/network_status_live.ex modified +35 -6

@@ -287,6 +287,23 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

287 287
  defp deploy_result_variant("needs_rolling_replace"), do: :warning
288 288
  defp deploy_result_variant(_result), do: :danger
289 289
290
  # Classification-only receipts ran no deployment; a receipt without a
291
  # recorded lane (rows older than the column) renders an honest "unknown".
292
  defp deploy_type_text(%{"result" => "needs_rolling_replace"}), do: "classification only"
293
  defp deploy_type_text(%{"type" => "direct_load"}), do: "direct load"
294
  defp deploy_type_text(%{"type" => "relup"}), do: "relup"
295
  defp deploy_type_text(%{"type" => "rolling_replacement"}), do: "rolling replacement"
296
  defp deploy_type_text(_deploy), do: "unknown lane"
297
298
  defp completed_text(iso) when is_binary(iso) do
299
    case DateTime.from_iso8601(iso) do
300
      {:ok, at, _offset} -> Calendar.strftime(at, "%Y-%m-%d %H:%M:%S UTC")
301
      _error -> "—"
302
    end
303
  end
304
305
  defp completed_text(_iso), do: "—"
306
290 307
  defp overall_badge_variant("ok"), do: :success
291 308
  defp overall_badge_variant("rolling"), do: :info
292 309
  defp overall_badge_variant("recovering"), do: :warning

@@ -493,16 +510,28 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

493 510
              deploy policy: direct hot load → relup → rolling replacement
494 511
            </p>
495 512
496
            <div :if={@projection["forge"]["recent_deploys"] != []} class="status-forge__history">
497
              <h3>Recent deploys</h3>
498
              <ul class="status-events">
499
                <li :for={deploy <- @projection["forge"]["recent_deploys"]}>
513
            <div id="status-forge-history" class="status-forge__history">
514
              <h3>Recent deployments</h3>
515
              <.empty
516
                :if={@projection["forge"]["recent_deploys"] == []}
517
                id="status-forge-no-deploys"
518
                title="No deployment receipts yet"
519
              >
520
                Deployment receipts appear here newest first as Forge records them.
521
              </.empty>
522
              <ul :if={@projection["forge"]["recent_deploys"] != []} class="status-events">
523
                <li
524
                  :for={deploy <- @projection["forge"]["recent_deploys"]}
525
                  data-deploy-type={deploy["type"] || "none"}
526
                >
500 527
                  <span class="status-events__stamp"><code>{deploy["sha"]}</code></span>
501 528
                  <.badge variant={deploy_result_variant(deploy["result"])}>
502 529
                    {deploy["result"]}
503 530
                  </.badge>
504
                  {deploy["modules"]} module{if deploy["modules"] == 1, do: "", else: "s"} · push→live {ms_text(
505
                    deploy["push_to_live_ms"]
531
                  {deploy_type_text(deploy)} · {deploy["modules"]} module{if deploy["modules"] == 1,
532
                    do: "",
533
                    else: "s"} · completed {completed_text(deploy["completed_at"])} · took {ms_text(
534
                    deploy["duration_ms"]
506 535
                  )}
507 536
                </li>
508 537
              </ul>
priv/migration_lineages/prior-2026-08-19.json modified +1

@@ -237,6 +237,7 @@

237 237
    20260823021021,
238 238
    20260823034851,
239 239
    20260823040635,
240
    20260823042207,
240 241
    20260823043000
241 242
  ],
242 243
  "required_tables": [
priv/repo/migrations/20260823042207_add_forge_deploys_deployment_type.exs added +16

@@ -0,0 +1,16 @@

1
defmodule OpenAgents.Repo.Migrations.AddForgeDeploysDeploymentType do
2
  use Ecto.Migration
3
4
  # Nullable on purpose: receipts are immutable (no backfill), so rows written
5
  # before this column render an honest "unknown" type rather than a guess.
6
  def change do
7
    alter table(:forge_deploys) do
8
      add :deployment_type, :string
9
    end
10
11
    create constraint(:forge_deploys, :forge_deploys_deployment_type,
12
             check:
13
               "deployment_type IS NULL OR deployment_type IN ('direct_load', 'relup', 'rolling_replacement')"
14
           )
15
  end
16
end
test/openagents/network_status_test.exs modified +76

@@ -200,6 +200,82 @@ defmodule OpenAgents.NetworkStatusTest do

200 200
      assert NetworkStatus.projection(refresh: true)["forge"]["state"] == "off"
201 201
    end
202 202
203
    test "recent deployments expose lane, completion time, and duration, newest first" do
204
      sha = String.duplicate("c", 40)
205
206
      target =
207
        %Target{}
208
        |> Target.changeset(%{
209
          repo: "openagents.com",
210
          sha: sha,
211
          promoted_by: "operator:1",
212
          status: "live"
213
        })
214
        |> Repo.insert!()
215
216
      now = DateTime.utc_now()
217
218
      rows = [
219
        # Oldest: a pre-column legacy row (no lane recorded).
220
        %{result: "live", started_at: DateTime.add(now, -100, :millisecond), completed_at: now},
221
        # A classification-only receipt — no deployment ran.
222
        %{result: "needs_rolling_replace"},
223
        %{
224
          result: "live",
225
          deployment_type: "direct_load",
226
          started_at: DateTime.add(now, -1_500, :millisecond),
227
          completed_at: now
228
        },
229
        %{
230
          result: "failed",
231
          deployment_type: "relup",
232
          started_at: DateTime.add(now, -2_000, :millisecond),
233
          completed_at: now
234
        },
235
        # Newest: a rolling replacement.
236
        %{
237
          result: "live",
238
          deployment_type: "rolling_replacement",
239
          started_at: DateTime.add(now, -60_000, :millisecond),
240
          completed_at: now
241
        }
242
      ]
243
244
      for row <- rows do
245
        %DeployReceipt{}
246
        |> DeployReceipt.changeset(
247
          Map.merge(%{repo: "openagents.com", sha: sha, target_id: target.id}, row)
248
        )
249
        |> Repo.insert!()
250
251
        # Distinct inserted_at ordering under usec timestamps.
252
        Process.sleep(2)
253
      end
254
255
      deploys = NetworkStatus.projection(refresh: true)["forge"]["recent_deploys"]
256
257
      assert [rolling, relup, direct, classification, legacy] = deploys
258
259
      assert %{"type" => "rolling_replacement", "result" => "live", "duration_ms" => 60_000} =
260
               rolling
261
262
      assert %{"type" => "relup", "result" => "failed", "duration_ms" => 2_000} = relup
263
      assert %{"type" => "direct_load", "result" => "live", "duration_ms" => 1_500} = direct
264
265
      # Classification-only receipts stay distinguishable: no lane, no
266
      # duration, and their own result.
267
      assert %{"type" => nil, "result" => "needs_rolling_replace", "duration_ms" => nil} =
268
               classification
269
270
      # Rows older than the lane column degrade honestly to a nil type while
271
      # keeping their measured duration.
272
      assert %{"type" => nil, "result" => "live", "duration_ms" => 100} = legacy
273
274
      for deploy <- deploys do
275
        assert {:ok, _at, _offset} = DateTime.from_iso8601(deploy["completed_at"])
276
      end
277
    end
278
203 279
    test "loop metrics: last is newest, median over live deploys only" do
204 280
      sha = String.duplicate("b", 40)
205 281
test/openagents_web/live/network_status_live_test.exs modified +80

@@ -132,6 +132,8 @@ defmodule OpenAgentsWeb.NetworkStatusLiveTest do

132 132
      assert html =~ "Rapid deploys"
133 133
      assert has_element?(view, "#status-forge-state")
134 134
      assert html =~ "No deploys yet"
135
      assert has_element?(view, "#status-forge-no-deploys")
136
      assert html =~ "No deployment receipts yet"
135 137
    end
136 138
137 139
    test "renders the pipeline position for an advancing target", %{conn: conn} do

@@ -174,6 +176,84 @@ defmodule OpenAgentsWeb.NetworkStatusLiveTest do

174 176
      assert render(view) =~ "hot deploy #{String.duplicate("c", 12)}: live"
175 177
    end
176 178
179
    test "renders deployment history with lane, completion time, and duration", %{conn: conn} do
180
      target = seed_target("live")
181
      now = DateTime.utc_now()
182
183
      receipts = [
184
        # Oldest: classification only.
185
        %{result: "needs_rolling_replace"},
186
        %{
187
          result: "live",
188
          deployment_type: "direct_load",
189
          started_at: DateTime.add(now, -13_242, :millisecond),
190
          completed_at: now
191
        },
192
        # Newest: a settled relup.
193
        %{
194
          result: "live",
195
          deployment_type: "relup",
196
          started_at: DateTime.add(now, -2_500, :millisecond),
197
          completed_at: now
198
        }
199
      ]
200
201
      for receipt <- receipts do
202
        %DeployReceipt{}
203
        |> DeployReceipt.changeset(
204
          Map.merge(%{repo: "openagents.com", sha: target.sha, target_id: target.id}, receipt)
205
        )
206
        |> Repo.insert!()
207
208
        # Distinct inserted_at ordering under usec timestamps.
209
        Process.sleep(2)
210
      end
211
212
      {:ok, view, html} = live(conn, ~p"/status")
213
214
      assert html =~ "Recent deployments"
215
      assert has_element?(view, "#status-forge-history li[data-deploy-type='direct_load']")
216
      assert has_element?(view, "#status-forge-history li[data-deploy-type='relup']")
217
      assert has_element?(view, "#status-forge-history li[data-deploy-type='none']")
218
      assert html =~ "direct load"
219
      assert html =~ "classification only"
220
      assert html =~ "took 13.2s"
221
      assert html =~ "took 2.5s"
222
      assert html =~ "completed #{Calendar.strftime(now, "%Y-%m-%d")}"
223
224
      # Newest first: the relup receipt renders before the direct load.
225
      {relup_at, _} = :binary.match(html, ~s(data-deploy-type="relup"))
226
      {direct_at, _} = :binary.match(html, ~s(data-deploy-type="direct_load"))
227
      assert relup_at < direct_at
228
    end
229
230
    test "the deployment list updates when Forge records a new receipt", %{conn: conn} do
231
      target = seed_target("live", sha: String.duplicate("d", 40))
232
      {:ok, view, html} = live(conn, ~p"/status")
233
234
      refute html =~ ~s(data-deploy-type="direct_load")
235
236
      %DeployReceipt{}
237
      |> DeployReceipt.changeset(%{
238
        repo: "openagents.com",
239
        sha: target.sha,
240
        target_id: target.id,
241
        result: "live",
242
        deployment_type: "direct_load"
243
      })
244
      |> Repo.insert!()
245
246
      Phoenix.PubSub.broadcast(
247
        OpenAgents.PubSub,
248
        "forge:deploys",
249
        {:forge_deploy, %{repo: "openagents.com", sha: target.sha, result: "live"}}
250
      )
251
252
      html = render(view)
253
      assert html =~ ~s(data-deploy-type="direct_load")
254
      assert html =~ "direct load"
255
    end
256
177 257
    test "the page is content-free: no operator identity, no module names", %{conn: conn} do
178 258
      target = seed_target("live", promoted_by: "operator:55554444")
179 259

This page updates live while a promote is in flight · changelog