Show active SCVs on the public status page

2d12d621c1d9 · AtlantisPleb · · parent fbf9cccdb9a5

Show active SCVs on the public status page

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/network_status.ex
  • modified lib/openagents/runtime_supervisor.ex
  • added lib/openagents/scv/activity.ex
  • modified lib/openagents_web/live/network_status_live.ex
  • modified test/openagents/network_status_test.exs
  • added test/openagents/scv/activity_test.exs
  • modified test/openagents_web/live/network_status_live_test.exs

Diff

7 files changed, +510 -2

lib/openagents/network_status.ex modified +5 -2

@@ -8,8 +8,10 @@ defmodule OpenAgents.NetworkStatus do

8 8
  `:erpc` fan-out to its peers (short timeout each): cluster membership and
9 9
  quorum (`OpenAgents.Cluster`), Raft membership (`OpenAgents.Cluster.Ra`), and per-node
10 10
  release version, hot-load revision (`OpenAgents.BuildInfo`), relup marker, and
11
  uptime. Counts only, never content: connected controller machines and
12
  active work jobs appear as integers — no names, goals, ids, or addresses.
11
  uptime. Public SCV activity includes only a pseudonymous label, lifecycle
12
  state, admitted tool category, and normalized action. Connected controller
13
  machines and active work jobs remain counts only. No names, goals, internal
14
  ids, addresses, prompts, repository paths, tool output, or reports appear.
13 15
14 16
  Honesty rules the shape:
15 17
  - A peer that does not answer in time renders `"unreachable"` — the page

@@ -112,6 +114,7 @@ defmodule OpenAgents.NetworkStatus do

112 114
      },
113 115
      "nodes" => nodes,
114 116
      "counts" => counts(),
117
      "scvs" => safely(fn -> OpenAgents.SCV.Activity.public_projection() end) || [],
115 118
      "forge" => forge_section(),
116 119
      "generated_at" => DateTime.utc_now() |> DateTime.to_iso8601()
117 120
    }
lib/openagents/runtime_supervisor.ex modified +1

@@ -32,6 +32,7 @@ defmodule OpenAgents.RuntimeSupervisor do

32 32
        {DynamicSupervisor, strategy: :one_for_one, name: OpenAgents.TurnSupervisor},
33 33
        {Registry, keys: :unique, name: OpenAgents.VoiceSessionRegistry},
34 34
        {DynamicSupervisor, strategy: :one_for_one, name: OpenAgents.VoiceSessionSupervisor},
35
        OpenAgents.SCV.Activity,
35 36
        OpenAgents.Leaderboard.Server,
36 37
        {Task.Supervisor, name: OpenAgents.ProviderTaskSupervisor},
37 38
        {Task.Supervisor, name: OpenAgents.ToolTaskSupervisor},
lib/openagents/scv/activity.ex added +293

@@ -0,0 +1,293 @@

1
defmodule OpenAgents.SCV.Activity do
2
  @moduledoc """
3
  Publishes a bounded, content-free projection of active SCV runs.
4
5
  The projection accepts only normalized SCV lifecycle metadata. It replaces
6
  the internal run ID with a one-way public label and derives activity text
7
  from admitted event and tool names. Objectives, repository paths, arguments,
8
  tool output, report text, credentials, and diagnostic content never enter the
9
  public state.
10
  """
11
12
  use GenServer
13
14
  @telemetry_event [:openagents, :scv, :event]
15
  @public_topic "scv_activity:public"
16
  @replication_topic "scv_activity:replication"
17
  @maximum_entries 32
18
  @default_expire_after_ms :timer.seconds(30)
19
  @default_prune_interval_ms :timer.seconds(5)
20
  @admitted_tools ~w(apply_patch bash edit glob grep list read todowrite write)
21
22
  @type public_entry :: %{String.t() => String.t() | float()}
23
24
  @spec start_link(keyword()) :: GenServer.on_start()
25
  def start_link(options) do
26
    case Keyword.get(options, :name, __MODULE__) do
27
      nil -> GenServer.start_link(__MODULE__, options)
