Close production parity blockers

36fe3c49d6d6 · AtlantisPleb · · parent 5716ca08edb7

Close production parity blockers

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 INVARIANTS.md
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • modified docs/operations/forge-transactional-deployment.md
  • modified docs/operations/staging-migration-lineage.md
  • modified docs/operations/staging-regression.md
  • modified docs/scv-planning.md
  • modified infra/staging/.terraform.lock.hcl
  • modified infra/staging/README.md
  • added lib/mix/tasks/openagents.atif.export.ex
  • added lib/mix/tasks/openagents.eval.persona.ex
  • added lib/mix/tasks/openagents.eval.recall.ex
  • added lib/mix/tasks/openagents.icons.vendor.ex
  • added lib/mix/tasks/openagents.persona.verify_promotion.ex
  • added lib/mix/tasks/openagents.voice.load_probe.ex
  • added lib/mix/tasks/openagents.voice.release_control.ex
  • added lib/mix/tasks/openagents.voice.report.ex
  • added lib/mix/tasks/openagents.voice.retention.ex
  • added lib/openagents_web/controllers/admin_recording_controller.ex
  • modified lib/openagents_web/endpoint.ex
  • added lib/openagents_web/live/admin_recordings_live.ex
  • modified lib/openagents_web/live/network_status_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified lib/openagents_web/user_auth.ex
  • modified ops/ci/release-smoke.sh
  • modified ops/staging/run-public-smoke.sh
  • modified ops/staging/terraform.sh
  • added test/openagents/operational_mix_tasks_test.exs
  • modified test/openagents_web/auth_gate_test.exs
  • added test/openagents_web/controllers/admin_recording_controller_test.exs
  • modified test/openagents_web/controllers/health_controller_test.exs
  • added test/openagents_web/live/admin_recordings_live_test.exs
  • modified test/openagents_web/live/network_status_live_test.exs
  • modified test/openagents_web/route_authority_test.exs

Diff

34 files changed, +1084 -28

INVARIANTS.md modified +1 -1

@@ -1700,7 +1700,7 @@ quorum, the database, or a full fleet — every gathered field degrades

1700 1700
independently (an unreachable node reports as unreachable; a failed count is
1701 1701
absent), and the per-node fan-out is time-bounded and briefly cached so page
1702 1702
traffic cannot become an rpc storm. Legacy JSON pollers of `/status` keep the
1703
old health payload via content negotiation until they migrate to `/healthz`
1703
old health payload via content negotiation until they migrate to `/health`
1704 1704
or `/api/status`.
1705 1705
1706 1706
Evidence: `OpenAgents.NetworkStatus`, `OpenAgentsWeb.NetworkStatusLive`,
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +5 -5

@@ -108,7 +108,7 @@ Completed on 2026-08-20:

108 108
- Added `ops/ci/release-smoke.sh`. It requires an explicitly acknowledged
109 109
  disposable PostgreSQL URL, generates throwaway runtime secrets, builds the
110 110
  production assets and release, starts the real release, waits for the bounded
111
  `/healthz` response, and terminates the release cleanly.
111
  `/health` response, and terminates the release cleanly.
112 112
- Fixed `mix assets.deploy` to compile Phoenix's colocated assets before
113 113
  Tailwind resolves them. The release smoke exposed this production-only build
114 114
  failure and now passes against a fresh PostgreSQL 18 container.

@@ -1031,7 +1031,7 @@ Completed locally on 2026-08-20:

1031 1031
  cycle. A late-joining or replaced node leaves readiness while a target is
1032 1032
  deploying or as soon as a newer live target exists, then converges before it
1033 1033
  serves that revision.
1034
- Made `/healthz` return `503` while boot code or a deployment participant is
1034
- Made `/health` return `503` while boot code or a deployment participant is
1035 1035
  divergent. Added a bounded, content-free boot and deployment projection to
1036 1036
  `/status`, and made its quorum calculation honor the configured forge fleet
1037 1037
  size even when Ra is disabled.

@@ -1284,7 +1284,7 @@ Use this sequence for every staging candidate:

1284 1284
7. Snapshot the actual staging database.
1285 1285
8. Deploy the candidate to the web acceptance lane with high-risk features
1286 1286
   disabled.
1287
9. Confirm migration completion, `/healthz`, `/status`, database connectivity,
1287
9. Confirm migration completion, `/health`, `/status`, database connectivity,
1288 1288
   LiveView connection, and revision identity.
1289 1289
10. Hard-reload persistent browser sessions so they connect to the new revision.
1290 1290
11. Enable one gated subsystem at a time and run its regression group.

@@ -1322,7 +1322,7 @@ Implemented locally on 2026-08-20:

1322 1322
  Fresh current-lineage databases were missing the constraint even though the
1323 1323
  changeset named it; prior-lineage databases already have it and remain
1324 1324
  unchanged.
1325
- Rehearsed the bridge and all 20 remaining current migrations on a disposable
1325
- Rehearsed the bridge and all 21 remaining current migrations on a disposable
1326 1326
  copy of the complete 57-version prior schema. Account, message, forge target,
1327 1327
  build receipt, and deploy receipt probes remained present. Both the candidate
1328 1328
  application and the last-known-good application started against the migrated

@@ -1359,7 +1359,7 @@ erase the first failure; record both attempts and explain the result.

1359 1359
1360 1360
### Public and browser surfaces
1361 1361
1362
- `/healthz`, `/status`, `/api/status`, and `/favicon.ico` return their bounded
1362
- `/health`, `/status`, `/api/status`, and `/favicon.ico` return their bounded
1363 1363
  expected responses.
1364 1364
- `/`, `/leaderboard`, `/changelog`, `/docs`, `/components`, and configured
1365 1365
  public forge pages render without an authenticated session where intended.
docs/operations/forge-transactional-deployment.md modified +1 -1

@@ -138,7 +138,7 @@ On a cold or replaced node, boot convergence:

138 138
   artifacts or receipts.
139 139
6. Publishes a bounded convergence state and enters readiness.
140 140
141
If image code does not match the live target and convergence fails, `/healthz`
141
If image code does not match the live target and convergence fails, `/health`
142 142
returns `503` and `/status` reports a content-free degraded state. The worker
143 143
retries indefinitely with exponential backoff capped by
144 144
`OPENAGENTS_FORGE_BOOT_RETRY_MAX_MS`. A missing live target is the only normal
docs/operations/staging-migration-lineage.md modified +2 -2

@@ -27,7 +27,7 @@ partitions every current migration into four disjoint groups:

27 27
  drop build history if replayed.
28 28
- Four reconciliation migrations run normally because they are guarded or
29 29
  idempotent.
30
- 16 versions are genuinely new and run normally after the baseline.
30
- 17 versions are genuinely new and run normally after the baseline.
31 31
32 32
The bridge adds only the nullable `users.browser_key_hash` column and its
33 33
partial unique index. It then records the 14 reviewed versions. It preserves

@@ -85,7 +85,7 @@ isolated staging database instance and rehearse on that disposable copy:

85 85
8. Run `bin/migration-lineage check` again and require `prior_baselined` with
86 86
   zero missing facts.
87 87
9. Start the candidate release with high-risk features disabled. Require
88
   configuration readiness, database connectivity, `/healthz`, and `/status`.
88
   configuration readiness, database connectivity, `/health`, and `/status`.
89 89
10. Start the last-known-good release against the migrated copy with traffic and
90 90
    workers disabled. This verifies that the additive schema remains rollback
91 91
    compatible.
docs/operations/staging-regression.md modified +6 -1

@@ -133,12 +133,17 @@ ops/staging/record-result.sh \

133 133
  "$report" public-001 passed public-smoke "$smoke_receipt"
