Structure SCV report delivery

53f4989e38ea · AtlantisPleb · · parent 69a29c9a5ac2

Structure SCV report delivery

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 .dockerignore
  • modified docs/scv-planning.md
  • modified lib/openagents/scv/open_code_report.ex
  • modified lib/openagents/scv/worker.ex
  • modified test/openagents/scv/open_code_report_test.exs
  • modified test/openagents/scv/run_test.exs

Diff

6 files changed, +133 -10

.dockerignore modified -2

@@ -5,8 +5,6 @@ _build/

5 5
deps/
6 6
cover/
7 7
doc/
8
docs/
9
test/
10 8
tmp/
11 9
assets/node_modules/
12 10
priv/static/assets/
docs/scv-planning.md modified +15 -3

@@ -2,9 +2,10 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: First complete OpenCode SCV environment implemented and proven locally
6
and in the shared-project read-only staging lane; durable coordination, durable
7
tool effects, isolated staging, and autonomous deployment remain disabled
5
Status: OpenCode SCV environment and bounded report path implemented and proven
6
locally; three shared-project read-only audit SCVs deployed; durable
7
coordination, durable tool effects, isolated staging, and autonomous deployment
8
remain disabled
8 9
9 10
## Outcome
10 11

@@ -100,6 +101,9 @@ authority in staging, worker registration, Forge promotion, or deployment:

100 101
  explicit permission profile.
101 102
- `OpenAgents.SCV.OpenCodeEvents` normalizes content-free event counts, tool
102 103
  outcomes, token classes, and estimated cost.
104
- `OpenAgents.SCV.OpenCodeReport` collects only redacted OpenCode text events
105
  into a versioned report capped at 32 KiB. It never includes tool output or
106
  diagnostic lines.
103 107
- `OpenAgents.SCV.ResourceSampler` observes the direct OpenCode process from the
104 108
  host and records RSS and CPU samples.
105 109
- `mix openagents.scv.opencode` exposes the adapter for local qualification.

@@ -116,6 +120,14 @@ OpenCode logs as OpenCode produces them. Final JSON remains on standard output,

116 120
so an operator or process can consume the receipt without waiting blindly for
117 121
the command to finish.
118 122
123
The terminal worker result now includes `openagents.scv.report.v1`. The executor
124
applies its run-specific provider-key redaction before the report parser sees a
125
line, preserves valid UTF-8 at the byte limit, and marks truncated reports. This
126
path makes a read-only audit result consumable through Cloud Logging. It does
127
not replace durable artifact storage: a write-capable SCV must persist and
128
acknowledge the report, event artifact, and their digests before it reports
129
success.
130
119 131
The adapter writes the bounded prompt to a mode `0600` scratch file and gives
120 132
that finite file to OpenCode as standard input. This keeps prompt content out of
121 133
the process argument list and delivers EOF after the prompt. OpenCode reads a
lib/openagents/scv/open_code_report.ex modified +16

@@ -43,6 +43,13 @@ defmodule OpenAgents.SCV.OpenCodeReport do

43 43
    }
44 44
  end
45 45
46
  @doc "Splits a report into UTF-8-safe chunks for structured log delivery."
47
  @spec chunks(map(), pos_integer()) :: [String.t()]
48
  def chunks(%{text: text}, maximum_bytes)
49
      when is_binary(text) and is_integer(maximum_bytes) and maximum_bytes >= 4 do
50
    split_chunks(text, maximum_bytes, [])
51
  end
52
46 53
  defp append(state, ""), do: state
47 54
48 55
  defp append(state, text) do

@@ -78,4 +85,13 @@ defmodule OpenAgents.SCV.OpenCodeReport do

78 85
      |> remove_invalid_suffix()
79 86
    end
80 87
  end
88
89
  defp split_chunks("", _maximum_bytes, chunks), do: Enum.reverse(chunks)
90
91
  defp split_chunks(text, maximum_bytes, chunks) do
92
    chunk = valid_prefix(text, maximum_bytes)
93
    remaining_bytes = byte_size(text) - byte_size(chunk)
94
    remaining = binary_part(text, byte_size(chunk), remaining_bytes)
95
    split_chunks(remaining, maximum_bytes, [chunk | chunks])
96
  end
81 97
end
lib/openagents/scv/worker.ex modified +53 -5

@@ -9,11 +9,13 @@ defmodule OpenAgents.SCV.Worker do