28
      name -> GenServer.start_link(__MODULE__, options, name: name)
29
    end
30
  end
31
32
  @doc "Observes one versioned SCV event."
33
  @spec observe(map(), GenServer.server()) :: :ok
34
  def observe(event, server \\ __MODULE__), do: GenServer.cast(server, {:observe, event})
35
36
  @doc "Returns the bounded public projection, newest activity first."
37
  @spec public_projection(GenServer.server()) :: [public_entry()]
38
  def public_projection(server \\ __MODULE__) do
39
    GenServer.call(server, :public_projection)
40
  catch
41
    :exit, _reason -> []
42
  end
43
44
  @doc "Subscribes the caller to `{:scv_activity, entries}` updates."
45
  @spec subscribe(module()) :: :ok | {:error, term()}
46
  def subscribe(pubsub \\ OpenAgents.PubSub),
47
    do: Phoenix.PubSub.subscribe(pubsub, @public_topic)
48
49
  @doc false
50
  def handle_telemetry(_event_name, _measurements, metadata, activity) do
51
    send(activity, {:telemetry_event, metadata})
52
  end
53
54
  @impl true
55
  def init(options) do
56
    pubsub = Keyword.get(options, :pubsub, OpenAgents.PubSub)
57
    telemetry? = Keyword.get(options, :telemetry, true)
58
    expire_after_ms = Keyword.get(options, :expire_after_ms, @default_expire_after_ms)
59
    prune_interval_ms = Keyword.get(options, :prune_interval_ms, @default_prune_interval_ms)
60
61
    if pubsub, do: Phoenix.PubSub.subscribe(pubsub, @replication_topic)
62
    schedule_prune(prune_interval_ms)
63
64
    handler_id = {__MODULE__, self()}
65
66
    if telemetry? do
67
      :ok =
68
        :telemetry.attach(handler_id, @telemetry_event, &__MODULE__.handle_telemetry/4, self())
69
    end
70
71
    {:ok,
72
     %{
73
       entries: %{},
74
       expire_after_ms: expire_after_ms,
75
       handler_id: if(telemetry?, do: handler_id),
76
       prune_interval_ms: prune_interval_ms,
77
       pubsub: pubsub
78
     }}
79
  end
80
81
  @impl true
82
  def handle_call(:public_projection, _from, state) do
83
    {:reply, project(state.entries), state}
84
  end
85
86
  @impl true
87
  def handle_cast({:observe, event}, state) do
88
    case public_command(event) do
89
      :ignore ->
90
        {:noreply, state}
91
92
      command ->
93
        state = apply_and_publish(state, command)
94
        replicate(state.pubsub, command)
95
        {:noreply, state}
96
    end
97
  end
98
99
  @impl true
100
  def handle_info({:telemetry_event, event}, state) do
101
    case public_command(event) do
102
      :ignore ->
103
        {:noreply, state}
104
105
      command ->
106
        state = apply_and_publish(state, command)
107
        replicate(state.pubsub, command)
108
        {:noreply, state}
109
    end
110
  end
111
112
  def handle_info({:scv_activity_replication, origin, _command}, state)
113
      when origin == self(),
114
      do: {:noreply, state}
115
116
  def handle_info({:scv_activity_replication, _origin, command}, state) do
117
    {:noreply, apply_and_publish(state, command)}
118
  end
119
120
  def handle_info(:prune, state) do
121
    entries = prune_expired(state.entries)
122
123
    if entries != state.entries do
124
      broadcast_public(state.pubsub, project(entries))
125
    end
126
127
    schedule_prune(state.prune_interval_ms)
128
    {:noreply, %{state | entries: entries}}
129
  end
130
131
  def handle_info(_message, state), do: {:noreply, state}
132
133
  @impl true
134
  def terminate(_reason, %{handler_id: nil}), do: :ok
135
136
  def terminate(_reason, %{handler_id: handler_id}) do
137
    :telemetry.detach(handler_id)
138
    :ok
139
  end
140
141
  defp public_command(event) when is_map(event) do
142
    with "openagents.scv.event.v1" <- value(event, :schema),
