From 3ebdc4d43de77b623643998a6a03a3a1945cf29e Mon Sep 17 00:00:00 2001 From: Ryan Born Date: Wed, 23 Sep 2026 09:10:35 -0400 Subject: [PATCH 1/2] Provider accounts P10: arb usage --by account / --account, arb quota --account, JSON + LiveView surfaces (bd-icwk2k) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usage.summarize(by: :provider_account) and its provider_account_id filter now read usage_events.provider_account_id directly instead of the pre-P9 workspace-join approximation, so probe/pre-flight rows (no workspace_id, but always an account) are included — closing the exact under-reporting bias bd-adyhvn measured. Quota.provider_spend/1 gets the same fix. arb usage and arb quota gain --account (CLI + REST), and the usage/quota web surfaces and workspace detail page expose the account dimension. Co-Authored-By: Claude Sonnet 5 --- apps/arbiter/lib/arbiter/quota.ex | 34 +++-- apps/arbiter/lib/arbiter/usage.ex | 97 ++----------- .../arbiter/quota/account_wide_hold_test.exs | 59 ++++---- .../arbiter/quota/provider_spend_test.exs | 100 ++++++++++++++ .../usage/provider_account_rollup_test.exs | 127 ++++++++++++++++++ .../arbiter/workflows/dispatch_queue_test.exs | 7 + apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex | 35 ++++- apps/arbiter_cli/lib/arbiter_cli/cmd/usage.ex | 24 +++- .../test/arbiter_cli/cmd/quota_test.exs | 51 +++++++ .../test/arbiter_cli/cmd/usage_test.exs | 53 ++++++++ .../controllers/api/quota_controller.ex | 71 ++++++++++ .../controllers/api/usage_controller.ex | 52 +++++-- .../lib/arbiter_web/live/usage_live.ex | 33 ++++- .../policy_config_component.ex | 31 ++++- .../controllers/api/quota_controller_test.exs | 46 +++++++ .../controllers/api/usage_controller_test.exs | 77 +++++++++++ .../test/arbiter_web/live/usage_live_test.exs | 26 ++++ .../arbiter_web/live/workspace_live_test.exs | 27 ++++ docs/provider-account-design.md | 2 +- 19 files changed, 806 insertions(+), 146 deletions(-) create mode 100644 apps/arbiter/test/arbiter/quota/provider_spend_test.exs create mode 100644 apps/arbiter/test/arbiter/usage/provider_account_rollup_test.exs diff --git a/apps/arbiter/lib/arbiter/quota.ex b/apps/arbiter/lib/arbiter/quota.ex index 674845f19..16ef18318 100644 --- a/apps/arbiter/lib/arbiter/quota.ex +++ b/apps/arbiter/lib/arbiter/quota.ex @@ -1063,22 +1063,32 @@ defmodule Arbiter.Quota do ("claude" / "gemini" / "openai"); `cost_for/2` maps quota codes onto it. Returns `%{}` on any error so cost is a best-effort add-on, never a failure. - "How much of this plan did I spend?" is an account question (§8), so this - is the sum over every workspace metered under the account. `usage_events` - does not carry `provider_account_id` until P9, so the account total is - reached through the workspace link rather than read off the ledger row — - `workspace_spend/1` is the per-workspace term §6's breakdown line prints. + "How much of this plan did I spend?" is an account question (§8), read + straight off `usage_events.provider_account_id` (P9) rather than summed + through the workspace link — `workspace_spend/1` sums *only* rows that + carry a `workspace_id`, so a probe/pre-flight row (`workspace_id: nil`, but + always a `provider_account_id` — §8's seam with bd-adyhvn) would silently + drop out of the total, reintroducing the exact under-reporting bias + bd-adyhvn measured. `workspace_spend/1` is still the per-workspace term + §6's breakdown line prints, since that one *is* workspace-scoped by + definition. """ @spec provider_spend(String.t() | nil) :: %{optional(String.t()) => float()} - def provider_spend(account_id) do - account_id - |> Resolver.workspace_ids() - |> Enum.map(&workspace_spend/1) - |> Enum.reduce(%{}, fn spend, acc -> - Map.merge(acc, spend, fn _provider, a, b -> a + b end) - end) + def provider_spend(nil), do: %{} + + def provider_spend(account_id) when is_binary(account_id) do + since = DateTime.utc_now() |> DateTime.add(-@cost_window_days * 86_400, :second) + + case Arbiter.Usage.summarize(by: :provider, since: since, provider_account_id: account_id) do + {:ok, rows} -> Map.new(rows, &{&1.group, &1.total_cost_usd}) + _ -> %{} + end + rescue + _ -> %{} end + def provider_spend(_), do: %{} + @doc """ Build the `t:spend_cache/0` memo for `accounts`: one `workspace_spend/1` scan per distinct workspace metered under them. diff --git a/apps/arbiter/lib/arbiter/usage.ex b/apps/arbiter/lib/arbiter/usage.ex index 40784ca71..c28e56247 100644 --- a/apps/arbiter/lib/arbiter/usage.ex +++ b/apps/arbiter/lib/arbiter/usage.ex @@ -50,7 +50,6 @@ defmodule Arbiter.Usage do use Ash.Domain - alias Arbiter.Accounts.Resolver alias Arbiter.Tasks.Dependency alias Arbiter.Usage.Estimate alias Arbiter.Usage.Event @@ -119,12 +118,10 @@ defmodule Arbiter.Usage do * `:since` — `%DateTime{}` filter on `occurred_at`. Optional. * `:workspace_id` — restrict to one workspace. Optional. * `:provider_account_id` — restrict to one provider account - (`docs/provider-account-design.md` §3.3). `usage_events` carries no - account column until P9, so this narrows the query to the account's - *workspaces* — which over-selects, because a workspace is metered under - a different account per provider. With `by: :provider_account` the - result is then exact (every other account's group is dropped); with any - other grouping it is the workspace-set approximation. Optional. + (`docs/provider-account-design.md` §3.3, §8). Filters + `usage_events.provider_account_id` directly (P9), so it is exact with + every grouping — including rows with no `workspace_id` at all (a probe + or pre-flight row has no workspace but always has an account). Optional. * `:session_ids` — restrict to a list of `session_id` values, pushed into the query as `session_id in ^ids` rather than filtered after the read. `Event` indexes `:session_id`, so this keeps a `:by :session` rollup for @@ -166,7 +163,6 @@ defmodule Arbiter.Usage do {:ok, events |> group_events(by) - |> exact_account_groups(by, opts) |> Enum.map(&aggregate_group(by, &1)) |> sort_rollups(by) |> maybe_limit(opts)} @@ -308,23 +304,6 @@ defmodule Arbiter.Usage do end end - # `:provider_account_id` narrows the *query* to the account's workspaces, - # which is as exact as SQL can be while `usage_events` carries no account - # column. It is not exact enough on its own: a workspace is metered under a - # different account per provider, so its Claude rows come back for a Codex - # account too — measured on the live install, where a Codex account's 5h - # overage figure was $125 of Claude spend. The `:provider_account` - # grouping *is* per-event exact, so when both are given, drop every group - # but the one asked for. - defp exact_account_groups(groups, :provider_account, opts) do - case Keyword.get(opts, :provider_account_id) do - id when is_binary(id) and id != "" -> Map.take(groups, [id]) - _ -> groups - end - end - - defp exact_account_groups(groups, _by, _opts), do: groups - defp base_filter(query, opts) do query |> filter_since(Keyword.get(opts, :since)) @@ -344,15 +323,11 @@ defmodule Arbiter.Usage do defp filter_workspace_id(query, ""), do: query defp filter_workspace_id(query, ws), do: Ash.Query.filter(query, workspace_id == ^ws) - # `usage_events` carries no `provider_account_id` column until P9, so an - # account filter is the set of workspaces metered under it. An account with - # no workspaces matches nothing, which is the honest answer — not - # "everything". defp filter_provider_account_id(query, nil), do: query defp filter_provider_account_id(query, ""), do: query defp filter_provider_account_id(query, account_id), - do: Ash.Query.filter(query, workspace_id in ^Resolver.workspace_ids(account_id)) + do: Ash.Query.filter(query, provider_account_id == ^account_id) defp filter_session_ids(query, nil), do: query defp filter_session_ids(query, []), do: query @@ -391,25 +366,15 @@ defmodule Arbiter.Usage do defp group_events(events, :workspace), do: Enum.group_by(events, &(&1.workspace_id || "(none)")) - # §5 row 14: the account rollup. Resolved through the workspace → account - # join rather than read off the row, because `usage_events` does not carry - # `provider_account_id` until P9. An event is attributed to the account its - # workspace is metered under **for that event's provider** — a workspace can - # hold a different account per provider — and rows with no resolvable - # account fall into the `(none)` sentinel rather than vanishing. + # §5 row 14, §8: the account rollup, read straight off + # `usage_events.provider_account_id` (P9). This is what makes probe/ + # pre-flight rows count — they carry no `workspace_id` but always carry + # `provider_account_id` (§8's seam with bd-adyhvn) — dropping them here + # would reintroduce the exact under-reporting bias bd-adyhvn measured. + # Rows with no resolvable account fall into the `(none)` sentinel rather + # than vanishing. defp group_events(events, :provider_account) do - index = account_index(events) - codes = provider_codes(events) - fallbacks = default_provider_codes(events, codes) - - Enum.group_by(events, fn ev -> - code = Map.get(codes, ev.provider) || Map.get(fallbacks, ev.workspace_id) - - index - |> Map.get(ev.workspace_id, %{}) - |> Map.get(code) - |> Kernel.||("(none)") - end) + Enum.group_by(events, &(&1.provider_account_id || "(none)")) end defp group_events(events, :repo), do: Enum.group_by(events, &(&1.repo || "(none)")) @@ -430,42 +395,6 @@ defmodule Arbiter.Usage do end) end - # `%{workspace_id => %{provider_code => account_id}}` for every workspace the - # window touches — one read, rather than one per event. - defp account_index(events) do - events - |> Enum.map(& &1.workspace_id) - |> Enum.reject(&(is_nil(&1) or &1 == "")) - |> Enum.uniq() - |> Map.new(&{&1, Resolver.account_ids(&1)}) - end - - # Ledger providers ("claude" / "openai" / "gemini") are not the quota - # provider codes the join rows use, and resolving "gemini" probes the PATH - # (`Arbiter.Quota.provider_code/1`), so map each distinct value once. - defp provider_codes(events) do - events - |> Enum.map(& &1.provider) - |> Enum.uniq() - |> Map.new(&{&1, Arbiter.Quota.provider_code(&1)}) - end - - # `usage_events.provider` is nullable, and a row that records no provider - # (or one with no tracked quota) would otherwise fall out of every account — - # silently under-reporting the overage figure `Arbiter.Quota.Overage` alerts - # on, which pre-P7 counted every row the workspace had. Attribute it to the - # account the workspace actually dispatches on, the same provider the gate - # reads its snapshot for. Only workspaces that have such a row pay for the - # lookup. - defp default_provider_codes(events, codes) do - events - |> Enum.filter(&is_nil(Map.get(codes, &1.provider))) - |> Enum.map(& &1.workspace_id) - |> Enum.reject(&(is_nil(&1) or &1 == "")) - |> Enum.uniq() - |> Map.new(&{&1, Arbiter.Quota.provider_code(Arbiter.Quota.default_provider(&1))}) - end - defp task_attributed?(ev), do: is_binary(ev.task_id) and ev.task_id != "" defp session_attributed?(ev), do: is_binary(ev.session_id) and ev.session_id != "" diff --git a/apps/arbiter/test/arbiter/quota/account_wide_hold_test.exs b/apps/arbiter/test/arbiter/quota/account_wide_hold_test.exs index 7e1889310..6b535334c 100644 --- a/apps/arbiter/test/arbiter/quota/account_wide_hold_test.exs +++ b/apps/arbiter/test/arbiter/quota/account_wide_hold_test.exs @@ -65,12 +65,19 @@ defmodule Arbiter.Quota.AccountWideHoldTest do }) end - defp usage_event!(ws_id, cost, provider \\ "claude") do + # P10 (bd-icwk2k): `usage_events.provider_account_id` is a real column + # since P9, populated at write time by `Arbiter.Worker`'s own + # `AccountResolver.account_id(workspace_id, provider)` call — this mirrors + # that so these fixtures land exactly where the production write path + # would put them, rather than relying on `Usage.summarize/1` to infer it + # from the workspace join at read time (the pre-P9 approximation). + defp usage_event!(ws_id, cost, provider \\ "claude", account_id \\ nil) do Ash.create!(Event, %{ workspace_id: ws_id, task_id: "bd-p7-#{System.unique_integer([:positive])}", step: :work, provider: provider, + provider_account_id: account_id, cost_usd: cost, occurred_at: DateTime.utc_now() }) @@ -243,8 +250,8 @@ defmodule Arbiter.Quota.AccountWideHoldTest do link!(a, account) link!(b, account) - usage_event!(a.id, 1.25) - usage_event!(b.id, 2.75) + usage_event!(a.id, 1.25, "claude", account.id) + usage_event!(b.id, 2.75, "claude", account.id) assert_in_delta Overage.windowed_spend(account, nil), 4.0, 0.0001 assert_in_delta Overage.windowed_spend(account.id, nil), 4.0, 0.0001 @@ -258,8 +265,8 @@ defmodule Arbiter.Quota.AccountWideHoldTest do link!(mine, account) link!(theirs, other) - usage_event!(mine.id, 1.0) - usage_event!(theirs.id, 9.0) + usage_event!(mine.id, 1.0, "claude", account.id) + usage_event!(theirs.id, 9.0, "claude", other.id) assert_in_delta Overage.windowed_spend(account, nil), 1.0, 0.0001 end @@ -276,8 +283,8 @@ defmodule Arbiter.Quota.AccountWideHoldTest do link!(ws, claude) link_codex!(ws, codex) - usage_event!(ws.id, 100.0, "claude") - usage_event!(ws.id, 7.0, "openai") + usage_event!(ws.id, 100.0, "claude", claude.id) + usage_event!(ws.id, 7.0, "openai", codex.id) assert_in_delta Overage.windowed_spend(codex, nil), 7.0, 0.0001 assert_in_delta Overage.windowed_spend(claude, nil), 100.0, 0.0001 @@ -297,8 +304,8 @@ defmodule Arbiter.Quota.AccountWideHoldTest do link!(a, account) link!(b, account) - usage_event!(a.id, 1.0) - usage_event!(b.id, 2.0) + usage_event!(a.id, 1.0, "claude", account.id) + usage_event!(b.id, 2.0, "claude", account.id) assert {:ok, rows} = Usage.summarize(by: :provider_account) row = Enum.find(rows, &(&1.group == account.id)) @@ -314,8 +321,8 @@ defmodule Arbiter.Quota.AccountWideHoldTest do link!(mine, account) link!(theirs, other) - usage_event!(mine.id, 1.0) - usage_event!(theirs.id, 9.0) + usage_event!(mine.id, 1.0, "claude", account.id) + usage_event!(theirs.id, 9.0, "claude", other.id) assert {:ok, rows} = Usage.summarize(by: :provider_account, provider_account_id: account.id) assert [%{group: group, rows: 1}] = rows @@ -329,8 +336,8 @@ defmodule Arbiter.Quota.AccountWideHoldTest do link!(ws, claude) link_codex!(ws, codex) - usage_event!(ws.id, 100.0, "claude") - usage_event!(ws.id, 7.0, "openai") + usage_event!(ws.id, 100.0, "claude", claude.id) + usage_event!(ws.id, 7.0, "openai", codex.id) assert {:ok, [%{group: group, rows: 1, total_cost_usd: cost}]} = Usage.summarize(by: :provider_account, provider_account_id: codex.id) @@ -339,22 +346,20 @@ defmodule Arbiter.Quota.AccountWideHoldTest do assert_in_delta cost, 7.0, 0.0001 end - test "a row with no recorded provider falls back to the workspace's default provider" do - # `usage_events.provider` is nullable, and pre-P7 `windowed_spend/2` - # counted every row the workspace had regardless of provider. Dropping - # such a row from the account rollup would silently under-report - # overage and suppress the alert, so it is attributed to the account - # the workspace actually dispatches on — the same provider the gate - # reads the snapshot for. + test "a probe row with no workspace_id still lands in its account's group (§8)" do + # §8's seam with bd-adyhvn: a probe/pre-flight row has no workspace but + # always has an account, because it is issued *as* a credential. + # `usage_events.provider_account_id` (P9) is what makes this possible — + # there is no workspace to join through at all. account = account!() - ws = workspace!() - link!(ws, account) Ash.create!(Event, %{ - workspace_id: ws.id, - task_id: "bd-p7-noprov", - step: :work, - provider: nil, + workspace_id: nil, + task_id: nil, + source: :preflight, + step: :other, + provider: "claude", + provider_account_id: account.id, cost_usd: 3.0, occurred_at: DateTime.utc_now() }) @@ -366,7 +371,7 @@ defmodule Arbiter.Quota.AccountWideHoldTest do assert_in_delta Overage.windowed_spend(account, nil), 3.0, 0.0001 end - test "a row whose workspace has no account lands in the (none) sentinel" do + test "a row with no provider_account_id lands in the (none) sentinel" do usage_event!(workspace!().id, 1.0) assert {:ok, rows} = Usage.summarize(by: :provider_account) diff --git a/apps/arbiter/test/arbiter/quota/provider_spend_test.exs b/apps/arbiter/test/arbiter/quota/provider_spend_test.exs new file mode 100644 index 000000000..73a737fff --- /dev/null +++ b/apps/arbiter/test/arbiter/quota/provider_spend_test.exs @@ -0,0 +1,100 @@ +defmodule Arbiter.Quota.ProviderSpendTest do + @moduledoc """ + Provider accounts P10 (`docs/provider-account-design.md` §8, bd-icwk2k): + `Quota.provider_spend/1` is the account total `arb quota --account` prints. + + Pre-P9 it summed `workspace_spend/1` over the account's workspaces, which + by construction excludes probe/pre-flight rows (`workspace_id: nil`) — + exactly the under-reporting bias bd-adyhvn measured (unmetered probes + consume window percentage without contributing ledger dollars). Now that + `usage_events.provider_account_id` is a real column (P9), the account total + must be read straight off it so those rows count. + """ + use Arbiter.DataCase, async: false + + alias Arbiter.Accounts.ProviderAccount + alias Arbiter.Quota + alias Arbiter.Tasks.Workspace + alias Arbiter.Usage.Event + + defp workspace!(name \\ "default") do + Ash.create!(Workspace, %{name: name}) + end + + defp account!(provider \\ :claude) do + n = System.unique_integer([:positive]) + Ash.create!(ProviderAccount, %{provider: provider, slug: "spend-#{n}", label: "acct #{n}"}) + end + + defp task_event!(account_id, ws_id, provider, cost) do + Ash.create!(Event, %{ + task_id: "bd-spend-#{System.unique_integer([:positive])}", + source: :task, + step: :work, + provider: provider, + provider_account_id: account_id, + workspace_id: ws_id, + cost_usd: cost, + occurred_at: DateTime.utc_now() + }) + end + + defp preflight_event!(account_id, provider, cost) do + Ash.create!(Event, %{ + task_id: nil, + source: :preflight, + step: :other, + provider: provider, + provider_account_id: account_id, + workspace_id: nil, + cost_usd: cost, + occurred_at: DateTime.utc_now() + }) + end + + describe "provider_spend/1" do + test "sums task spend across every workspace metered under the account" do + account = account!() + a = workspace!("a") + b = workspace!("b") + task_event!(account.id, a.id, "claude", 1.0) + task_event!(account.id, b.id, "claude", 2.0) + + spend = Quota.provider_spend(account.id) + assert_in_delta spend["claude"], 3.0, 0.0001 + end + + test "includes probe/pre-flight rows, which carry no workspace_id (§8)" do + account = account!() + ws = workspace!() + task_event!(account.id, ws.id, "claude", 1.0) + preflight_event!(account.id, "claude", 0.5) + + spend = Quota.provider_spend(account.id) + assert_in_delta spend["claude"], 1.5, 0.0001 + end + + test "an account with only probe spend still reports it" do + account = account!() + preflight_event!(account.id, "claude", 0.25) + + spend = Quota.provider_spend(account.id) + assert_in_delta spend["claude"], 0.25, 0.0001 + end + + test "another account's spend is not counted" do + mine = account!() + theirs = account!() + preflight_event!(mine.id, "claude", 1.0) + preflight_event!(theirs.id, "claude", 9.0) + + spend = Quota.provider_spend(mine.id) + assert_in_delta spend["claude"], 1.0, 0.0001 + end + + test "a nil account spends nothing rather than summing the whole ledger" do + preflight_event!(account!().id, "claude", 5.0) + assert Quota.provider_spend(nil) == %{} + end + end +end diff --git a/apps/arbiter/test/arbiter/usage/provider_account_rollup_test.exs b/apps/arbiter/test/arbiter/usage/provider_account_rollup_test.exs new file mode 100644 index 000000000..25034921b --- /dev/null +++ b/apps/arbiter/test/arbiter/usage/provider_account_rollup_test.exs @@ -0,0 +1,127 @@ +defmodule Arbiter.Usage.ProviderAccountRollupTest do + @moduledoc """ + Provider accounts P10 (`docs/provider-account-design.md` §8, bd-icwk2k): + `Usage.summarize(by: :provider_account)` and its `:provider_account_id` + filter read `usage_events.provider_account_id` directly (P9, + `apps/arbiter/lib/arbiter/usage/event.ex`) instead of the pre-P9 + workspace-join approximation. + + The falsifiable claim this pins: a `source: :probe` or `:preflight` row + carries no `workspace_id` but always carries `provider_account_id` (§8), so + it must show up in `arb usage --by account` / `--account ` — dropping + it is exactly the under-reporting bias bd-adyhvn measured (unmetered probes + consume window percentage without contributing ledger dollars). + """ + use Arbiter.DataCase, async: false + + alias Arbiter.Accounts.ProviderAccount + alias Arbiter.Usage + alias Arbiter.Usage.Event + + defp account!(provider \\ :claude) do + n = System.unique_integer([:positive]) + + Ash.create!(ProviderAccount, %{ + provider: provider, + slug: "p10-acct-#{n}", + label: "P10 account #{n}" + }) + end + + defp task_event!(account_id, ws_id, cost) do + Ash.create!(Event, %{ + task_id: "bd-p10-#{System.unique_integer([:positive])}", + source: :task, + workspace_id: ws_id, + provider_account_id: account_id, + step: :work, + provider: "claude", + cost_usd: cost, + occurred_at: DateTime.utc_now() + }) + end + + defp probe_event!(account_id, cost, source \\ :preflight) do + Ash.create!(Event, %{ + task_id: nil, + source: source, + workspace_id: nil, + provider_account_id: account_id, + step: :other, + provider: "claude", + cost_usd: cost, + occurred_at: DateTime.utc_now() + }) + end + + describe "group_events(:provider_account) reads the column directly (P9+)" do + test "a probe row with no workspace_id is grouped under its account" do + account = account!() + probe_event!(account.id, 0.02, :preflight) + + assert {:ok, rows} = Usage.summarize(by: :provider_account) + row = Enum.find(rows, &(&1.group == account.id)) + assert row.rows == 1 + assert_in_delta row.total_cost_usd, 0.02, 0.0001 + end + + test "task and probe rows on the same account both contribute to the total" do + account = account!() + task_event!(account.id, "ws-fake-1", 1.0) + probe_event!(account.id, 0.5, :preflight) + probe_event!(account.id, 0.25, :probe) + + assert {:ok, rows} = Usage.summarize(by: :provider_account) + row = Enum.find(rows, &(&1.group == account.id)) + assert row.rows == 3 + assert_in_delta row.total_cost_usd, 1.75, 0.0001 + end + + test "a row with no provider_account_id lands in the (none) sentinel" do + probe_event!(nil, 1.0, :preflight) + + assert {:ok, rows} = Usage.summarize(by: :provider_account) + assert Enum.any?(rows, &(&1.group == "(none)")) + end + + test "rows on another account are not counted" do + mine = account!() + theirs = account!() + probe_event!(mine.id, 1.0) + probe_event!(theirs.id, 9.0) + + assert {:ok, rows} = Usage.summarize(by: :provider_account) + mine_row = Enum.find(rows, &(&1.group == mine.id)) + theirs_row = Enum.find(rows, &(&1.group == theirs.id)) + assert_in_delta mine_row.total_cost_usd, 1.0, 0.0001 + assert_in_delta theirs_row.total_cost_usd, 9.0, 0.0001 + end + end + + describe ":provider_account_id filter reads the column directly (P9+)" do + test "restricting to one account includes its probe rows and drops others'" do + mine = account!() + theirs = account!() + task_event!(mine.id, "ws-fake-2", 1.0) + probe_event!(mine.id, 0.5) + probe_event!(theirs.id, 9.0) + + assert {:ok, rows} = + Usage.summarize(by: :provider_account, provider_account_id: mine.id) + + assert [%{group: group, rows: 2, total_cost_usd: cost}] = rows + assert group == mine.id + assert_in_delta cost, 1.5, 0.0001 + end + + test "the filter also narrows other groupings (e.g. :source) to the account" do + mine = account!() + theirs = account!() + probe_event!(mine.id, 0.5, :preflight) + probe_event!(theirs.id, 9.0, :preflight) + + assert {:ok, rows} = Usage.summarize(by: :source, provider_account_id: mine.id) + assert [%{group: "preflight", rows: 1}] = rows + end + end +end diff --git a/apps/arbiter/test/arbiter/workflows/dispatch_queue_test.exs b/apps/arbiter/test/arbiter/workflows/dispatch_queue_test.exs index a67031871..d33a1be7b 100644 --- a/apps/arbiter/test/arbiter/workflows/dispatch_queue_test.exs +++ b/apps/arbiter/test/arbiter/workflows/dispatch_queue_test.exs @@ -846,10 +846,17 @@ defmodule Arbiter.Workflows.DispatchQueueTest do end end + # P10 (bd-icwk2k): `Overage.windowed_spend/2` reads + # `usage_events.provider_account_id` directly (P9) rather than summing + # through the workspace link, so this has to stamp the same account + # `seed_quota/2` seeds the snapshot under — mirroring what + # `Arbiter.Worker`'s own write path (`AccountResolver.account_id/2`) does + # for a real dispatch. defp seed_usage(ws, cost_usd) do Ash.create!(Arbiter.Usage.Event, %{ task_id: "usage-#{System.unique_integer([:positive])}", workspace_id: ws.id, + provider_account_id: quota_account_id!(ws.id), step: :work, cost_usd: cost_usd, occurred_at: DateTime.utc_now() |> DateTime.truncate(:second) diff --git a/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex b/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex index ccebf157e..b5c49a46b 100644 --- a/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex +++ b/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex @@ -40,9 +40,16 @@ defmodule ArbiterCli.Cmd.Quota do and muscle memory keep working. `--json` gains `account` / `workspaces` keys and retains `workspace_id` for one release as a deprecated alias. + `--account` (P10, `docs/provider-account-design.md` §8) goes straight to + the account instead of through a workspace — a UUID, a `provider:slug` + ref, or a bare unambiguous slug (`arb account list` for slugs). Shows that + account's own total plus its per-workspace breakdown, with no workspace + lookup involved. `--workspace` and `--account` are mutually exclusive; + `--account` wins if both are given. + Usage: - arb quota [--workspace ] [--json] + arb quota [--workspace | --account ] [--json] Defaults to the installation's default workspace. With `--json` emits the machine-readable snapshot; otherwise a short human-readable summary. @@ -60,13 +67,12 @@ defmodule ArbiterCli.Cmd.Quota do rest = Output.drop_json(argv) {opts, _rest, _bad} = - OptionParser.parse(rest, switches: [workspace: :string], aliases: [w: :workspace]) + OptionParser.parse(rest, + switches: [workspace: :string, account: :string], + aliases: [w: :workspace, a: :account] + ) - params = - case Keyword.get(opts, :workspace) do - ws when is_binary(ws) and ws != "" -> [workspace: ws] - _ -> [] - end + params = quota_params(opts) case Client.get("/api/quota", params) do {:ok, %{"data" => data}} -> emit(data, mode, params) @@ -75,6 +81,21 @@ defmodule ArbiterCli.Cmd.Quota do end end + # `--account` bypasses the workspace lookup entirely, so it wins over + # `--workspace` when both are given rather than silently picking one. + defp quota_params(opts) do + case Keyword.get(opts, :account) do + acct when is_binary(acct) and acct != "" -> + [account: acct] + + _ -> + case Keyword.get(opts, :workspace) do + ws when is_binary(ws) and ws != "" -> [workspace: ws] + _ -> [] + end + end + end + # ---- render ------------------------------------------------------------ defp emit(data, :json, _params), do: IO.puts(Jason.encode!(data)) diff --git a/apps/arbiter_cli/lib/arbiter_cli/cmd/usage.ex b/apps/arbiter_cli/lib/arbiter_cli/cmd/usage.ex index c12ba0850..a30677928 100644 --- a/apps/arbiter_cli/lib/arbiter_cli/cmd/usage.ex +++ b/apps/arbiter_cli/lib/arbiter_cli/cmd/usage.ex @@ -10,12 +10,13 @@ defmodule ArbiterCli.Cmd.Usage do Usage: - arb usage [--by day|task|epic|workspace|repo|model|step|provider|source|session] + arb usage [--by day|task|epic|workspace|provider_account|repo|model|step|provider|source|session] [--since YYYY-MM-DD | ] [--workspace ] + [--account ] [--limit N] [--json] - arb usage events [--task ] [--workspace ] [--step work|review|impl] + arb usage events [--task ] [--workspace ] [--account ] [--step work|review|impl] [--source task|probe|preflight|coordinator_session|terminal_session|maintenance] [--since ...] [--limit N] [--json] arb usage --session [--since ...] [--limit N] [--json] @@ -27,6 +28,19 @@ defmodule ArbiterCli.Cmd.Usage do shortcuts. `events` lists raw rows newest-first (default limit 50) and is the drill-down path when a rollup catches your eye. + ## `--by provider_account` / `--account` (P10, `docs/provider-account-design.md` §8) + + "How much of *this plan* have I spent?" is an account question, not a + workspace one — three workspaces can share one Claude plan. `--by + provider_account` rolls spend up by the provider account (`arb account + list` for slugs); `--account ` (a UUID, a `provider:slug` ref, or a + bare unambiguous slug) narrows any rollup or `events` to one account. + + This includes quota probe / pre-flight rows, which carry no workspace but + always an account — dropping them would under-report the plan's actual + spend (the bias bd-adyhvn measured: unmetered probes consume window + percentage without contributing ledger dollars). + ## Not all spend belongs to a task (bd-adyhvn) Quota refresh probes, the per-dispatch auth pre-flight, and the coordinator's @@ -118,9 +132,10 @@ defmodule ArbiterCli.Cmd.Usage do by: :string, since: :string, workspace: :string, + account: :string, limit: :integer ], - aliases: [b: :by, s: :since, w: :workspace, l: :limit] + aliases: [b: :by, s: :since, w: :workspace, a: :account, l: :limit] ) by = Keyword.get(opts, :by, @default_by) @@ -129,6 +144,7 @@ defmodule ArbiterCli.Cmd.Usage do [by: by] |> maybe_put(:since, normalize_since(Keyword.get(opts, :since))) |> maybe_put(:workspace_id, Keyword.get(opts, :workspace)) + |> maybe_put(:account, Keyword.get(opts, :account)) |> maybe_put(:limit, Keyword.get(opts, :limit)) case Client.get("/api/usage", params) do @@ -146,6 +162,7 @@ defmodule ArbiterCli.Cmd.Usage do switches: [ task: :string, workspace: :string, + account: :string, step: :string, source: :string, session: :string, @@ -158,6 +175,7 @@ defmodule ArbiterCli.Cmd.Usage do [] |> maybe_put(:task_id, Keyword.get(opts, :task)) |> maybe_put(:workspace_id, Keyword.get(opts, :workspace)) + |> maybe_put(:account, Keyword.get(opts, :account)) |> maybe_put(:step, Keyword.get(opts, :step)) |> maybe_put(:source, Keyword.get(opts, :source)) |> maybe_put(:session_id, Keyword.get(opts, :session)) diff --git a/apps/arbiter_cli/test/arbiter_cli/cmd/quota_test.exs b/apps/arbiter_cli/test/arbiter_cli/cmd/quota_test.exs index 1798ea585..6e832576b 100644 --- a/apps/arbiter_cli/test/arbiter_cli/cmd/quota_test.exs +++ b/apps/arbiter_cli/test/arbiter_cli/cmd/quota_test.exs @@ -576,4 +576,55 @@ defmodule ArbiterCli.Cmd.QuotaTest do assert out =~ "auth expired" end end + + describe "--account (P10, bd-icwk2k)" do + test "goes straight to the account, with the total + workspace breakdown" do + stub_get("/api/quota", %{ + "data" => %{ + "workspace_id" => nil, + "claude" => @snapshot, + "quotas" => [ + %{ + "provider" => "claude", + "account" => %{"slug" => "personal-max", "provider" => "claude"}, + "workspaces" => [ + %{"id" => "ws-1", "name" => "default", "cost_usd" => 4.0}, + %{"id" => "ws-2", "name" => "emricare", "cost_usd" => 6.0} + ] + } + ] + } + }) + + {out, _err, code} = + capture(fn -> ArbiterCli.Cmd.Quota.run(["--account", "personal-max"]) end) + + assert code == 0 + assert out =~ "Anthropic quota (account personal-max" + assert out =~ "2 workspaces: default, emricare" + refute out =~ "via workspace" + end + + test "--account is forwarded to the API as a query param, taking priority over --workspace" do + stub_routes([ + {{"get", "/api/quota"}, + fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + assert conn.query_params["account"] == "personal-max" + refute Map.has_key?(conn.query_params, "workspace") + + conn + |> Plug.Conn.put_status(200) + |> Req.Test.json(%{"data" => %{"workspace_id" => nil, "claude" => nil}}) + end} + ]) + + {_out, _err, code} = + capture(fn -> + ArbiterCli.Cmd.Quota.run(["--account", "personal-max", "--workspace", "emricare"]) + end) + + assert code == 0 + end + end end diff --git a/apps/arbiter_cli/test/arbiter_cli/cmd/usage_test.exs b/apps/arbiter_cli/test/arbiter_cli/cmd/usage_test.exs index cb2b5b595..de6debace 100644 --- a/apps/arbiter_cli/test/arbiter_cli/cmd/usage_test.exs +++ b/apps/arbiter_cli/test/arbiter_cli/cmd/usage_test.exs @@ -478,4 +478,57 @@ defmodule ArbiterCli.Cmd.UsageTest do assert out =~ "task=-" end end + + describe "account dimension (P10, bd-icwk2k)" do + test "--by provider_account renders the account rollup" do + stub_get("/api/usage", %{ + "by" => "provider_account", + "data" => [ + %{"group" => "acct-1", "rows" => 12, "total_cost_usd" => 9.5} + ] + }) + + {out, _err, code} = + capture(fn -> ArbiterCli.Cmd.Usage.run(["--by", "provider_account"]) end) + + assert code == 0 + assert out =~ "Usage rollup by provider_account" + assert out =~ "acct-1" + assert out =~ "9.50" + end + + test "--account is forwarded to the summarize API as a query param" do + stub_routes([ + {{"get", "/api/usage"}, + fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + assert conn.query_params["account"] == "personal-max" + conn |> Plug.Conn.put_status(200) |> Req.Test.json(%{"by" => "day", "data" => []}) + end} + ]) + + {_out, _err, code} = + capture(fn -> ArbiterCli.Cmd.Usage.run(["--account", "personal-max"]) end) + + assert code == 0 + end + + test "events --account is forwarded to the API as a query param" do + stub_routes([ + {{"get", "/api/usage/events"}, + fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + assert conn.query_params["account"] == "claude:personal-max" + conn |> Plug.Conn.put_status(200) |> Req.Test.json(%{"data" => []}) + end} + ]) + + {_out, _err, code} = + capture(fn -> + ArbiterCli.Cmd.Usage.run(["events", "--account", "claude:personal-max"]) + end) + + assert code == 0 + end + end end diff --git a/apps/arbiter_web/lib/arbiter_web/controllers/api/quota_controller.ex b/apps/arbiter_web/lib/arbiter_web/controllers/api/quota_controller.ex index efc01b18a..865ae18e1 100644 --- a/apps/arbiter_web/lib/arbiter_web/controllers/api/quota_controller.ex +++ b/apps/arbiter_web/lib/arbiter_web/controllers/api/quota_controller.ex @@ -24,6 +24,13 @@ defmodule ArbiterWeb.Api.QuotaController do `workspace_id` is retained for one release as its deprecated alias. The top-level `account` / `workspaces` describe the headline (Claude) provider. + `?account=` (P10, §8) goes straight to the account + instead of through a workspace — the same ref shapes `arb account` itself + accepts. `workspace_id` / `workspace` are `null` in this shape (there was + no workspace lookup), and only that account's own provider carries real + data; the rest are `null`, the same as an unauthenticated CLI. Takes + priority over `?workspace=` when both are given. + * `claude` — the latest polled snapshot, including per-model weekly breakdowns and overage spend; `null` before the first poll. * `codex` — the persisted OpenAI session/weekly-window snapshot (a distinct @@ -40,6 +47,10 @@ defmodule ArbiterWeb.Api.QuotaController do alias Arbiter.Tasks.Workspace require Ash.Query + def show(conn, %{"account" => account_ref}) when is_binary(account_ref) and account_ref != "" do + show_by_account(conn, account_ref) + end + def show(conn, params) do case resolve_workspace_id(Map.get(params, "workspace")) do {:ok, ws_id} -> @@ -91,6 +102,66 @@ defmodule ArbiterWeb.Api.QuotaController do end end + # P10 (`docs/provider-account-design.md` §8, bd-icwk2k): `?account=` goes + # straight to the account instead of through a workspace — a UUID, a + # `"provider:slug"` ref, or a bare unambiguous slug, the same refs + # `arb account` itself accepts. Only that account's own provider carries + # real data; the others stay `nil`, same as an unauthenticated CLI. + defp show_by_account(conn, account_ref) do + case Arbiter.Accounts.get_account(account_ref) do + {:ok, account} -> + provider = Atom.to_string(account.provider) + spend = Quota.spend_cache(account.id) + fields = Quota.account_fields(account.id, provider, spend) + + codex = if provider == "codex", do: Quota.Codex.serialize_latest(account.id) + + render(conn, :show, + workspace_id: nil, + workspace: nil, + requested_workspace: nil, + claude: + if(provider == "claude", + do: Quota.serialize(account.id, "claude", spend_cache: spend) + ), + quotas: Quota.list_serialized(account.id, spend_cache: spend), + account: fields[:account], + workspaces: fields[:workspaces], + codex: codex, + codex_message: Quota.codex_absence_message(codex), + codex_credentials_expired: + Arbiter.Agents.CredentialWatchdog.expired?(Arbiter.Agents.Codex), + gemini: + if(provider == "gemini_cli", + do: Quota.CloudCode.serialize_latest(account.id, "gemini_cli") + ), + antigravity: + if(provider == "antigravity", + do: Quota.CloudCode.serialize_latest(account.id, "antigravity") + ), + gemini_credentials_expired: + Arbiter.Agents.CredentialWatchdog.expired?(Arbiter.Agents.Gemini) + ) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{ + error: %{type: "not_found", message: "account #{inspect(account_ref)} not found"} + }) + + {:error, :ambiguous} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{ + error: %{ + type: "ambiguous", + message: "account #{inspect(account_ref)} is ambiguous; use \"provider:slug\"" + } + }) + end + end + defp workspace_view(ws_id) do case Ash.get(Workspace, ws_id) do {:ok, %Workspace{id: id, name: name}} -> %{id: id, name: name} diff --git a/apps/arbiter_web/lib/arbiter_web/controllers/api/usage_controller.ex b/apps/arbiter_web/lib/arbiter_web/controllers/api/usage_controller.ex index 41c95cc7a..77a75241f 100644 --- a/apps/arbiter_web/lib/arbiter_web/controllers/api/usage_controller.ex +++ b/apps/arbiter_web/lib/arbiter_web/controllers/api/usage_controller.ex @@ -5,13 +5,14 @@ defmodule ArbiterWeb.Api.UsageController do Routes: * `GET /api/usage` — aggregated rollup. Required query: `by` (one of - `day | task | epic | workspace | repo | - model | step | provider | source | session`; - `campaign` also accepted as a deprecated alias - for `epic`). Optional: `workspace_id`, `since` - (ISO8601), `limit`. + `day | task | epic | workspace | + provider_account | repo | model | step | + provider | source | session`; `campaign` + also accepted as a deprecated alias for + `epic`). Optional: `workspace_id`, `account`, + `since` (ISO8601), `limit`. * `GET /api/usage/events` — raw event list (newest first). Optional - filters: `workspace_id`, `task_id`, + filters: `workspace_id`, `account`, `task_id`, `session_id`, `since`, `step`, `source`, `limit` (default 50). * `GET /api/usage/calibration` — difficulty mis-rating report (bd-3j4ch4): @@ -23,6 +24,14 @@ defmodule ArbiterWeb.Api.UsageController do `by=task` covers task-attributed spend only — probe / pre-flight / session rows carry no `task_id` (bd-adyhvn). Use `by=source` for the full split. + `account` (P10, `docs/provider-account-design.md` §8) accepts anything + `Arbiter.Accounts.get_account/1` resolves — a UUID, a `"provider:slug"` + ref, or a bare unambiguous slug — and filters + `usage_events.provider_account_id` directly (P9), so probe/pre-flight rows + (no `workspace_id`, but always an account) are included. `by=provider_account` + is the rollup dimension; `account` narrows any rollup or the raw event list + to one account. + Both back the `arb usage` CLI; the rollup is the primary surface (per-day spend, top tasks, rework cost). `events` is for debugging / drill-down. """ @@ -41,11 +50,13 @@ defmodule ArbiterWeb.Api.UsageController do def summarize(conn, params) do with {:ok, by} <- parse_by(params["by"]), {:ok, since} <- parse_since(params["since"]), - {:ok, limit} <- parse_optional_limit(params["limit"]) do + {:ok, limit} <- parse_optional_limit(params["limit"]), + {:ok, account_id} <- parse_account(params["account"]) do opts = [by: by] |> add_opt(:since, since) |> add_opt(:workspace_id, params["workspace_id"]) + |> add_opt(:provider_account_id, account_id) |> add_opt(:limit, limit) case Usage.summarize(opts) do @@ -90,10 +101,12 @@ defmodule ArbiterWeb.Api.UsageController do with {:ok, since} <- parse_since(params["since"]), {:ok, step} <- parse_step(params["step"]), {:ok, source} <- parse_source(params["source"]), - {:ok, limit} <- parse_limit(params["limit"]) do + {:ok, limit} <- parse_limit(params["limit"]), + {:ok, account_id} <- parse_account(params["account"]) do events = Event |> filter_eq(:workspace_id, params["workspace_id"]) + |> filter_eq(:provider_account_id, account_id) |> filter_eq(:task_id, params["task_id"]) |> filter_eq(:session_id, params["session_id"]) |> filter_eq(:step, step) @@ -201,6 +214,9 @@ defmodule ArbiterWeb.Api.UsageController do defp filter_eq(query, _field, value) when value in [nil, ""], do: query defp filter_eq(query, :workspace_id, v), do: Ash.Query.filter(query, workspace_id == ^v) + defp filter_eq(query, :provider_account_id, v), + do: Ash.Query.filter(query, provider_account_id == ^v) + defp filter_eq(query, :task_id, v) do prefix = v <> "#%" Ash.Query.filter(query, task_id == ^v or like(task_id, ^prefix)) @@ -308,4 +324,24 @@ defmodule ArbiterWeb.Api.UsageController do defp parse_optional_limit(nil), do: {:ok, nil} defp parse_optional_limit(""), do: {:ok, nil} defp parse_optional_limit(raw), do: parse_limit(raw) + + # `?account=` resolves the same way `arb account` refs do — a UUID, a + # `"provider:slug"` ref, or a bare unambiguous slug — to an account id, so + # `usage_events.provider_account_id` can be filtered directly (P9). + defp parse_account(nil), do: {:ok, nil} + defp parse_account(""), do: {:ok, nil} + + defp parse_account(ref) when is_binary(ref) do + case Arbiter.Accounts.get_account(ref) do + {:ok, account} -> + {:ok, account.id} + + {:error, :not_found} -> + {:error, {:invalid_request, "account #{inspect(ref)} not found"}} + + {:error, :ambiguous} -> + {:error, + {:invalid_request, "account #{inspect(ref)} is ambiguous; use \"provider:slug\""}} + end + end end diff --git a/apps/arbiter_web/lib/arbiter_web/live/usage_live.ex b/apps/arbiter_web/lib/arbiter_web/live/usage_live.ex index 18fc419d4..a07d7f673 100644 --- a/apps/arbiter_web/lib/arbiter_web/live/usage_live.ex +++ b/apps/arbiter_web/lib/arbiter_web/live/usage_live.ex @@ -33,7 +33,7 @@ defmodule ArbiterWeb.UsageLive do require Ash.Query @ranges ~w(7d 30d all) - @tabs ~w(by_task by_model by_repo) + @tabs ~w(by_task by_model by_repo by_account) @impl true def mount(_params, _session, socket) do @@ -63,6 +63,7 @@ defmodule ArbiterWeb.UsageLive do task_rollup = summarize!(by: :task, since: since) model_rollup = summarize!(by: :model, since: since) repo_rollup = summarize!(by: :repo, since: since) + account_rollup = summarize!(by: :provider_account, since: since) work_sessions = load_work_sessions(since) titles = load_titles(task_rollup) @@ -89,6 +90,7 @@ defmodule ArbiterWeb.UsageLive do bar_rows(model_rollup, grand_cost, &model_hue/2, &ModelDisplay.short/1) ) |> assign(:repo_bars, bar_rows(repo_rollup, grand_cost, &repo_hue/2, &to_string/1)) + |> assign(:account_bars, bar_rows(account_rollup, grand_cost, &repo_hue/2, &account_label/1)) |> assign_overage() end @@ -276,6 +278,18 @@ defmodule ArbiterWeb.UsageLive do defp repo_hue(_repo, 1), do: "var(--arb-info)" defp repo_hue(_repo, _index), do: "var(--arb-done)" + # `Usage.summarize(by: :provider_account)`'s group is an account id (or the + # `"(none)"` sentinel) — resolve it to the slug an operator recognizes, + # falling back to the raw id for an account that has since been deleted. + defp account_label("(none)"), do: "(none)" + + defp account_label(account_id) do + case Arbiter.Accounts.Resolver.get(account_id) do + %{slug: slug} -> slug + nil -> account_id + end + end + defp sum_cost(rollup), do: Enum.reduce(rollup, 0.0, fn r, acc -> acc + (r.total_cost_usd || 0.0) end) @@ -344,7 +358,8 @@ defmodule ArbiterWeb.UsageLive do tabs={[ %{label: "By task", value: "by_task"}, %{label: "By model", value: "by_model"}, - %{label: "By repo", value: "by_repo"} + %{label: "By repo", value: "by_repo"}, + %{label: "By account", value: "by_account"} ]} active={@tab} event="tab" @@ -414,6 +429,19 @@ defmodule ArbiterWeb.UsageLive do No usage events yet. + +
+ <.usage_bar + :for={bar <- @account_bars} + label={bar.label} + value={bar.value} + pct={bar.pct} + hue={bar.hue} + /> + + No usage events yet. + +
@@ -566,6 +594,7 @@ defmodule ArbiterWeb.UsageLive do defp tab_meta("by_task"), do: "by task" defp tab_meta("by_model"), do: "by model" defp tab_meta("by_repo"), do: "by repo" + defp tab_meta("by_account"), do: "by account" defp bucket_pct(_count, 0), do: 0 defp bucket_pct(count, total), do: round(count / total * 100) diff --git a/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex b/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex index 7f343dd67..a4b8adb25 100644 --- a/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex +++ b/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex @@ -50,8 +50,10 @@ defmodule ArbiterWeb.WorkspaceDetail.PolicyConfigComponent do # own write as well as on a parent update: the `{:workspace_updated, _}` a # write sends up is a second round trip, and until it lands the operator # would be looking at the previous tracker type's fields. - defp load_derived(%{assigns: %{workspace: ws}} = socket) do - assign(socket, :tracker_type_preview, cfg(ws, ["tracker", "type"], "none")) + defp load_derived(%{assigns: %{workspace: ws, agent_types: agent_types}} = socket) do + socket + |> assign(:tracker_type_preview, cfg(ws, ["tracker", "type"], "none")) + |> assign(:account_labels, account_labels(ws, agent_types)) end @impl true @@ -331,6 +333,22 @@ defmodule ArbiterWeb.WorkspaceDetail.PolicyConfigComponent do end end + # P10 (`docs/provider-account-design.md` §8, bd-icwk2k): "which account is + # this workspace's Claude on?" has nowhere to answer that question until + # now — `%{"claude" => "personal-max", ...}`, one entry per provider this + # workspace is actually linked to. A read-only lookup off the + # `workspace_provider_accounts` join, so — like `Usage`/`Quota`'s own + # account reads — it needs no `Accounts.enabled?/0` gate: that flag guards + # the *credential* read path, not whether a link can be shown. + defp account_labels(%Workspace{id: ws_id}, agent_types) do + for provider <- agent_types, + account = Arbiter.Accounts.Resolver.account(ws_id, provider), + not is_nil(account), + into: %{} do + {provider, account.slug} + end + end + # Collapse a checkbox selection back to the config shape: a single # provider saves as a scalar string (matching existing single-provider # workspaces), multiple providers save as a pool list. An empty selection @@ -363,6 +381,7 @@ defmodule ArbiterWeb.WorkspaceDetail.PolicyConfigComponent do attr :selected, :list, required: true attr :available, :list, required: true attr :target, :any, required: true + attr :account_labels, :map, default: %{} defp agent_type_editor(assigns) do ~H""" @@ -375,6 +394,13 @@ defmodule ArbiterWeb.WorkspaceDetail.PolicyConfigComponent do > {idx + 1} {type} + + account: {Map.get(@account_labels, type)} +