9 9
  """
10 10
11 11
  alias OpenAgents.SCV
12
  alias OpenAgents.SCV.OpenCodeReport
12 13
  alias OpenAgents.SCV.Run
13 14
14 15
  @default_model "openai/gpt-5.4-mini"
15 16
  @default_timeout_ms 300_000
16 17
  @default_output_root "/workspace/runs"
18
  @maximum_report_chunk_bytes 3_072
17 19
18 20
  @spec run(map(), keyword()) :: {:ok, map()} | {:error, term()}
19 21
  def run(environment \\ System.get_env(), options \\ [])

@@ -34,11 +36,11 @@ defmodule OpenAgents.SCV.Worker do

34 36
35 37
    case run(System.get_env(), event_sink: sink) do
36 38
      {:ok, %{status: "succeeded"} = result} ->
37
        write_json(worker_result(result))
39
        write_terminal_events(result)
38 40
        :ok
39 41
40 42
      {:ok, result} ->
41
        write_json(worker_result(result))
43
        write_terminal_events(result)
42 44
        raise "SCV worker finished with status #{result.status}"
43 45
44 46
      {:error, reason} ->

@@ -186,11 +188,47 @@ defmodule OpenAgents.SCV.Worker do

186 188
    end
187 189
  end
188 190
189
  defp worker_result(result) do
191
  @doc false
192
  @spec terminal_events(map()) :: [map()]
193
  def terminal_events(result) when is_map(result) do
194
    chunks = OpenCodeReport.chunks(result.report, @maximum_report_chunk_bytes)
195
    emitted_at = DateTime.utc_now() |> DateTime.to_iso8601()
196
    chunk_count = length(chunks)
197
    report_digest = digest(result.report.text)
198
199
    report_events =
200
      chunks
201
      |> Enum.with_index(1)
202
      |> Enum.map(fn {chunk, sequence} ->
203
        %{
204
          schema: "openagents.scv.report.chunk.v1",
205
          type: "report_chunk",
206
          emitted_at: emitted_at,
207
          run_id: result.run_id,
208
          repository_revision: result.repository.git_sha,
209
          sequence: sequence,
210
          chunk_count: chunk_count,
211
          report_digest: report_digest,
212
          text: chunk
213
        }
214
      end)
215
216
    report_metadata = %{
217
      schema: result.report.schema,
218
      bytes: result.report.bytes,
219
      truncated: result.report.truncated,
220
      chunk_count: chunk_count,
221
      digest: report_digest
222
    }
223
224
    report_events ++ [worker_result(result, report_metadata, emitted_at)]
225
  end
226
227
  defp worker_result(result, report_metadata, emitted_at) do
190 228
    %{
191 229
      schema: "openagents.scv.worker.result.v1",
192 230
      type: "worker_finished",
193
      emitted_at: DateTime.utc_now() |> DateTime.to_iso8601(),
231
      emitted_at: emitted_at,
194 232
      run_id: result.run_id,
195 233
      status: result.status,
196 234
      driver: result.scv.driver,

@@ -202,10 +240,20 @@ defmodule OpenAgents.SCV.Worker do

202 240
      usage: result.events.usage,
203 241
      resources: result.resources,
204 242
      artifact_digest: result.artifacts.events_digest,
205
      report: result.report
243
      report: report_metadata
206 244
    }
207 245
  end
208 246
247
  defp write_terminal_events(result) do
248
    result
249
    |> terminal_events()
250
    |> Enum.each(&write_json/1)
251
  end
252
253
  defp digest(value) do
254
    "sha256:" <> (:crypto.hash(:sha256, value) |> Base.encode16(case: :lower))
255
  end
256
209 257
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
210 258
  defp error_code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
211 259
  defp error_code(_reason), do: "worker_failed"
test/openagents/scv/open_code_report_test.exs modified +12

@@ -43,4 +43,16 @@ defmodule OpenAgents.SCV.OpenCodeReportTest do

43 43
    assert String.valid?(report.text)
44 44
    assert report.truncated
45 45
  end
46
47
  test "splits a report into ordered UTF-8-safe chunks" do
48
    text = String.duplicate("audit 🚀 ", 1_000)
49
    report = %{text: text}
50
51
    chunks = OpenCodeReport.chunks(report, 1_025)
52
53
    assert length(chunks) > 1
54
    assert Enum.all?(chunks, &(byte_size(&1) <= 1_025))
55
    assert Enum.all?(chunks, &String.valid?/1)
56
    assert Enum.join(chunks) == text
57
  end
46 58
end
test/openagents/scv/run_test.exs modified +37

@@ -119,6 +119,43 @@ defmodule OpenAgents.SCV.RunTest do

119 119
             Worker.run(Map.delete(environment, "OPENAI_API_KEY"))
120 120
  end
121 121
122
  test "emits bounded structured report chunks before the terminal result" do
123
    text = String.duplicate("SCV finding 🚀 ", 500)
124
    digest = "sha256:" <> (:crypto.hash(:sha256, text) |> Base.encode16(case: :lower))
125
126
    result = %{
127
      run_id: Ecto.UUID.generate(),
128
      status: "succeeded",
129
      duration_ms: 100,
130
      repository: %{git_sha: String.duplicate("a", 40)},
131
      scv: %{driver: "opencode", environment: "opencode-core"},
132
      events: %{event_count: 2, tool_calls: %{}, usage: %{}},
133
      resources: %{},
134
      artifacts: %{events_digest: String.duplicate("b", 64)},
135
      report: %{
136
        schema: "openagents.scv.report.v1",
137
        text: text,
138
        bytes: byte_size(text),
139
        truncated: false
140
      }
141
    }
142
143
    events = Worker.terminal_events(result)
144
    terminal = List.last(events)
145
    chunks = Enum.drop(events, -1)
146
147
    assert terminal.type == "worker_finished"
148
    assert terminal.report.digest == digest
149
    assert terminal.report.chunk_count == length(chunks)
150
    refute Map.has_key?(terminal.report, :text)
151
152
    assert Enum.map(chunks, & &1.sequence) == Enum.to_list(1..length(chunks))
153
    assert Enum.all?(chunks, &(&1.type == "report_chunk"))
154
    assert Enum.all?(chunks, &(&1.report_digest == digest))
155
    assert Enum.all?(chunks, &(byte_size(Jason.encode!(&1)) < 4_096))
156
    assert chunks |> Enum.map(& &1.text) |> Enum.join() == text
157
  end
158
122 159
  defp fake_executable do
123 160
    """
124 161
    #!/bin/sh

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