143
         run_id when is_binary(run_id) <- value(event, :run_id),
144
         {:ok, _uuid} <- Ecto.UUID.cast(run_id),
145
         type when is_binary(type) <- value(event, :type) do
146
      id = public_id(run_id)
147
148
      case type do
149
        terminal when terminal in ["process_finished", "run_finished"] ->
150
          {:delete, id}
151
152
        "heartbeat" ->
153
          {:touch, id, base_entry(id, "Working within its resource budget")}
154
155
        "run_preparing" ->
156
          {:upsert, id, base_entry(id, "Preparing an admitted SCV run")}
157
158
        "process_starting" ->
159
          {:upsert, id, base_entry(id, "Starting its coding runtime")}
160
161
        "process_started" ->
162
          {:upsert, id, base_entry(id, "Coding runtime started")}
163
164
        "opencode_event" ->
165
          {:upsert, id, open_code_entry(id, event)}
166
167
        _other ->
168
          :ignore
169
      end
170
    else
171
      _invalid -> :ignore
172
    end
173
  end
174
175
  defp public_command(_event), do: :ignore
176
177
  defp open_code_entry(id, event) do
178
    event_type = value(event, :event_type)
179
    tool = admitted_tool(value(event, :tool))
180
181
    {text, tool} =
182
      case {event_type, tool} do
183
        {"tool_use", "read"} -> {"Reading repository context", "read"}
184
        {"tool_use", "grep"} -> {"Searching repository context", "grep"}
185
        {"tool_use", "glob"} -> {"Mapping repository files", "glob"}
186
        {"tool_use", "list"} -> {"Listing repository context", "list"}
187
        {"tool_use", "edit"} -> {"Applying a bounded code edit", "edit"}
188
        {"tool_use", "apply_patch"} -> {"Applying a bounded code patch", "apply_patch"}
189
        {"tool_use", "write"} -> {"Writing an admitted workspace file", "write"}
190
        {"tool_use", "bash"} -> {"Running an admitted command", "bash"}
191
        {"tool_use", "todowrite"} -> {"Updating its work plan", "todowrite"}
192
        {"step_start", _tool} -> {"Starting its next model step", nil}
193
        {"step_finish", _tool} -> {"Finished a model step", nil}
194
        {"text", _tool} -> {"Preparing its bounded report", nil}
195
        {_event_type, _tool} -> {"Working on its admitted objective", nil}
196
      end
197
198
    id
199
    |> base_entry(text)
200
    |> maybe_put_tool(tool)
201
  end
202
203
  defp admitted_tool(tool) when tool in @admitted_tools, do: tool
204
  defp admitted_tool(_tool), do: nil
205
206
  defp base_entry(id, text) do
207
    %{
208
      "id" => id,
209
      "label" => id |> String.replace_prefix("scv-", "SCV ") |> String.upcase(),
210
      "status" => "running",
211
      "weight" => 0.4,
212
      "text" => text
213
    }
214
  end
215
216
  defp maybe_put_tool(entry, nil), do: Map.delete(entry, "tool")
217
  defp maybe_put_tool(entry, tool), do: Map.put(entry, "tool", tool)
218
219
  defp apply_and_publish(state, command) do
220
    entries = apply_command(state.entries, command, state.expire_after_ms)
221
    entries = retain_latest(entries)
222
    broadcast_public(state.pubsub, project(entries))
223
    %{state | entries: entries}
224
  end
225
226
  defp apply_command(entries, {:delete, id}, _expire_after_ms), do: Map.delete(entries, id)
227
228
  defp apply_command(entries, {:upsert, id, public}, expire_after_ms) do
229
    Map.put(entries, id, timed_entry(public, expire_after_ms))
230
  end
231
232
  defp apply_command(entries, {:touch, id, public}, expire_after_ms) do
233
    current = Map.get(entries, id, %{public: public})
234
    Map.put(entries, id, timed_entry(current.public, expire_after_ms))
235
  end
236
237
  defp timed_entry(public, expire_after_ms) do
238
    %{
239
      expires_at: monotonic_ms() + expire_after_ms,
240
      order: next_order(),
241
      public: public
242
    }