134 134
```
135 135
136
The smoke checks `/healthz`, `/status`, `/api/status`, `/favicon.ico`, `/`,
136
The smoke checks `/health`, `/status`, `/api/status`, `/favicon.ico`, `/`,
137 137
`/leaderboard`, `/changelog`, `/docs`, and `/components`. JSON endpoints must
138 138
report the candidate SHA. The home response must bind the CSP nonce to the
139 139
theme bootstrap and publish the microphone permissions policy. The receipt
140 140
contains no response bodies or header values.
141 141
142
Use `/health` for every public Cloud Run check. Cloud Run reserves some paths
143
that end in `z`, so `/healthz` can return a platform `404` before the request
144
reaches Phoenix. The application retains `/healthz` as a compatibility alias
145
for direct and distributed fleet traffic.
146
142 147
The receipt proves all of `public-001` and only the automated portions of
143 148
`public-002` and `public-005`. Complete the configured forge, browser-policy,
144 149
cookie, origin, image, microphone, and manual browser checks before marking
docs/scv-planning.md modified +1 -1

@@ -1388,7 +1388,7 @@ Define the expected signals before promotion. Use candidate-specific signals

1388 1388
where possible:
1389 1389
1390 1390
- the new regression test remains green against the packaged or live target;
1391
- `/healthz` and deployment readiness remain healthy;
1391
- `/health` and deployment readiness remain healthy;
1392 1392
- fleet revision and artifact identities remain consistent;
1393 1393
- affected error codes do not regress;
1394 1394
- latency, memory, mailbox, and restart measurements remain within an admitted
infra/staging/.terraform.lock.hcl modified +1

@@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/google" {

6 6
  constraints = "~> 7.41"
7 7
  hashes = [
8 8
    "h1:5bwzwKa/bvJmUkVMkrF18v9AfFeJ/wjR230oY+4LHrc=",
9
    "h1:FGFsRBzfeyq56BUAcb/WT676NieMX3NRfR4DBj2eEqk=",
9 10
    "zh:0f33485d068e39b1661d4ad789dfac353134b99cb5746e463e7719c053d70c06",
10 11
    "zh:162335c448b15125924c3451dc0a411635138c00e36784d9586b9df61bc8a3d7",
11 12
    "zh:172c9e4902e9a01b1111bbbb9063a47804ec5f4207b2221df9c398239ff3b350",
infra/staging/README.md modified +7 -1

@@ -71,13 +71,19 @@ stored in the plan or state. Generate it with an approved password manager,

71 71
keep it out of shell history, and use the same value when Gate 13 creates the
72 72
staging runtime secret. Never reuse a production credential.
73 73
74
Authenticate both the Cloud CLI and Terraform provider:
74
Authenticate the Cloud CLI. Application Default Credentials are preferred for
75
Terraform because they can refresh during a long operation:
75 76
76 77
```sh
77 78
gcloud auth login
78 79
gcloud auth application-default login
79 80
```
80 81
82
If Application Default Credentials have expired, the wrapper uses a fresh,
83
process-only Cloud CLI access token. Terraform cannot renew that token, so each
84
`plan`, `apply`, or `output` command must complete within one hour. The wrapper
85
never writes the token to a plan, Terraform state, or command output.
86
81 87
## Provision the boundary
82 88
83 89
1. Review the project bootstrap targets without changing cloud state:
lib/mix/tasks/openagents.atif.export.ex added +79

@@ -0,0 +1,79 @@

1
defmodule Mix.Tasks.Openagents.Atif.Export do
2
  @shortdoc "Export one account's conversation as an ATIF v1.7 trajectory file"
3
4
  @moduledoc """
5
  Exports the same ATIF document as `GET /data/export/atif` without starting
6
  the OpenAgents supervision tree or its recovery workers.
7
8
      mix openagents.atif.export --github-id 14167547 --out /tmp/atif.json \
9
        --database-url postgres://openagents_app:URL_ENCODED_PASSWORD@127.0.0.1:5433/openagents
10
  """
11
12
  use Mix.Task
13
14
  import Ecto.Query
15
16
  @requirements ["app.config"]
17
18
  @impl Mix.Task
19
  def run(arguments) do
20
    {options, remaining, invalid} =
21
      OptionParser.parse(arguments,
22
        strict: [github_id: :integer, out: :string, database_url: :string]
23
      )
24
25
    if remaining != [] or invalid != [], do: Mix.raise("invalid ATIF export options")
26
27
    github_id =
28
      Keyword.get(options, :github_id) || Mix.raise("--github-id GITHUB_NUMERIC_ID is required")
29
30
    start_repo_only(Keyword.get(options, :database_url))
31
32
    user =
33
      OpenAgents.Repo.get_by(OpenAgents.Accounts.User, github_id: github_id) ||
34
        Mix.raise("no user with GitHub ID #{github_id}")
35
36
    conversation =
37
      OpenAgents.Repo.one(
38
        from(conversation in OpenAgents.Conversations.Conversation,
39
          join: visitor in OpenAgents.Conversations.Visitor,
40
          on: visitor.id == conversation.visitor_id,
41
          where: visitor.user_id == ^user.id,
42
          order_by: [asc: conversation.inserted_at],
43
          limit: 1
44
        )
45
      ) || Mix.raise("user #{github_id} has no conversation")
46
47
    visitor = OpenAgents.Repo.get!(OpenAgents.Conversations.Visitor, conversation.visitor_id)
48
    {:ok, export} = OpenAgents.DataRights.AtifExport.build(user, visitor, conversation)
49
    output = Keyword.get(options, :out, "openagents-conversation-#{conversation.id}-atif.json")
50
    File.write!(output, Jason.encode!(export, pretty: true))
51
52
    steps = export["steps"]
53
    tool_calls = steps |> Enum.flat_map(&(&1["tool_calls"] || [])) |> length()
54
55
    Mix.shell().info(
56
      "wrote #{output}: #{length(steps)} steps, #{tool_calls} tool calls, " <>
57
        "final_metrics=#{Jason.encode!(export["final_metrics"])}"
58
    )
59
  end
60
61
  defp start_repo_only(database_url) do
62
    {:ok, _apps} = Application.ensure_all_started(:ssl)
63
    {:ok, _apps} = Application.ensure_all_started(:postgrex)
64
    {:ok, _apps} = Application.ensure_all_started(:ecto_sql)
65
    _snapshot = OpenAgents.Tools.Registry.install!(Application.fetch_env!(:openagents, :tools))
66
67
    repo_options =
68
      case database_url do
69
        nil -> [pool_size: 2]
70
        url when is_binary(url) -> [url: url, pool_size: 2, ssl: false]
71
      end
72
73
    case OpenAgents.Repo.start_link(repo_options) do
74
      {:ok, _repo} -> :ok
75
      {:error, {:already_started, _repo}} -> :ok
76
      {:error, reason} -> Mix.raise("repo start failed: #{inspect(reason)}")
77
    end
78
  end
79
end
lib/mix/tasks/openagents.eval.persona.ex added +46

@@ -0,0 +1,46 @@

1
defmodule Mix.Tasks.Openagents.Eval.Persona do
2
  use Mix.Task
3
4
  @shortdoc "Run the provider-backed Sarah persona regression corpus"
5
6
  @moduledoc """
7
  Runs the committed Sarah persona corpus through the configured OpenAgents
8
  provider. This task makes billable provider requests.
9
10
      mix openagents.eval.persona --output tmp/persona-report.json
11
  """
12
13
  @impl Mix.Task
14
  def run(arguments) do
15
    {options, remaining, invalid} =
16
      OptionParser.parse(arguments, strict: [output: :string, model: :string])
17
18
    if remaining != [] or invalid != [] do
19
      Mix.raise("usage: mix openagents.eval.persona [--model MODEL] [--output PATH]")
20
    end
21
22
    Mix.Task.run("app.start")
23
    provider = Application.fetch_env!(:openagents, :provider)
24
    model_id = options[:model] || Application.fetch_env!(:openagents, :openai_model)
25
    output = options[:output] || "tmp/openagents-persona-eval.json"
26
27
    case OpenAgents.Persona.Evaluation.Runner.run(provider, model_id) do
28
      {:ok, report} ->
29
        write_report!(output, report)
30
31
        if report["passed"] do
32
          Mix.shell().info("persona regression passed: #{report["score"]} (#{output})")
33
        else
34
          Mix.raise("persona regression failed: #{report["score"]} (#{output})")
35
        end
36
37
      {:error, reason} ->
38
        Mix.raise("persona regression could not run: #{inspect(reason)}")
39
    end
40
  end
41
42
  defp write_report!(path, report) do
43
    path |> Path.dirname() |> File.mkdir_p!()
44
    File.write!(path, Jason.encode_to_iodata!(report, pretty: true))
45
  end
46
end
lib/mix/tasks/openagents.eval.recall.ex added +44

@@ -0,0 +1,44 @@

1
defmodule Mix.Tasks.Openagents.Eval.Recall do
2
  use Mix.Task
3
4
  @shortdoc "Run the deterministic PostgreSQL recall release evaluation"
5
6
  @moduledoc """
7
  Runs the committed synthetic recall corpus and writes a revision-bound
8
  report. The runner rolls back every fixture row.
9
10
      mix openagents.eval.recall --output tmp/openagents-recall-eval.json
11
  """
12
13
  @impl Mix.Task
14
  def run(arguments) do
15
    {options, remaining, invalid} = OptionParser.parse(arguments, strict: [output: :string])
16
17
    if remaining != [] or invalid != [] do
18
      Mix.raise("usage: mix openagents.eval.recall [--output PATH]")
19
    end
20
21
    Mix.Task.run("app.start")
22
    Logger.configure(level: :warning)
23
    output = options[:output] || "tmp/openagents-recall-eval.json"
24
25
    case OpenAgents.Memory.Evaluation.Runner.run() do
26
      {:ok, report} ->
27
        write_report!(output, report)
28
29
        if report["passed"] do
30
          Mix.shell().info("recall evaluation passed (#{output})")
31
        else
32
          Mix.raise("recall evaluation failed (#{output})")
33
        end
34
35
      {:error, reason} ->
36
        Mix.raise("recall evaluation could not run: #{inspect(reason)}")
37
    end
38
  end
39
40
  defp write_report!(path, report) do
41
    path |> Path.dirname() |> File.mkdir_p!()
42
    File.write!(path, Jason.encode_to_iodata!(report, pretty: true))
43
  end
44
end
lib/mix/tasks/openagents.icons.vendor.ex added +147

@@ -0,0 +1,147 @@

1
defmodule Mix.Tasks.Openagents.Icons.Vendor do
2
  @shortdoc "Convert the Apps SDK UI React icon set into vendored SVG files"
3
4
  @moduledoc """
5
  Vendors the `openai/apps-sdk-ui` icon set into `priv/icons`.
6
7
      mix openagents.icons.vendor ~/work/projects/repos/apps-sdk-ui
8
9
  The converter accepts only the JSX forms in the pinned upstream set. It
10
  fails when upstream introduces an unsupported expression instead of writing
11
  a malformed glyph.
12
  """
13
14
  use Mix.Task
15
16
  @icon_subpath "src/components/Icon/svg"
17
  @output "priv/icons"
18
  @attribute_names %{
19
    "fillRule" => "fill-rule",
20
    "clipRule" => "clip-rule",
21
    "clipPath" => "clip-path",
22
    "strokeWidth" => "stroke-width",
23
    "strokeLinecap" => "stroke-linecap",
24
    "strokeLinejoin" => "stroke-linejoin",
25
    "strokeMiterlimit" => "stroke-miterlimit",
26
    "fillOpacity" => "fill-opacity",
27
    "strokeOpacity" => "stroke-opacity",
28
    "stopColor" => "stop-color",
29
    "stopOpacity" => "stop-opacity",
30
    "className" => "class",
31
    "xmlnsXlink" => "xmlns:xlink",
32
    "xlinkHref" => "xlink:href"
33
  }
34
35
  @impl Mix.Task
36
  def run(arguments) do
37
    source = arguments |> List.first() |> source_directory!()
38
    File.mkdir_p!(@output)
39
    clear_previous_icons()
40
41
    written =
42
      source
43
      |> File.ls!()
44
      |> Enum.filter(&String.ends_with?(&1, ".tsx"))
45
      |> Enum.sort()
46
      |> Enum.map(&convert(source, &1))
47
48
    Mix.shell().info("Vendored #{length(written)} icons into #{@output}/")
49
50
    case Enum.filter(written, &(&1.ids != [])) do
51
      [] ->
52
        :ok
53
54
      namespaced ->
55
        Mix.shell().info(
56
          "Namespaced element IDs in #{length(namespaced)} icons: " <>
57
            Enum.map_join(namespaced, ", ", & &1.name)
58
        )
59
    end
60
  end
61
62
  defp source_directory!(nil) do
63
    Mix.raise("usage: mix openagents.icons.vendor PATH_TO_APPS_SDK_UI")
64
  end
65
66
  defp source_directory!(path) do
67
    directory = path |> Path.expand() |> Path.join(@icon_subpath)
68
69
    if File.dir?(directory) do
70
      directory
71
    else
72
      Mix.raise("#{directory} does not contain the Apps SDK UI icon source")
73
    end
74
  end
75
76
  defp clear_previous_icons do
77
    @output |> Path.join("*.svg") |> Path.wildcard() |> Enum.each(&File.rm!/1)
78
  end
79
80
  defp convert(source, file) do
81
    component = Path.basename(file, ".tsx")
82
    name = kebab_case(component)
83
84
    svg =
85
      source
86
      |> Path.join(file)
87
      |> File.read!()
88
      |> extract_svg!(component)
89
      |> drop_props_spread()
90
      |> rewrite_attribute_names()
91
      |> rewrite_expression_values!(component)
92
      |> namespace_ids(name)
93
      |> normalize_whitespace()
94
95
    File.write!(Path.join(@output, "#{name}.svg"), svg <> "\n")
96
    %{name: name, ids: Regex.scan(~r/\sid="([^"]+)"/, svg)}
97
  end
98
99
  defp extract_svg!(contents, component) do
100
    case Regex.run(~r|(<svg\b.*</svg>)|s, contents) do
101
      [_, svg] -> svg
102
      _missing -> Mix.raise("#{component}: could not find an <svg> element")
103
    end
104
  end
105
106
  defp drop_props_spread(svg), do: String.replace(svg, ~r/\s*\{\.\.\.props\}/, "")
107
108
  defp rewrite_attribute_names(svg) do
109
    Enum.reduce(@attribute_names, svg, fn {jsx_name, svg_name}, current ->
110
      String.replace(current, ~r/(\s)#{jsx_name}=/, "\\1#{svg_name}=")
111
    end)
112
  end
113
114
  defp rewrite_expression_values!(svg, component) do
115
    Regex.replace(~r/=\{([^}]*)\}/, svg, fn _match, value ->
116
      value = String.trim(value)
117
118
      if Regex.match?(~r/^-?\d+(\.\d+)?$/, value) do
119
        ~s(="#{value}")
120
      else
121
        Mix.raise("#{component}: unsupported JSX expression value #{inspect(value)}")
122
      end
123
    end)
124
  end
125
126
  defp namespace_ids(svg, name) do
127
    svg
128
    |> then(&Regex.replace(~r/\sid="([^"]+)"/, &1, ~s( id="#{name}-\\1")))
129
    |> then(&Regex.replace(~r/url\(#([^)]+)\)/, &1, "url(##{name}-\\1)"))
130
  end
131
132
  defp normalize_whitespace(svg) do
133
    svg
134
    |> String.replace(~r/\s*\n\s*/, " ")
135
    |> String.replace(~r/\s+/, " ")
136
    |> String.replace(" >", ">")
137
    |> String.replace("/ >", "/>")
138
    |> String.trim()
139
  end
140
141
  defp kebab_case(component) do
142
    component
143
    |> String.replace(~r/([a-z0-9])([A-Z])/, "\\1-\\2")
144
    |> String.replace(~r/([A-Z])([A-Z][a-z])/, "\\1-\\2")
145
    |> String.downcase()
146
  end
147
end
lib/mix/tasks/openagents.persona.verify_promotion.ex added +25

@@ -0,0 +1,25 @@

1
defmodule Mix.Tasks.Openagents.Persona.VerifyPromotion do
2
  use Mix.Task
3
4
  @shortdoc "Verify persona source and regression evidence before promotion"
5
6
  @impl Mix.Task
7
  def run([report_path]) do
8
    Mix.Task.run("app.start")
9
10
    with {:ok, contents} <- File.read(report_path),
11
         {:ok, report} <- Jason.decode(contents),
12
         manifest <- OpenAgents.Persona.SourceManifest.load!(),
13
         persona <- OpenAgents.Persona.current!(),
14
         corpus <- OpenAgents.Persona.Evaluation.Corpus.load!(),
15
         :ok <-
16
           OpenAgents.Persona.Evaluation.ReleaseGate.validate(persona, manifest, corpus, report) do
17
      Mix.shell().info("promotion evidence passed for #{persona.id} on #{report["model_id"]}")
18
    else
19
      {:error, reason} -> Mix.raise("persona promotion blocked: #{inspect(reason)}")
20
    end
21
  end
22
23
  def run(_arguments),
24
    do: Mix.raise("usage: mix openagents.persona.verify_promotion REPORT_PATH")
25
end
lib/mix/tasks/openagents.voice.load_probe.ex added +36

@@ -0,0 +1,36 @@

1
defmodule Mix.Tasks.Openagents.Voice.LoadProbe do
2
  use Mix.Task
3
4
  @shortdoc "Run a bounded readiness load probe"
5
6
  @impl Mix.Task
7
  def run(arguments) do
8
    {options, remaining, invalid} =
9
      OptionParser.parse(arguments,
10
        strict: [url: :string, requests: :integer, concurrency: :integer, output: :string]
11
      )
12
13
    if invalid != [] or remaining != [], do: Mix.raise("invalid load-probe arguments")
14
15
    url = Keyword.fetch!(options, :url)
16
    requests = Keyword.get(options, :requests, 100)
17
    concurrency = Keyword.get(options, :concurrency, 10)
18
19
    case Application.ensure_all_started(:req) do
20
      {:ok, _applications} -> :ok
21
      {:error, reason} -> Mix.raise("could not start Req: #{inspect(reason)}")
22
    end
23
24
    report = OpenAgents.Voice.Operations.LoadProbe.run(url, requests, concurrency)
25
    encoded = Jason.encode_to_iodata!(report, pretty: true)
26
27
    case Keyword.get(options, :output) do
28
      nil ->
29
        Mix.shell().info(encoded)
30
31
      path ->
32
        File.write!(path, encoded)
33
        Mix.shell().info("wrote #{path}")
34
    end
35
  end
36
end
lib/mix/tasks/openagents.voice.release_control.ex added +44

@@ -0,0 +1,44 @@

1
defmodule Mix.Tasks.Openagents.Voice.ReleaseControl do
2
  use Mix.Task
3
4
  @shortdoc "Append a governed voice admission state"
5
6
  @impl Mix.Task
7
  def run(arguments) do
8
    {options, remaining, invalid} =
9
      OptionParser.parse(arguments,
10
        strict: [state: :string, reason: :string, actor: :string, revision: :string]
11
      )
12
13
    if invalid != [] or remaining != [], do: Mix.raise("invalid release-control arguments")
14
15
    start_operational_application()
16
17
    case OpenAgents.Voice.ReleaseControl.append(%{
18
           state: Keyword.get(options, :state),
19
           reason: Keyword.get(options, :reason),
20
           actor: Keyword.get(options, :actor),
21
           source_revision: Keyword.get(options, :revision)
22
         }) do
23
      {:ok, control} ->
24
        Mix.shell().info(
25
          Jason.encode!(%{
26
            schema: "openagents.voice_release_control_result.v1",
27
            id: control.id,
28
            state: control.state,
29
            inserted_at: control.inserted_at
30
          })
31
        )
32
33
      {:error, changeset} ->
34
        Mix.raise("release control refused: #{inspect(changeset.errors)}")
35
    end
36
  end
37
38
  defp start_operational_application do
39
    Application.put_env(:openagents, :turn_recovery_worker_enabled, false)
40
    Application.put_env(:openagents, :voice_recovery_worker_enabled, false)
41
    Application.put_env(:openagents, :voice_retention_worker_enabled, false)
42
    Mix.Task.run("app.start")
43
  end
44
end
lib/mix/tasks/openagents.voice.report.ex added +39

@@ -0,0 +1,39 @@

1
defmodule Mix.Tasks.Openagents.Voice.Report do
2
  use Mix.Task
3
4
  @shortdoc "Write a transcript-free aggregate voice release report"
5
6
  @impl Mix.Task
7
  def run(arguments) do
8
    {options, remaining, invalid} =
9
      OptionParser.parse(arguments, strict: [since_hours: :integer, output: :string])
10
11
    if invalid != [] or remaining != [], do: Mix.raise("invalid voice-report options")
12
13
    hours = Keyword.get(options, :since_hours, 24)
14
    if hours not in 1..2_160, do: Mix.raise("--since-hours must be between 1 and 2160")
15
16
    start_read_only_application()
17
18
    report =
19
      OpenAgents.Voice.Operations.Report.build(DateTime.add(DateTime.utc_now(), -hours, :hour))
20
21
    encoded = Jason.encode_to_iodata!(report, pretty: true)
22
23
    case Keyword.get(options, :output) do
24
      nil ->
25
        Mix.shell().info(encoded)
26
27
      path ->
28
        File.write!(path, encoded)
29
        Mix.shell().info("wrote #{path}")
30
    end
31
  end
32
33
  defp start_read_only_application do
34
    Application.put_env(:openagents, :turn_recovery_worker_enabled, false)
35
    Application.put_env(:openagents, :voice_recovery_worker_enabled, false)
36
    Application.put_env(:openagents, :voice_retention_worker_enabled, false)
37
    Mix.Task.run("app.start")
38
  end
39
end
lib/mix/tasks/openagents.voice.retention.ex added +20

@@ -0,0 +1,20 @@

1
defmodule Mix.Tasks.Openagents.Voice.Retention do
2
  use Mix.Task
3
4
  @shortdoc "Run the bounded voice operational-retention purge"
5
6
  @impl Mix.Task
7
  def run([]) do
8
    Application.put_env(:openagents, :turn_recovery_worker_enabled, false)
9
    Application.put_env(:openagents, :voice_recovery_worker_enabled, false)
10
    Application.put_env(:openagents, :voice_retention_worker_enabled, false)
11
    Mix.Task.run("app.start")
12
13
    case OpenAgents.Voice.Retention.purge_expired() do
14
      {:ok, count} -> Mix.shell().info(Jason.encode!(%{purged_sessions: count}))
15
      {:error, reason} -> Mix.raise("voice retention failed: #{inspect(reason)}")
16
    end
17
  end
18
19
  def run(_arguments), do: Mix.raise("this task accepts no arguments")
20
end
lib/openagents_web/controllers/admin_recording_controller.ex added +61

@@ -0,0 +1,61 @@

1
defmodule OpenAgentsWeb.AdminRecordingController do
2
  @moduledoc """
3
  Streams one call recording to an authenticated operator.
4
5
  The reader sends the complete ordered recording because later WebM, Ogg, and
6
  MP4 chunks depend on the first chunk's container header. It advertises no
7
  range support, unseals one chunk at a time, and never loads a complete call
8
  into memory.
9
  """
10
11
  use OpenAgentsWeb, :controller
12
13
  import Plug.Conn
14
15
  alias OpenAgents.Admin
16
  alias OpenAgents.Repo
17
  alias OpenAgents.Voice.Recordings
18
19
  def show(conn, %{"id" => id}) do
20
    case Admin.get_recording(id) do
21
      {:ok, recording, _owner} ->
22
        if recording.chunk_count > 0 and
23
             recording.status in OpenAgents.Voice.Recording.playable_statuses() do
24
          stream_recording(conn, recording)
25
        else
26
          send_resp(conn, :not_found, "")
27
        end
28
29
      {:error, :not_found} ->
30
        send_resp(conn, :not_found, "")
31
    end
32
  end
33
34
  defp stream_recording(conn, recording) do
35
    conn =
36
      conn
37
      |> put_resp_content_type(Recordings.content_type(recording), nil)
38
      |> put_resp_header("cache-control", "no-store")
39
      |> put_resp_header("accept-ranges", "none")
40
      |> put_resp_header("content-disposition", "inline")
41
      |> put_resp_header("x-content-type-options", "nosniff")
42
      |> send_chunked(:ok)
43
44
    {:ok, final_conn} =
45
      Repo.transaction(
46
        fn ->
47
          recording
48
          |> Recordings.stream()
49
          |> Enum.reduce_while(conn, fn recording_chunk, current_conn ->
50
            case chunk(current_conn, recording_chunk) do
51
              {:ok, next_conn} -> {:cont, next_conn}
52
              {:error, :closed} -> {:halt, current_conn}
53
            end
54
          end)
55
        end,
56
        timeout: :infinity
57
      )
58
59
    final_conn
60
  end
61
end
lib/openagents_web/endpoint.ex modified +1 -1

@@ -22,7 +22,7 @@ defmodule OpenAgentsWeb.Endpoint do

22 22
    websocket: [connect_info: [session: @session_options]],
23 23
    longpoll: [connect_info: [session: @session_options]]
24 24
25
  socket "/controller", OpenAgentsWeb.ControllerSocket,
25
  socket "/controller/socket", OpenAgentsWeb.ControllerSocket,
26 26
    websocket: [connect_info: [session: @session_options]],
27 27
    longpoll: [connect_info: [session: @session_options]]
28 28
lib/openagents_web/live/admin_recordings_live.ex added +225

@@ -0,0 +1,225 @@

1
defmodule OpenAgentsWeb.AdminRecordingsLive do
2
  @moduledoc """
3
  Lists bounded voice-call metadata and operator-only recording playback.
4
5
  The account panel remains the primary operator surface. This focused page
6
  restores recording qualification without exposing transcripts, prompts,
7
  provider identifiers, or recording bytes to ordinary accounts.
8
  """
9
10
  use OpenAgentsWeb, :live_view
11
12
  alias OpenAgents.Accounts
13
  alias OpenAgents.Admin
14
  alias OpenAgents.Admin.Call
15
  alias OpenAgents.Voice.Recordings
16
17
  @page_size 25
18
19
  @impl true
20
  def mount(_params, _session, socket) do
21
    if Accounts.admin?(socket.assigns.current_user) do
22
      {:ok,
23
       socket
24
       |> assign(:page_title, "Voice recordings · OpenAgents")
25
       |> assign(:offset, 0)
26
       |> assign(:page_size, @page_size)
27
       |> assign(:recording_config, Recordings.config())
28
       |> stream_configure(:calls, dom_id: &"admin-call-#{&1.session_id}")
29
       |> load_page()}
30
    else
31
      {:ok, redirect(socket, to: ~p"/")}
32
    end
33
  end
34
35
  @impl true
36
  def handle_event(event, _params, socket) when event in ["next_page", "previous_page"] do
37
    if Accounts.admin?(socket.assigns.current_user) do
38
      paginate(event, socket)
39
    else
40
      {:noreply, redirect(socket, to: ~p"/")}
41
    end
42
  end
43
44
  defp paginate("next_page", socket) do
45
    offset = socket.assigns.offset + @page_size
46
47
    if offset < socket.assigns.totals.calls do
48
      {:noreply, socket |> assign(:offset, offset) |> load_page()}
49
    else
50
      {:noreply, socket}
51
    end
52
  end
53
54
  defp paginate("previous_page", socket) do
55
    offset = max(socket.assigns.offset - @page_size, 0)
56
    {:noreply, socket |> assign(:offset, offset) |> load_page()}
57
  end
58
59
  defp load_page(socket) do
60
    calls = Admin.list_calls(limit: @page_size, offset: socket.assigns.offset)
61
62
    socket
63
    |> assign(:totals, Admin.recording_totals())
64
    |> assign(:calls_empty?, calls == [])
65
    |> assign(:page_count, length(calls))
66
    |> stream(:calls, calls, reset: true)
67
  end
68
69
  @impl true
70
  def render(assigns) do
71
    ~H"""
72
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Voice recordings" wide>
73
      <section id="admin-recordings-page" class="panel" aria-label="Voice call recordings">
74
        <header class="panel__header">
75
          <div>
76
            <h1 class="panel__title">Voice recordings</h1>
77
            <p class="text-muted-foreground">
78
              Audio is stored {sealed_label(@recording_config)} and deleted {@recording_config.retention_days} days after a call ends.
79
            </p>
80
          </div>
81
          <div class="admin-totals">
82
            <.badge variant={:dim}>{@totals.calls} calls</.badge>
83
            <.badge variant={:info}>{@totals.recorded} with audio</.badge>
84
            <.badge variant={:dim}>{format_bytes(@totals.byte_size)} stored</.badge>
85
          </div>
86
        </header>
87
88
        <div id="admin-recording-calls" phx-update="stream" class="admin-rows">
89
          <.empty
90
            :if={@calls_empty?}
91
            id="admin-recordings-empty"
92
            title="No voice calls yet"
93
          >
94
            Calls appear here after an account uses voice.
95
          </.empty>
96
97
          <.card :for={{dom_id, call} <- @streams.calls} id={dom_id}>
98
            <div class="admin-row">
99
              <div class="admin-identity">
100
                <.avatar
101
                  src={call.github_avatar_url}
102
                  alt=""
103
                  size={:sm}
104
                  fallback={String.first(call.github_login)}
105
                />
106
                <span>
107
                  <strong :if={Call.display_name(call)}>{Call.display_name(call)}</strong>
108
                  <span>@{call.github_login}</span>
109
                </span>
110
              </div>
111
112
              <div class="admin-state">
113
                <.badge variant={status_variant(call.status)}>{String.upcase(call.status)}</.badge>
114
                <.badge :if={call.termination_reason} variant={:dim}>
115
                  {call.termination_reason}
116
                </.badge>
117
                <.badge :if={call.failure_code} variant={:danger}>{call.failure_code}</.badge>
118
              </div>
119
120
              <dl class="admin-meta">
121
                <div>
122
                  <dt>Started</dt><dd>{format_timestamp(call.started_at)}</dd>
123
                </div>
124
                <div>
125
                  <dt>Call length</dt><dd>{format_call_length(call)}</dd>
126
                </div>
127
                <div>
128
                  <dt>Model</dt><dd>{call.model_id}</dd>
129
                </div>
130
                <div>
131
                  <dt>Tokens</dt><dd>{format_count(call.total_tokens)}</dd>
132
                </div>
133
                <div>
134
                  <dt>Transcript</dt><dd>{format_count(call.transcript_item_count)} items</dd>
135
                </div>
136
                <div :if={call.recording}>
137
                  <dt>Audio</dt>
138
                  <dd>{format_bytes(call.recording.byte_size)} · {Call.completeness(call)}</dd>
139
                </div>
140
              </dl>
141
142
              <.audio_player
143
                :if={Call.playable?(call)}
144
                id={"admin-audio-#{call.session_id}"}
145
                src={~p"/admin/recordings/#{call.recording.id}/audio"}
146
                label={"Call with @#{call.github_login} on #{format_timestamp(call.started_at)}"}
147
              />
148
149
              <p :if={!Call.playable?(call)} class="admin-absence">{Call.absence_reason(call)}</p>
150
            </div>
151
          </.card>
152
        </div>
153
154
        <nav :if={@totals.calls > @page_size} class="admin-pager" aria-label="Call pages">
155
          <.button
156
            id="admin-recordings-previous"
157
            variant={:secondary}
158
            size={:sm}
159
            disabled={@offset == 0}
160
            phx-click="previous_page"
161
          >
162
            Previous
163
          </.button>
164
          <span>{@offset + 1}–{@offset + @page_count} of {@totals.calls}</span>
165
          <.button
166
            id="admin-recordings-next"
167
            variant={:secondary}
168
            size={:sm}
169
            disabled={@offset + @page_size >= @totals.calls}
170
            phx-click="next_page"
171
          >
172
            Next
173
          </.button>
174
        </nav>
175
      </section>
176
    </Layouts.app>
177
    """
178
  end
179
180
  defp sealed_label(%{sealed?: true}), do: "encrypted"
181
  defp sealed_label(%{sealed?: false}), do: "unencrypted"
182
183
  defp status_variant("ended"), do: :dim
184
  defp status_variant("failed"), do: :danger
185
  defp status_variant(_active), do: :success
186
187
  defp format_call_length(%Call{recording: %{client_duration_ms: ms}}) when is_integer(ms),
188
    do: format_duration_ms(ms)
189
190
  defp format_call_length(%Call{started_at: started, ended_at: ended})
191
       when not is_nil(started) and not is_nil(ended),
192
       do: started |> DateTime.diff(ended, :millisecond) |> abs() |> format_duration_ms()
193
194
  defp format_call_length(%Call{}), do: "—"
195
196
  defp format_duration_ms(ms) do
197
    total_seconds = div(ms, 1_000)
198
199
    "#{div(total_seconds, 60)}:#{total_seconds |> rem(60) |> Integer.to_string() |> String.pad_leading(2, "0")}"
200
  end
201
202
  defp format_timestamp(nil), do: "—"
203
204
  defp format_timestamp(%DateTime{} = at) do
205
    at |> DateTime.truncate(:second) |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
206
  end
207
208
  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_024, do: "#{bytes} B"
209
210
  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_048_576,
211
    do: "#{Float.round(bytes / 1_024, 1)} KB"
212
213
  defp format_bytes(bytes) when is_integer(bytes),
214
    do: "#{Float.round(bytes / 1_048_576, 1)} MB"
215
216
  defp format_bytes(_bytes), do: "0 B"
217
218
  defp format_count(count) when is_integer(count) do
219
    count
220
    |> Integer.to_string()
221
    |> String.reverse()
222
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
223
    |> String.reverse()
224
  end
225
end
lib/openagents_web/live/network_status_live.ex modified +1 -1

@@ -483,7 +483,7 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

483 483
            <p>
484 484
              Machine-readable: <code>GET /api/status</code>
485 485
              (schema <code>openagents.network_status.v1</code>) ·
486
              probe: <code>GET /healthz</code>. This page updates live.
486
              probe: <code>GET /health</code>. This page updates live.
487 487
            </p>
488 488
          </footer>
489 489
        </section>
lib/openagents_web/route_authority.ex modified +8 -1

@@ -27,6 +27,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

27 27
    "/components/:slug",
28 28
    "/docs",
29 29
    "/docs/:slug",
30
    "/health",
30 31
    "/healthz"
31 32
  ]
32 33

@@ -83,7 +84,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

83 84
      %{
84 85
        transport: :websocket,
85 86
        verb: "connect",
86
        path: "/controller",
87
        path: "/controller/socket",
87 88
        handler: "OpenAgentsWeb.ControllerSocket",
88 89
        action: "connect",
89 90
        class: :machine,

@@ -116,6 +117,12 @@ defmodule OpenAgentsWeb.RouteAuthority do

116 117
  defp policy(%{path: "/admin/scv/accounts"}),
117 118
    do: declaration(:operator, "configured operator GitHub ID", "scv:account:connect", true)
118 119
120
  defp policy(%{path: "/admin/recordings"}),
121
    do: declaration(:operator, "configured operator GitHub ID", "voice:recording:list", false)
122
123
  defp policy(%{path: "/admin/recordings/:id/audio"}),
124
    do: declaration(:operator, "configured operator GitHub ID", "voice:recording:read", false)
125
119 126
  defp policy(%{path: "/admin"}),
120 127
    do: declaration(:operator, "configured operator GitHub ID", "voice:metadata:read", false)
121 128
lib/openagents_web/router.ex modified +31 -6

@@ -40,6 +40,10 @@ defmodule OpenAgentsWeb.Router do

40 40
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
41 41
  end
42 42
43
  pipeline :forge_git do
44
    plug OpenAgentsWeb.Plugs.ForgeGitAuth
45
  end
46
43 47
  pipeline :status_probe_compat do
44 48
    plug OpenAgentsWeb.Plugs.StatusProbeCompat
45 49
  end

@@ -49,6 +53,10 @@ defmodule OpenAgentsWeb.Router do

49 53
    plug :require_authenticated_user
50 54
  end
51 55
56
  pipeline :operator do
57
    plug :require_admin_user
58
  end
59
52 60
  scope "/", OpenAgentsWeb do
53 61
    pipe_through [:status_probe_compat, :browser]
54 62

@@ -80,7 +88,6 @@ defmodule OpenAgentsWeb.Router do

80 88
    post "/auth/github", AuthController, :start
81 89
    get "/auth/github/callback", AuthController, :callback, log: false
82 90
    delete "/logout", AuthController, :logout
83
    get "/healthz", HealthController, :show
84 91
  end
85 92
86 93
  # The public forge web UI (TRANSPARENCY-001), addressed exactly the way the

@@ -110,10 +117,6 @@ defmodule OpenAgentsWeb.Router do

110 117
      live "/chat", ChatLive, :index
111 118
      live "/computers", ComputersLive, :index
112 119
      live "/settings/api-tokens", ApiTokensLive, :index
113
      live "/admin", AdminLive, :index
114
      live "/admin/forge", AdminForgeLive, :index
115
      live "/admin/scv/accounts", AdminScvAccountsLive, :index
116
117 120
      live "/:owner/:repo/issues/new", IssueNewLive, :new
118 121
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
119 122
      live "/:owner/:repo/issues", IssueIndexLive, :index

@@ -162,11 +165,33 @@ defmodule OpenAgentsWeb.Router do

162 165
    delete "/computer-agent-jobs/:id", ComputerAgentJobsController, :delete
163 166
  end
164 167
165
  forward "/git", OpenAgents.Forge.GitHTTP
168
  scope "/admin", OpenAgentsWeb do
169
    pipe_through [:browser, :authenticated, :operator]
170
171
    live_session :operator,
172
      on_mount: [
173
        {OpenAgentsWeb.UserAuth, :ensure_authenticated},
174
        {OpenAgentsWeb.UserAuth, :ensure_admin}
175
      ] do
176
      live "/", AdminLive, :index
177
      live "/forge", AdminForgeLive, :index
178
      live "/recordings", AdminRecordingsLive, :index
179
      live "/scv/accounts", AdminScvAccountsLive, :index
180
    end
181
182
    get "/recordings/:id/audio", AdminRecordingController, :show
183
  end
184
185
  scope "/git" do
186
    pipe_through :forge_git
187
    forward "/", OpenAgents.Forge.GitHTTP
188
  end
166 189
167 190
  scope "/", OpenAgentsWeb do
168 191
    pipe_through :api
169 192
193
    get "/health", HealthController, :show
194
    get "/healthz", HealthController, :show
170 195
    get "/api/status", NetworkStatusController, :show
171 196
    get "/api/changelog", ChangelogController, :show
172 197
lib/openagents_web/user_auth.ex modified +34

@@ -36,6 +36,17 @@ defmodule OpenAgentsWeb.UserAuth do

36 36
    |> halt()
37 37
  end
38 38
39
  def require_admin_user(conn, _options) do
40
    if Accounts.admin?(conn.assigns[:current_user]) do
41
      conn
42
    else
43
      conn
44
      |> put_resp_header("cache-control", "no-store")
45
      |> Phoenix.Controller.redirect(to: ~p"/")
46
      |> halt()
47
    end
48
  end
49
39 50
  def on_mount(:mount_current_user, _params, session, socket) do
40 51
    with user_id when is_binary(user_id) <- session[@session_key],
41 52
         {:ok, user} <- Accounts.get_active_user(user_id) do

@@ -73,6 +84,20 @@ defmodule OpenAgentsWeb.UserAuth do

73 84
    end
74 85
  end
75 86
87
  def on_mount(:ensure_admin, _params, _session, socket) do
88
    with %{id: user_id} <- socket.assigns[:current_user],
89
         {:ok, user} <- Accounts.get_active_user(user_id),
90
         true <- Accounts.admin?(user) do
91
      {:cont,
92
       socket
93
       |> Phoenix.Component.assign(:current_user, user)
94
       |> Phoenix.LiveView.attach_hook(:admin_guard, :handle_event, &ensure_admin_event/3)}
95
    else
96
      _not_an_operator ->
97
        {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
98
    end
99
  end
100
76 101
  defp ensure_active_event(_event, _params, socket) do
77 102
    case Accounts.get_active_user(socket.assigns.current_user.id) do
78 103
      {:ok, user} ->

@@ -86,6 +111,15 @@ defmodule OpenAgentsWeb.UserAuth do

86 111
    end
87 112
  end
88 113
114
  defp ensure_admin_event(_event, _params, socket) do
115
    with {:ok, user} <- Accounts.get_active_user(socket.assigns.current_user.id),
116
         true <- Accounts.admin?(user) do
117
      {:cont, Phoenix.Component.assign(socket, :current_user, user)}
118
    else
119
      _not_an_operator -> {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
120
    end
121
  end
122
89 123
  def require_authenticated_api_user(
90 124
        %{assigns: %{current_user: %{status: "active"}}} = conn,
91 125
        _options
ops/ci/release-smoke.sh modified +1 -1

@@ -96,7 +96,7 @@ env \

96 96
  "$staging_profile" "$release_bin" start >"$release_log" 2>&1 &
97 97
release_pid=$!
98 98
99
health_url="http://127.0.0.1:$port/healthz"
99
health_url="http://127.0.0.1:$port/health"
100 100
health_body=
101 101
102 102
for attempt in $(seq 1 120); do
ops/staging/run-public-smoke.sh modified +1 -1

@@ -195,7 +195,7 @@ record_check() {

195 195
  ' >>"$checks"
196 196
}
197 197
198
record_check public-001 /healthz json
198
record_check public-001 /health json
199 199
record_check public-001 /status json
200 200
record_check public-001 /api/status json
201 201
record_check public-001 /favicon.ico binary
ops/staging/terraform.sh modified +13 -1

@@ -13,6 +13,18 @@ git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)

13 13
plan_root="$repo_root/.git/openagents/staging-plans"
14 14
plan_path="$plan_root/$git_sha.tfplan"
15 15
16
authenticate_terraform() {
17
  if gcloud auth application-default print-access-token >/dev/null 2>&1; then
18
    return
19
  fi
20
21
  # The Google provider and GCS backend accept this process-only token. The
22
  # operator credential never enters Terraform state, a plan, or command output.
23
  # Refresh it for every command because Terraform cannot renew access tokens.
24
  GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token)
25
  export GOOGLE_OAUTH_ACCESS_TOKEN
26
}
27
16 28
require_boundary() {
17 29
  : "${staging_project:?OPENAGENTS_STAGING_PROJECT_ID is required}"
18 30
  : "${production_project:?OPENAGENTS_PRODUCTION_PROJECT_ID is required}"

@@ -29,7 +41,7 @@ require_boundary() {

29 41
    exit 1
30 42
  fi
31 43
32
  gcloud auth application-default print-access-token >/dev/null
44
  authenticate_terraform
33 45
  export TF_VAR_staging_project_id="$staging_project"
34 46
  export TF_VAR_production_project_id="$production_project"
35 47
}
test/openagents/operational_mix_tasks_test.exs added +22

@@ -0,0 +1,22 @@

1
defmodule OpenAgents.OperationalMixTasksTest do
2
  use ExUnit.Case, async: true
3
4
  @tasks ~w(
5
    openagents.atif.export
6
    openagents.eval.persona
7
    openagents.eval.recall
8
    openagents.icons.vendor
9
    openagents.persona.verify_promotion
10
    openagents.voice.load_probe
11
    openagents.voice.release_control
12
    openagents.voice.report
13
    openagents.voice.retention
14
  )
15
16
  test "every Sarah production operation has an OpenAgents task" do
17
    Enum.each(@tasks, fn task_name ->
18
      assert module = Mix.Task.get(task_name), "missing Mix task #{task_name}"
19
      assert function_exported?(module, :run, 1)
20
    end)
21
  end
22
end
test/openagents_web/auth_gate_test.exs modified +1 -1

@@ -40,7 +40,7 @@ defmodule OpenAgentsWeb.AuthGateTest do

40 40
41 41
  test "health endpoints remain public and create no identity state", %{conn: conn} do
42 42
    assert %{"status" => "ok"} = conn |> get(~p"/status") |> json_response(200)
43
    assert %{"status" => "ok"} = conn |> recycle() |> get(~p"/healthz") |> json_response(200)
43
    assert %{"status" => "ok"} = conn |> recycle() |> get(~p"/health") |> json_response(200)
44 44
    assert Repo.aggregate(from(visitor in Visitor), :count) == 0
45 45
  end
46 46
test/openagents_web/controllers/admin_recording_controller_test.exs added +98

@@ -0,0 +1,98 @@

1
defmodule OpenAgentsWeb.AdminRecordingControllerTest do
2
  @moduledoc """
3
  The recording reader is the only route that gives one account's voice to
4
  another person, so these tests enforce authorization on the media bytes.
5
  """
6
7
  use OpenAgentsWeb.ConnCase, async: false
8
9
  alias OpenAgents.Conversations
10
  alias OpenAgents.Voice
11
  alias OpenAgents.Voice.Config
12
  alias OpenAgents.Voice.Recordings
13
14
  @webm "audio/webm;codecs=opus"
15
16
  test "the operator receives ordered recording media", %{conn: conn} do
17
    recording = recorded_call("admin-audio-caller")
18
    conn = log_in_admin_user(conn, "admin-audio-operator")
19
20
    response = get(conn, ~p"/admin/recordings/#{recording.id}/audio")
21
22
    assert response.status == 200
23
    assert response.resp_body == "first-second-"
24
    assert get_resp_header(response, "content-type") == ["audio/webm"]
25
    assert get_resp_header(response, "cache-control") == ["no-store"]
26
    assert get_resp_header(response, "accept-ranges") == ["none"]
27
    assert get_resp_header(response, "x-content-type-options") == ["nosniff"]
28
  end
29
30
  test "ordinary, owning, and anonymous accounts receive no media bytes", %{conn: conn} do
31
    recording = recorded_call("admin-audio-private-caller")
32
33
    ordinary = log_in_github_user(conn, "admin-audio-intruder")
34
    assert redirected_to(get(ordinary, ~p"/admin/recordings/#{recording.id}/audio")) == ~p"/"
35
36
    owner = log_in_github_user(Phoenix.ConnTest.build_conn(), "admin-audio-private-caller")
37
    assert redirected_to(get(owner, ~p"/admin/recordings/#{recording.id}/audio")) == ~p"/"
38
39
    anonymous = get(Phoenix.ConnTest.build_conn(), ~p"/admin/recordings/#{recording.id}/audio")
40
    assert redirected_to(anonymous) == ~p"/"
41
  end
42
43
  test "missing, malformed, and failed recordings return 404", %{conn: conn} do
44
    conn = log_in_admin_user(conn, "admin-audio-404-operator")
45
46
    assert get(conn, ~p"/admin/recordings/#{Ecto.UUID.generate()}/audio").status == 404
47
    assert get(conn, ~p"/admin/recordings/not-a-uuid/audio").status == 404
48
49
    {:ok, conversation} = Conversations.ensure_conversation(github_user("admin-audio-failed"))
50
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
51
    {:ok, _chunk} = Recordings.append_chunk(session, session.generation, 1, "partial", @webm)
52
    {:ok, recording} = Recordings.finalize(session, session.generation, "failed", nil)
53
54
    assert get(conn, ~p"/admin/recordings/#{recording.id}/audio").status == 404
55
  end
56
57
  test "an aborted upload plays the bytes that arrived", %{conn: conn} do
58
    {:ok, conversation} = Conversations.ensure_conversation(github_user("admin-audio-aborted"))
59
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
60
    {:ok, _chunk} = Recordings.append_chunk(session, session.generation, 1, "half-a-call", @webm)
61
    {:ok, ended} = Voice.end_session(session, session.generation, "client_disconnected")
62
63
    grace = Recordings.config().late_chunk_grace_seconds
64
65
    ended
66
    |> Ecto.Changeset.change(%{ended_at: DateTime.add(DateTime.utc_now(), -grace - 5, :second)})
67
    |> OpenAgents.Repo.update!()
68
69
    {:ok, 1} = Recordings.abort_stale()
70
    recording = Recordings.for_session(session)
71
    conn = log_in_admin_user(conn, "admin-audio-aborted-operator")
72
73
    response = get(conn, ~p"/admin/recordings/#{recording.id}/audio")
74
    assert response.status == 200
75
    assert response.resp_body == "half-a-call"
76
  end
77
78
  defp recorded_call(key) do
79
    {:ok, conversation} = Conversations.ensure_conversation(github_user(key))
80
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
81
    {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "first-", @webm)
82
    {:ok, _second} = Recordings.append_chunk(session, session.generation, 2, "second-", @webm)
83
    {:ok, recording} = Recordings.finalize(session, session.generation, "complete", 2_000)
84
    recording
85
  end
86
87
  defp enabled_config do
88
    Config.build!(
89
      enabled: true,
90
      architecture: :openai_realtime,
91
      provider: "openai",
92
      model: "gpt-realtime-2.1",
93
      voice: "marin",
94
      reasoning_effort: "low",
95
      maximum_session_seconds: 3_000
96
    )
97
  end
98
end
test/openagents_web/controllers/health_controller_test.exs modified +5 -1

@@ -2,7 +2,7 @@ defmodule OpenAgentsWeb.HealthControllerTest do

2 2
  use OpenAgentsWeb.ConnCase
3 3
4 4
  test "reports healthy when PostgreSQL is reachable", %{conn: conn} do
5
    conn = get(conn, ~p"/status")
5
    conn = get(conn, ~p"/health")
6 6
7 7
    assert json_response(conn, 200) == %{
8 8
             "status" => "ok",

@@ -10,6 +10,10 @@ defmodule OpenAgentsWeb.HealthControllerTest do

10 10
           }
11 11
  end
12 12
13
  test "retains the non-Cloud Run healthz alias", %{conn: conn} do
14
    assert %{"status" => "ok"} = conn |> get(~p"/healthz") |> json_response(200)
15
  end
16
13 17
  test "refuses readiness while boot code diverges from the live target", %{conn: conn} do
14 18
    key = {OpenAgents.Forge.BootConverge, :state}
15 19
    previous = :persistent_term.get(key, :missing)
test/openagents_web/live/admin_recordings_live_test.exs added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgentsWeb.AdminRecordingsLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.Conversations
7
  alias OpenAgents.Voice
8
  alias OpenAgents.Voice.Config
9
  alias OpenAgents.Voice.Recordings
10
11
  @webm "audio/webm;codecs=opus"
12
13
  test "only an operator can open the recording inventory", %{conn: conn} do
14
    ordinary = log_in_github_user(conn, "recordings-ordinary")
15
    assert {:error, {:redirect, %{to: "/"}}} = live(ordinary, ~p"/admin/recordings")
16
17
    operator = log_in_admin_user(Phoenix.ConnTest.build_conn(), "recordings-operator")
18
    assert {:ok, view, _html} = live(operator, ~p"/admin/recordings")
19
    assert has_element?(view, "#admin-recordings-page")
20
  end
21
22
  test "a playable recording renders an operator-only audio source", %{conn: conn} do
23
    caller = github_user("recordings-caller")
24
    {:ok, conversation} = Conversations.ensure_conversation(caller)
25
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
26
27
    {:ok, _chunk} =
28
      Recordings.append_chunk(
29
        session,
30
        session.generation,
31
        1,
32
        "private-voice-bytes-sentinel",
33
        @webm
34
      )
35
36
    {:ok, recording} = Recordings.finalize(session, session.generation, "complete", 1_000)
37
38
    conn = log_in_admin_user(conn, "recordings-listener")
39
    {:ok, view, html} = live(conn, ~p"/admin/recordings")
40
41
    assert has_element?(view, "#admin-call-#{session.id}")
42
    assert has_element?(view, ~s(audio[src="/admin/recordings/#{recording.id}/audio"]))
43
    assert has_element?(view, "#admin-audio-#{session.id}[aria-label]")
44
    refute html =~ "private-voice-bytes-sentinel"
45
  end
46
47
  defp enabled_config do
48
    Config.build!(
49
      enabled: true,
50
      architecture: :openai_realtime,
51
      provider: "openai",
52
      model: "gpt-realtime-2.1",
53
      voice: "marin",
54
      reasoning_effort: "low",
55
      maximum_session_seconds: 3_000
56
    )
57
  end
58
end
test/openagents_web/live/network_status_live_test.exs modified +2 -2

@@ -88,8 +88,8 @@ defmodule OpenAgentsWeb.NetworkStatusLiveTest do

88 88
    assert is_map(response["cluster"])
89 89
  end
90 90
91
  test "GET /healthz is unchanged", %{conn: conn} do
92
    assert %{"status" => "ok"} = conn |> get(~p"/healthz") |> json_response(200)
91
  test "GET /health is the public readiness probe", %{conn: conn} do
92
    assert %{"status" => "ok"} = conn |> get(~p"/health") |> json_response(200)
93 93
  end
94 94
95 95
  alias OpenAgents.Forge.{DeployReceipt, Target}
test/openagents_web/route_authority_test.exs modified +18

@@ -56,6 +56,24 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

56 56
    assert route!(:post, "/controller/pairings").class == :machine
57 57
    assert route!(:get, "/controller/pairings/:id").scope == "machine:pairing:claim"
58 58
    assert route!(:post, "/api/inference/proxy").class == :internal_service
59
    assert Enum.find(RouteAuthority.socket_inventory(), &(&1.path == "/controller/socket"))
60
61
    assert Enum.any?(OpenAgentsWeb.Endpoint.__sockets__(), fn
62
             {"/controller/socket", OpenAgentsWeb.ControllerSocket, _options} -> true
63
             _socket -> false
64
           end)
65
  end
66
67
  test "the Forge smart HTTP mount always runs through credential authentication" do
68
    route =
69
      Phoenix.Router.route_info(
70
        OpenAgentsWeb.Router,
71
        "GET",
72
        "/git/openagents.com.git/info/refs",
73
        "stage.openagents.com"
74
      )
75
76
    assert route.pipe_through == [:forge_git]
59 77
  end
60 78
61 79
  test "the OAuth callback suppresses router parameter logging at the application boundary" do

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