243
  end
244
245
  defp prune_expired(entries) do
246
    now = monotonic_ms()
247
    Map.reject(entries, fn {_id, entry} -> entry.expires_at <= now end)
248
  end
249
250
  defp retain_latest(entries) when map_size(entries) <= @maximum_entries, do: entries
251
252
  defp retain_latest(entries) do
253
    entries
254
    |> Enum.sort_by(fn {_id, entry} -> entry.order end, :desc)
255
    |> Enum.take(@maximum_entries)
256
    |> Map.new()
257
  end
258
259
  defp project(entries) do
260
    entries
261
    |> Map.values()
262
    |> Enum.sort_by(& &1.order, :desc)
263
    |> Enum.map(& &1.public)
264
  end
265
266
  defp replicate(nil, _command), do: :ok
267
268
  defp replicate(pubsub, command) do
269
    Phoenix.PubSub.broadcast(pubsub, @replication_topic, {
270
      :scv_activity_replication,
271
      self(),
272
      command
273
    })
274
  end
275
276
  defp broadcast_public(nil, _projection), do: :ok
277
278
  defp broadcast_public(pubsub, projection) do
279
    Phoenix.PubSub.broadcast(pubsub, @public_topic, {:scv_activity, projection})
280
  end
281
282
  defp public_id(run_id) do
283
    digest = :crypto.hash(:sha256, run_id) |> Base.encode16(case: :lower) |> String.slice(0, 8)
284
    "scv-" <> digest
285
  end
286
287
  defp value(event, key), do: Map.get(event, key) || Map.get(event, Atom.to_string(key))
288
  defp monotonic_ms, do: System.monotonic_time(:millisecond)
289
  defp next_order, do: System.unique_integer([:monotonic, :positive])
290
291
  defp schedule_prune(nil), do: :ok
292
  defp schedule_prune(interval_ms), do: Process.send_after(self(), :prune, interval_ms)
293
end
lib/openagents_web/live/network_status_live.ex modified +45

@@ -14,6 +14,8 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

14 14
  use OpenAgentsWeb, :live_view
15 15
16 16
  alias OpenAgents.NetworkStatus
17
  alias OpenAgents.SCV.Activity
18
  alias OpenAgentsWeb.UI.Graph
17 19
18 20
  @tick_ms 5_000
19 21
  @events_kept 20

@@ -22,6 +24,7 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

22 24
  def mount(_params, _session, socket) do
23 25
    if connected?(socket) do
24 26
      :ok = NetworkStatus.subscribe()
27
      :ok = Activity.subscribe()
25 28
26 29
      Enum.each(["forge:pushes", "forge:target", "forge:builds", "forge:deploys"], fn topic ->
27 30
        Phoenix.PubSub.subscribe(OpenAgents.PubSub, topic)

@@ -43,6 +46,10 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

43 46
    {:noreply, socket |> track_transitions(projection) |> assign_projection(projection)}
44 47
  end
45 48
49
  def handle_info({:scv_activity, entries}, socket) do
50
    {:noreply, assign(socket, :scvs, public_scvs(entries))}
51
  end
52
46 53
  def handle_info(:tick, socket) do
47 54
    Process.send_after(self(), :tick, @tick_ms)
48 55
    projection = NetworkStatus.projection(refresh: true)

@@ -99,9 +106,28 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

99 106
100 107
    socket
101 108
    |> assign(:projection, projection)
109
    |> assign(:scvs, public_scvs(Activity.public_projection()))
102 110
    |> assign(:overall, overall(projection))
103 111
  end
104 112
113
  defp public_scvs(entries) when is_list(entries) do
114
    Enum.map(entries, fn entry ->
115
      %{
116
        id: entry["id"],
117
        label: entry["label"],
118
        status: public_scv_status(entry["status"]),
119
        weight: entry["weight"],
120
        tool: entry["tool"],
121
        text: entry["text"]
122
      }
123
    end)
124
  end
125
126
  defp public_scvs(_entries), do: []
127
128
  defp public_scv_status("running"), do: :running
129
  defp public_scv_status(_status), do: :idle
130
105 131
  # Version divergence across reachable nodes IS the rollout visualization:
106 132
  # when a roll or hot deploy is sweeping the fleet, nodes disagree — surface
107 133
  # that as an explicit "rolling" state with progress.

@@ -319,6 +345,25 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

319 345
            newest build. The cluster never drops below quorum during a roll.
320 346
          </.alert>
321 347
348
          <.card id="status-scvs">
349
            <h2>Active SCVs</h2>
350
            <p class="status-forge__intro">
351
              Follow the bounded public activity from SCVs that are running now.
352
              Prompts, repository paths, command arguments, tool output, and reports
353
              stay private.
354
            </p>
355
356
            <.empty :if={@scvs == []} id="status-no-scvs" title="No active SCVs">
357
              The next admitted SCV run will appear here as it works.
358
            </.empty>
359
360
            <Graph.scv_streams
361
              :if={@scvs != []}
362
              id="public-scv-streams"
363
              scvs={@scvs}
364
            />
365
          </.card>
366
322 367
          <div id="status-nodes" class="status-nodes">
323 368
            <.card
324 369
              :for={{node, index} <- Enum.with_index(@projection["nodes"])}
test/openagents/network_status_test.exs modified +1

@@ -36,6 +36,7 @@ defmodule OpenAgents.NetworkStatusTest do

36 36
    assert %{"machines_connected" => machines, "active_jobs" => jobs} = projection["counts"]
37 37
    assert is_nil(machines) or is_integer(machines)
38 38
    assert is_nil(jobs) or is_integer(jobs)
39
    assert is_list(projection["scvs"])
39 40
  end
40 41
41 42
  test "an unreachable peer degrades to an honest per-node report, not a crash" do
test/openagents/scv/activity_test.exs added +126

@@ -0,0 +1,126 @@

1
defmodule OpenAgents.SCV.ActivityTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.SCV.Activity
5
6
  test "projects telemetry events into bounded public activity" do
7
    activity = start_supervised!({Activity, name: nil, pubsub: nil, telemetry: false})
8
    run_id = Ecto.UUID.generate()
9
10
    Activity.observe(
11
      %{
12
        schema: "openagents.scv.event.v1",
13
        run_id: run_id,
14
        type: "run_preparing",
15
        objective: "private objective",
16
        repository: "/private/repository",
17
        model: "openai/gpt-5.6-luna"
18
      },
19
      activity
20
    )
21
22
    assert [entry] = Activity.public_projection(activity)
23
    assert entry["status"] == "running"
24
    assert entry["text"] == "Preparing an admitted SCV run"
25
    assert entry["id"] =~ ~r/^scv-[0-9a-f]{8}$/
26
    assert entry["label"] =~ ~r/^SCV [0-9A-F]{8}$/
27
28
    rendered = inspect(entry)
29
    refute rendered =~ run_id
30
    refute rendered =~ "private objective"
31
    refute rendered =~ "/private/repository"
32
    refute rendered =~ "gpt-5.6-luna"
33
34
    Activity.observe(
35
      %{
36
        schema: "openagents.scv.event.v1",
37
        run_id: run_id,
38
        type: "opencode_event",
39
        event_type: "tool_use",
40
        tool: "grep",
41
        tool_status: "completed",
42
        output: "private tool output"
43
      },
44
      activity
45
    )
46
47
    assert [entry] = Activity.public_projection(activity)
48
    assert entry["tool"] == "grep"
49
    assert entry["text"] == "Searching repository context"
50
    refute inspect(entry) =~ "private tool output"
51
52
    Activity.observe(
53
      %{schema: "openagents.scv.event.v1", run_id: run_id, type: "run_finished"},
54
      activity
55
    )
56
57
    assert Activity.public_projection(activity) == []
58
  end
59
60
  test "observes the executor telemetry event" do
61
    activity = start_supervised!({Activity, name: nil, pubsub: nil})
62
    run_id = Ecto.UUID.generate()
63
64
    :telemetry.execute(
65
      [:openagents, :scv, :event],
66
      %{count: 1},
67
      %{
68
        schema: "openagents.scv.event.v1",
69
        run_id: run_id,
70
        type: "heartbeat"
71
      }
72
    )
73
74
    assert [%{"text" => "Working within its resource budget"}] =
75
             Activity.public_projection(activity)
76
77
    :telemetry.execute(
78
      [:openagents, :scv, :event],
79
      %{count: 1},
80
      %{schema: "openagents.scv.event.v1", run_id: run_id, type: "run_finished"}
81
    )
82
83
    assert Activity.public_projection(activity) == []
84
    assert Activity.public_projection() == []
85
  end
86
87
  test "ignores malformed and unrelated events" do
88
    activity = start_supervised!({Activity, name: nil, pubsub: nil, telemetry: false})
89
90
    Activity.observe(
91
      %{schema: "other", run_id: Ecto.UUID.generate(), type: "heartbeat"},
92
      activity
93
    )
94
95
    Activity.observe(%{schema: "openagents.scv.event.v1", type: "heartbeat"}, activity)
96
97
    assert Activity.public_projection(activity) == []
98
  end
99
100
  test "returns an empty projection while the activity process is unavailable" do
101
    assert Activity.public_projection(OpenAgents.SCV.UnavailableActivity) == []
102
  end
103
104
  test "expires an SCV that stops emitting heartbeats" do
105
    activity =
106
      start_supervised!(
107
        {Activity,
108
         name: nil, pubsub: nil, telemetry: false, expire_after_ms: 0, prune_interval_ms: nil}
109
      )
110
111
    Activity.observe(
112
      %{
113
        schema: "openagents.scv.event.v1",
114
        run_id: Ecto.UUID.generate(),
115
        type: "heartbeat"
116
      },
117
      activity
118
    )
119
120
    assert [_entry] = Activity.public_projection(activity)
121
122
    send(activity, :prune)
123
124
    assert Activity.public_projection(activity) == []
125
  end
126
end
test/openagents_web/live/network_status_live_test.exs modified +39

@@ -10,10 +10,49 @@ defmodule OpenAgentsWeb.NetworkStatusLiveTest do

10 10
    assert html =~ "BEAM nodes"
11 11
    assert html =~ "node 1"
12 12
    assert has_element?(view, ".status-metric__label", "computers connected")
13
    assert has_element?(view, "#status-scvs")
14
    assert has_element?(view, "#status-no-scvs")
13 15
    # Content-free: the serving node's internal name never reaches the page.
14 16
    refute html =~ to_string(node())
15 17
  end
16 18
19
  test "renders bounded live SCV activity without private event content", %{conn: conn} do
20
    run_id = Ecto.UUID.generate()
21
22
    OpenAgents.SCV.Activity.observe(%{
23
      schema: "openagents.scv.event.v1",
24
      run_id: run_id,
25
      type: "opencode_event",
26
      event_type: "tool_use",
27
      tool: "grep",
28
      objective: "private parity objective",
29
      repository: "/workspace/private-repository",
30
      output: "private tool output"
31
    })
32
33
    _projection = OpenAgents.SCV.Activity.public_projection()
34
35
    on_exit(fn ->
36
      OpenAgents.SCV.Activity.observe(%{
37
        schema: "openagents.scv.event.v1",
38
        run_id: run_id,
39
        type: "run_finished"
40
      })
41
42
      _projection = OpenAgents.SCV.Activity.public_projection()
43
    end)
44
45
    conn = put_req_header(conn, "accept", "text/html")
46
    {:ok, view, html} = live(conn, ~p"/status")
47
48
    assert has_element?(view, "#public-scv-streams")
49
    assert html =~ "Searching repository context"
50
    refute html =~ run_id
51
    refute html =~ "private parity objective"
52
    refute html =~ "/workspace/private-repository"
53
    refute html =~ "private tool output"
54
  end
55
17 56
  test "legacy JSON pollers of /status keep the old health payload", %{conn: conn} do
18 57
    # No Accept header (probe-style) → legacy JSON.
19 58
    response = conn |> get(~p"/status") |> json_response(200)

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