Publish capacity and matching APIs

6cd31854f2d2 · Devin AI · · parent 90ee9be4b45d

Publish capacity and matching APIs

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

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 config/config.exs
  • modified config/runtime.exs
  • added docs/capacity-and-matching-api.md
  • added lib/openagents/capacity.ex
  • added lib/openagents/capacity/broker.ex
  • added lib/openagents/capacity/catalog.ex
  • added lib/openagents/capacity/connected.ex
  • added lib/openagents/capacity/estimate.ex
  • added lib/openagents/capacity/evidence.ex
  • added lib/openagents/capacity/matcher.ex
  • added lib/openagents/capacity/math.ex
  • added lib/openagents/capacity/requirement.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • added lib/openagents_web/controllers/capacity_controller.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents/capacity_test.exs
  • added test/openagents_web/controllers/capacity_controller_test.exs
  • added test/support/capacity_evidence_stub.ex

Diff

21 files changed, +1604 -0

INVARIANTS.md modified +23

@@ -2105,6 +2105,28 @@ Evidence: `OpenAgents.Stacks`, `OpenAgents.Stacks.Stack`,

2105 2105
`OpenAgents.Stacks.StackEntry`, `OpenAgents.Stacks.OID`,
2106 2106
`ops/ci/stack-contracts.sh`, and `test/openagents/stacks_test.exs`.
2107 2107
2108
### CAPACITY-001 — Capacity is a bounded, owner-safe quantity projection
2109
2110
Status: Current
2111
2112
`OpenAgents.Capacity` publishes logical inventory, active reservations, free
2113
capacity, queue pressure, and evidence freshness as separate quantities. It
2114
does not turn missing or stale evidence into a reported zero, and it refuses
2115
when evidence cannot support a safe decision. A connected customer computer is
2116
available only as an explicit target. Provider topology, credentials, and
2117
workspace content never leave the projection.
2118
2119
The capacity context reads managed evidence through the configured broker
2120
source and reads connected evidence through owner-scoped `machines` and
2121
`work_jobs` queries. `OpenAgents.Capacity.Math` applies the configured ceiling,
2122
reserved headroom, and broker-reported free capacity without subtracting active
2123
reservations twice. The executable proof exercises fresh, stale, private,
2124
redacted, and quantity-based projections.
2125
2126
Evidence: `OpenAgents.Capacity`, `OpenAgents.Capacity.Math`,
2127
`OpenAgents.Capacity.Broker`, `OpenAgents.Capacity.Connected`, and
2128
`test/openagents/capacity_test.exs`.
2129
2108 2130
## Executable proof index
2109 2131
2110 2132
This index is part of the ledger. Every `Current` invariant has at least one

@@ -2190,6 +2212,7 @@ contract; the invariant prose above defines the assertion, not the filename.

2190 2212
| RELEASE-004 | `ops/ci/gate.sh`, `test/openagents/forge/gate_receipt_test.exs` |
2191 2213
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/relup_node_test.exs`, `test/openagents/release/appup_test.exs`, `test/openagents/cluster/code_change_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
2192 2214
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
2215
| CAPACITY-001 | `test/openagents/capacity_test.exs` |
2193 2216
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs` |
2194 2217
| REPOSITORY-001 | `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
2195 2218
| REPOSITORY-002 | `ops/ci/push-remote-check.sh`, `ops/dev/install-push-guard.sh`, `test/openagents/push_remote_contract_test.exs` |
config/config.exs modified +18

@@ -366,6 +366,24 @@ config :openagents, :forum_tips,

366 366
  enabled: false,
367 367
  adapter: OpenAgents.Forum.Tips.PaymentService.Unavailable
368 368
369
config :openagents, OpenAgents.Capacity,
370
  evidence_source: OpenAgents.Capacity.Broker,
371
  broker_url: nil,
372
  broker_token: nil,
373
  broker_timeout_ms: 2_000,
374
  maximum_evidence_age_seconds: 120,
375
  reserved_headroom_fraction: 0.25,
376
  class_ceilings: %{"standard" => 16, "strong" => 2, "batch" => 8},
377
  active_per_conversation: 4,
378
  logical_per_conversation: 30,
379
  unit_cost_usd_cents_per_hour: %{
380
    "standard" => 16,
381
    "strong" => 32,
382
    "batch" => 8,
383
    "connected" => 0
384
  },
385
  buyer: nil
386
369 387
# Import environment specific config. This must remain at the bottom
370 388
# of this file so it overrides the configuration defined above.
371 389
import_config "#{config_env()}.exs"
config/runtime.exs modified +25

@@ -92,6 +92,31 @@ runtime_role =

92 92
93 93
config :openagents, :runtime_role, runtime_role
94 94
95
capacity_config = Application.get_env(:openagents, OpenAgents.Capacity, [])
96
97
capacity_buyer =
98
  case optional_text.("OPENAGENTS_CAPACITY_BUYER_JSON") do
99
    nil ->
100
      nil
101
102
    encoded ->
103
      case Jason.decode(encoded) do
104
        {:ok, buyer} when is_map(buyer) ->
105
          buyer
106
107
        _invalid ->
108
          raise "environment variable OPENAGENTS_CAPACITY_BUYER_JSON must be a JSON object"
109
      end
110
  end
111
112
config :openagents,
113
       OpenAgents.Capacity,
114
       Keyword.merge(capacity_config,
115
         broker_url: optional_text.("OPENAGENTS_CAPACITY_BROKER_URL"),
116
         broker_token: optional_text.("OPENAGENTS_CAPACITY_BROKER_TOKEN"),
117
         buyer: capacity_buyer
118
       )
119
95 120
if config_env() == :dev do
96 121
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
97 122
  config :openagents, :openrouter_api_key, optional_text.("OPENROUTER_API_KEY")
docs/capacity-and-matching-api.md added +250

@@ -0,0 +1,250 @@

1
# Capacity and matching API
2
3
**Status:** Contract  
4
**Tracking issue:** [#76, Publish quantity-based capacity and device-to-job matching APIs](https://openagents.com/OpenAgentsInc/openagents.com/issues/76)  
5
**Source:** [Cloud computer scale architecture audit](2026-08-22-cloud-computer-scale-architecture-audit.md)
6
7
Capacity answers a quantity question, not a presence question. A caller must be
8
able to tell 30 logical computers with four active leases apart from a fleet
9
that can admit 30 more, and it must be able to ask whether one typed job fits
10
before it spends a budget. These endpoints publish those numbers and that
11
decision.
12
13
`OpenAgents.Capacity` is a projection, not an authority. Managed runtime
14
capacity comes from the capacity and quota broker in the private control plane,
15
and connected-computer capacity comes from the `machines` and `work_jobs`
16
records this application already owns. Neither number is recomputed here, and
17
this module never admits, reserves, or leases anything.
18
19
## Endpoints
20
21
| Endpoint | Principal | Purpose |
22
| --- | --- | --- |
23
| `GET /api/capacity` | Signed-in account session | The projection the web surface reads |
24
| `GET /api/v3/capacity` | `chat:account` bearer token | The same projection for agents |
25
| `POST /api/v3/capacity/matches` | `chat:account` bearer token | Ranked candidates or a typed refusal for one job requirement |
26
27
Both capacity projections come from one function, so a web reader and an API
28
reader with the same authority see the same numbers.
29
30
## Runtime classes
31
32
A class is a product profile. Provider names, project identifiers, regions,
33
zones, clusters, hosts, guest addresses, credentials, and image paths never
34
appear in a response.
35
36
| Class | Isolation | Data location | Notes |
37
| --- | --- | --- | --- |
38
| `standard` | `managed_standard` | `openagents_managed` | Pooled managed runtime for ordinary work |
39
| `strong` | `managed_strong` | `openagents_managed` | Stronger isolation for arbitrary native code |
40
| `batch` | `managed_standard` | `openagents_managed` | One-shot work with no retained interactive runtime |
41
| `connected` | `customer_controlled` | `customer_premises` | A computer the caller connected. Always an explicit target |
42
43
A `managed_strong` requirement admits only `strong`. A `managed_standard`
44
requirement admits `standard`, `batch`, and `strong`. A `customer_controlled`
45
requirement admits only `connected`, and `connected` never appears as a
46
candidate for a managed requirement, so a customer's own hardware can never
47
become an implicit fallback.
48
49
Evidence can mark a class private. A private class is omitted from every
50
response instead of being reported as empty.
51
52
## Capacity projection
53
54
Schema `openagents.capacity.v1`:
55
56
```json
57
{
58
  "schema": "openagents.capacity.v1",
59
  "generated_at": "2026-08-23T04:00:00Z",
60
  "limits": {
61
    "reserved_headroom_fraction": 0.25,
62
    "active_per_conversation": 4,
63
    "logical_per_conversation": 30
64
  },
65
  "classes": [
66
    {
67
      "id": "standard",
68
      "label": "Standard",
69
      "isolation": "managed_standard",
70
      "egress": "policy_broker",
71
      "data_location": "openagents_managed",
72
      "explicit_target_only": false,
73
      "unit": {"vcpu": 1, "memory_gib": 2, "scratch_gib": 20},
74
      "quantities": {
75
        "logical": 30,
76
        "active_reservations": 4,
77
        "allocatable": 8,
78
        "queued": 2,
79
        "safety_headroom": 6,
80
        "configured_ceiling": 16,
81
        "observed_limit": 24
82
      },
83
      "queue": {"queued": 2, "estimated_wait_seconds": {"low": 5, "high": 90}},
84
      "evidence": {
85
        "source": "broker",
86
        "observed_at": "2026-08-23T03:59:48Z",
87
        "age_seconds": 12,
88
        "maximum_age_seconds": 120,
89
        "freshness": "fresh"
90
      },
91
      "admits": true,
92
      "refusal": null
93
    }
94
  ]
95
}
96
```
97
98
Quantity rules:
99
100
- `logical` counts durable records, and `active_reservations` counts current
101
  leases. They are separate numbers and neither implies the other.
102
- `allocatable` is
103
  `max(0, min(effective_limit - active_reservations, reported_free))`, where
104
  `effective_limit` is the smallest of the configured ceiling, the observed
105
  limit minus reserved headroom, the budget limit, and any incident or drain
106
  limit. `reported_free` is the free capacity the evidence reports, which
107
  already excludes active reservations, so the projection caps the limit-derived
108
  number with it instead of subtracting reservations twice. A missing
109
  `reported_free` leaves the limit-derived number as it is.
110
- `safety_headroom` reports the reserve the projection withheld, so a reader can
111
  see why `allocatable` is smaller than the raw observation.
112
- Every quantity is a non-negative integer. A missing observation renders as
113
  `null`, never as `0`.
114
115
Freshness rules:
116
117
- `fresh`: the observation is within `maximum_age_seconds`.
118
- `stale`: the observation is older. `admits` is `false`, `allocatable` is `0`,
119
  and `refusal` carries `evidence_stale`.
120
- `unavailable`: no observation exists, the broker is unconfigured, or the
121
  broker call failed. `admits` is `false`, quantities are `null`, and `refusal`
122
  carries `evidence_unavailable`.
123
124
An incident drain reports `admits: false` with the `incident_drained` refusal
125
code while quantities stay visible.
126
127
## Matching
128
129
`POST /api/v3/capacity/matches` takes one typed requirement:
130
131
```json
132
{
133
  "requirement": {
134
    "quantity": 3,
135
    "isolation": "managed_standard",
136
    "egress": "policy_broker",
137
    "data_location": "openagents_managed",
138
    "target": "openagents_managed",
139
    "tools": ["shell", "coding_agent"],
140
    "duration_seconds": 900,
141
    "budget": {"currency": "usd_cents", "amount": 250}
142
  }
143
}
144
```
145
146
`quantity` defaults to `1`, `target` defaults to `openagents_managed`, and a
147
`customer_computer` target requires an explicit `computer_id` the caller owns.
148
149
A match returns schema `openagents.capacity_match.v1` with ranked candidates and
150
the typed reason each other class was excluded:
151
152
```json
153
{
154
  "schema": "openagents.capacity_match.v1",
155
  "generated_at": "2026-08-23T04:00:00Z",
156
  "requirement": {"quantity": 3, "isolation": "managed_standard", "...": "normalized"},
157
  "candidates": [
158
    {
159
      "class": "standard",
160
      "rank": 1,
161
      "admissible_quantity": 3,
162
      "quantities": {"allocatable": 8, "queued": 2},
163
      "evidence": {"freshness": "fresh", "age_seconds": 12},
164
      "estimate": {
165
        "cost": {"currency": "usd_cents", "low": 12, "high": 48, "basis": "requested_quantity"},
166
        "completion_seconds": {"low": 120, "high": 960},
167
        "confidence": "medium",
168
        "evidence_age_seconds": 12,
169
        "assumptions": [
170
          "One unit of the class runs the whole job.",
171
          "Queue wait uses the current observed queue depth."
172
        ],
173
        "earnings": null,
174
        "earnings_reason": "no_named_buyer"
175
      }
176
    }
177
  ],
178
  "excluded": [
179
    {"class": "strong", "code": "quantity_unavailable", "detail": "The class admits 1 of 3 requested units."},
180
    {"class": "connected", "code": "explicit_target_required", "detail": "A connected computer is never an implicit target."}
181
  ]
182
}
183
```
184
185
Candidates rank by fresh evidence first, then by whether the class admits the
186
whole requested quantity, then by lower estimated cost, then by shorter
187
estimated completion. Ranking is routing, not authorization.
188
189
Estimates are bounds with stated assumptions and the age of the evidence behind
190
them. An estimate never reports earnings unless configuration names a buyer and
191
records a verified payout policy. Without both, `earnings` is `null` and
192
`earnings_reason` explains which one is missing.
193
194
## Typed refusals
195
196
A refusal returns schema `openagents.capacity_refusal.v1`:
197
198
```json
199
{
200
  "schema": "openagents.capacity_refusal.v1",
201
  "error": {"code": "unsupported_isolation", "detail": "No admitted class provides managed_confidential."}
202
}
203
```
204
205
| Code | Status | Meaning |
206
| --- | --- | --- |
207
| `invalid_requirement` | `422` | The body is missing, malformed, or out of bounds |
208
| `unsupported_isolation` | `422` | No class provides the requested isolation |
209
| `unsupported_egress` | `422` | No class provides the requested egress policy |
210
| `unsupported_data_location` | `422` | No class runs in the requested location |
211
| `unsupported_tool` | `422` | No class admits a requested tool category |
212
| `budget_below_minimum` | `422` | The budget cannot cover one unit for the requested duration |
213
| `explicit_target_required` | `422` | The requirement needs an explicit connected-computer target |
214
| `computer_not_found` | `404` | The named connected computer is not the caller's |
215
| `quantity_unavailable` | `409` | Every otherwise admitted class lacks the requested quantity |
216
| `evidence_stale` | `503` | The only otherwise admitted classes carry stale evidence |
217
| `evidence_unavailable` | `503` | No capacity evidence exists for any admitted class |
218
219
A refusal always names one code. A caller never receives an empty candidate list
220
with a `200` status.
221
222
## Redaction
223
224
The projection copies only the fields this contract names. It drops every other
225
field a broker reports, including provider names, project identifiers, regions
226
and zones, cluster and host references, guest addresses, credentials, image
227
paths, raw provider errors, and customer workspace content. Sensitive regions
228
are dropped rather than coarsened.
229
230
## Configuration
231
232
Configure the projection under `config :openagents, OpenAgents.Capacity`:
233
234
| Key | Default | Purpose |
235
| --- | --- | --- |
236
| `evidence_source` | `OpenAgents.Capacity.Broker` | The module that reads broker evidence |
237
| `broker_url` | `nil` | The capacity broker base URL. Unset reports `evidence_unavailable` |
238
| `broker_token` | `nil` | The bearer token for the broker |
239
| `broker_timeout_ms` | `2000` | Bounded request timeout |
240
| `maximum_evidence_age_seconds` | `120` | The freshness boundary |
241
| `reserved_headroom_fraction` | `0.25` | The reserve withheld from an observed limit |
242
| `class_ceilings` | `%{"standard" => 16, "strong" => 2, "batch" => 8}` | Configured safety ceilings |
243
| `active_per_conversation` | `4` | Active runtimes admitted for one conversation |
244
| `logical_per_conversation` | `30` | Logical computers admitted for one conversation |
245
| `unit_cost_usd_cents_per_hour` | per class | Cost bounds used by estimates |
246
| `buyer` | `nil` | A named buyer and its verified payout policy |
247
248
The broker stays unconfigured by default, so a deployment without the private
249
control plane publishes honest `evidence_unavailable` classes instead of
250
inventing numbers.
lib/openagents/capacity.ex added +334

@@ -0,0 +1,334 @@

1
defmodule OpenAgents.Capacity do
2
  @moduledoc """
3
  Publishes a bounded, read-only capacity projection and typed matching decisions.
4
5
  Managed capacity remains owned by the private broker. Connected-computer
6
  evidence comes from this application's owner-scoped records.
7
  """
8
9
  alias OpenAgents.Capacity.{Catalog, Connected, Estimate, Math, Matcher, Requirement}
10
  alias OpenAgents.Machines
11
12
  @schema "openagents.capacity.v1"
13
  @match_schema "openagents.capacity_match.v1"
14
  @refusal_schema "openagents.capacity_refusal.v1"
15
16
  @spec projection(map()) :: map()
17
  def projection(viewer) do
18
    config = config()
19
    broker = fetch_broker(config, viewer)
20
    connected = fetch_connected(viewer)
21
    evidence = merge_evidence(broker, connected)
22
    generated_at = now()
23
24
    classes =
25
      Catalog.all()
26
      |> Enum.flat_map(fn catalog ->
27
        case Map.get(evidence, catalog["id"]) do
28
          :private -> []
29
          raw -> [class_projection(catalog, raw, config)]
30
        end
31
      end)
32
33
    %{
34
      "schema" => @schema,
35
      "generated_at" => generated_at,
36
      "limits" => %{
37
        "reserved_headroom_fraction" => Keyword.get(config, :reserved_headroom_fraction, 0.25),
38
        "active_per_conversation" => Keyword.get(config, :active_per_conversation, 4),
39
        "logical_per_conversation" => Keyword.get(config, :logical_per_conversation, 30)
40
      },
41
      "classes" => classes
42
    }
43
  end
44
45
  @spec match(map(), map()) :: {:ok, map()} | {:error, map()}
46
  def match(viewer, raw_requirement) do
47
    config = config()
48
49
    with {:ok, requirement} <- normalize_requirement(raw_requirement),
50
         :ok <- verify_tools(requirement),
51
         :ok <- verify_target(viewer, requirement),
52
         :ok <- verify_budget(requirement, config) do
53
      projection = projection(viewer)
54
      result = Matcher.match(projection, requirement, config)
55
56
      if result.candidates == [] do
57
        {:error, refusal_for(result.excluded)}
58
      else
59
        {:ok,
60
         %{
61
           "schema" => @match_schema,
62
           "generated_at" => projection["generated_at"],
63
           "requirement" => requirement,
64
           "candidates" => result.candidates,
65
           "excluded" => result.excluded
66
         }}
67
      end
68
    else
69
      {:error, code, detail} -> {:error, refusal(code, detail)}
70
    end
71
  end
72
73
  def refusal_schema, do: @refusal_schema
74
75
  defp normalize_requirement(raw_requirement) do
76
    case Requirement.normalize(raw_requirement) do
77
      {:ok, requirement} -> {:ok, requirement}
78
      {:error, code, detail} -> {:error, code, detail}
79
    end
80
  end
81
82
  defp verify_tools(requirement) do
83
    if Enum.all?(requirement["tools"], fn tool ->
84
         Enum.any?(Catalog.all(), &(tool in &1["tools"]))
85
       end) do
86
      :ok
87
    else
88
      {:error, :unsupported_tool, "No admitted class supports every requested tool."}
89
    end
90
  end
91
92
  defp verify_target(_viewer, %{"target" => "openagents_managed"}), do: :ok
93
94
  defp verify_target(%{id: user_id}, %{"target" => "customer_computer", "computer_id" => id}) do
95
    case Machines.get_machine(user_id, id) do
96
      {:ok, _machine} ->
97
        :ok
98
99
      {:error, :machine_not_found} ->
100
        {:error, :computer_not_found, "The named computer is not yours."}
101
    end
102
  end
103
104
  defp verify_target(_viewer, _requirement),
105
    do:
106
      {:error, :explicit_target_required,
107
       "A customer computer target requires an owned computer_id."}
108
109
  defp verify_budget(%{"budget" => nil}, _config), do: :ok
110
111
  defp verify_budget(requirement, config) do
112
    compatible =
113
      Catalog.all()
114
      |> Enum.filter(fn class ->
115
        compatible_isolation?(class["id"], requirement["isolation"]) and
116
          class["egress"] == requirement["egress"] and
117
          class["data_location"] == requirement["data_location"] and
118
          Enum.all?(requirement["tools"], &(&1 in class["tools"]))
119
      end)
120
121
    minimum =
122
      compatible
123
      |> Enum.map(&Estimate.unit_cost(&1["id"], config))
124
      |> Enum.min(fn -> 0 end)
125
      |> then(&ceil(&1 * requirement["quantity"] * requirement["duration_seconds"] / 3_600))
126
127
    if requirement["budget"]["amount"] < minimum do
128
      {:error, :budget_below_minimum,
129
       "The budget cannot cover one admitted class for the requested duration."}
130
    else
131
      :ok
132
    end
133
  end
134
135
  defp compatible_isolation?("strong", isolation)
136
       when isolation in ["managed_standard", "managed_strong"],
137
       do: true
138
139
  defp compatible_isolation?(class_id, "managed_standard") when class_id in ["standard", "batch"],
140
    do: true
141
142
  defp compatible_isolation?("connected", "customer_controlled"), do: true
143
  defp compatible_isolation?(_class_id, _isolation), do: false
144
145
  defp refusal_for([]),
146
    do: refusal(:quantity_unavailable, "No admitted class can satisfy the requested quantity.")
147
148
  defp refusal_for(excluded) do
149
    codes =
150
      excluded
151
      |> Enum.reject(&(&1["code"] == "explicit_target_required"))
152
      |> Enum.map(& &1["code"])
153
154
    code =
155
      cond do
156
        "incident_drained" in codes -> :incident_drained
157
        "evidence_stale" in codes -> :evidence_stale
158
        "quantity_unavailable" in codes -> :quantity_unavailable
159
        "evidence_unavailable" in codes -> :evidence_unavailable
160
        true -> :quantity_unavailable
161
      end
162
163
    detail =
164
      excluded
165
      |> Enum.find(&(&1["code"] == Atom.to_string(code)))
166
      |> case do
167
        %{"detail" => detail} -> detail
168
        nil -> "No admitted class can satisfy the requested requirement."
169
      end
170
171
    refusal(code, detail)
172
  end
173
174
  defp refusal(code, detail) when is_atom(code),
175
    do: %{
176
      "schema" => @refusal_schema,
177
      "error" => %{"code" => Atom.to_string(code), "detail" => detail}
178
    }
179
180
  defp class_projection(catalog, :unavailable, _config),
181
    do: unavailable_class(catalog, "evidence_unavailable")
182
183
  defp class_projection(catalog, nil, _config),
184
    do: unavailable_class(catalog, "evidence_unavailable")
185
186
  defp class_projection(catalog, raw, config) when is_map(raw) do
187
    quantities = Math.quantities(catalog, raw, config)
188
    observed_at = parse_datetime(raw["observed_at"])
189
    age_seconds = age_seconds(observed_at)
190
    maximum_age = Keyword.get(config, :maximum_evidence_age_seconds, 120)
191
    freshness = freshness(observed_at, age_seconds, maximum_age)
192
    incident_drained = raw["incident_drained"] == true
193
194
    refusal_code =
195
      cond do
196
        freshness == "unavailable" -> "evidence_unavailable"
197
        freshness == "stale" -> "evidence_stale"
198
        incident_drained -> "incident_drained"
199
        true -> nil
200
      end
201
202
    quantities =
203
      if freshness == "stale" do
204
        Map.put(quantities, "allocatable", 0)
205
      else
206
        quantities
207
      end
208
209
    %{
210
      "id" => catalog["id"],
211
      "label" => catalog["label"],
212
      "isolation" => catalog["isolation"],
213
      "egress" => catalog["egress"],
214
      "data_location" => catalog["data_location"],
215
      "explicit_target_only" => catalog["explicit_target_only"],
216
      "unit" => catalog["unit"],
217
      "quantities" => quantities,
218
      "queue" => %{
219
        "queued" => quantities["queued"],
220
        "estimated_wait_seconds" => safe_wait(raw["estimated_wait_seconds"])
221
      },
222
      "evidence" => %{
223
        "source" => if(catalog["id"] == "connected", do: "local", else: "broker"),
224
        "observed_at" => raw["observed_at"],
225
        "age_seconds" => age_seconds,
226
        "maximum_age_seconds" => maximum_age,
227
        "freshness" => freshness
228
      },
229
      "admits" => freshness == "fresh" and not incident_drained,
230
      "refusal" => if(refusal_code, do: %{"code" => refusal_code}, else: nil)
231
    }
232
  end
233
234
  defp unavailable_class(catalog, code) do
235
    %{
236
      "id" => catalog["id"],
237
      "label" => catalog["label"],
238
      "isolation" => catalog["isolation"],
239
      "egress" => catalog["egress"],
240
      "data_location" => catalog["data_location"],
241
      "explicit_target_only" => catalog["explicit_target_only"],
242
      "unit" => catalog["unit"],
243
      "quantities" => %{
244
        "logical" => nil,
245
        "active_reservations" => nil,
246
        "allocatable" => nil,
247
        "queued" => nil,
248
        "safety_headroom" => nil,
249
        "configured_ceiling" => nil,
250
        "observed_limit" => nil
251
      },
252
      "queue" => %{"queued" => nil, "estimated_wait_seconds" => nil},
253
      "evidence" => %{
254
        "source" => if(catalog["id"] == "connected", do: "local", else: "broker"),
255
        "observed_at" => nil,
256
        "age_seconds" => nil,
257
        "maximum_age_seconds" => Keyword.get(config(), :maximum_evidence_age_seconds, 120),
258
        "freshness" => "unavailable"
259
      },
260
      "admits" => false,
261
      "refusal" => %{"code" => code}
262
    }
263
  end
264
265
  defp fetch_broker(config, viewer) do
266
    source = Keyword.get(config, :evidence_source, OpenAgents.Capacity.Broker)
267
268
    result =
269
      try do
270
        Code.ensure_loaded(source)
271
272
        cond do
273
          function_exported?(source, :fetch, 1) -> source.fetch(viewer)
274
          function_exported?(source, :read, 1) -> source.read(viewer)
275
          true -> {:error, :evidence_unavailable}
276
        end
277
      rescue
278
        _error -> {:error, :evidence_unavailable}
279
      end
280
281
    case result do
282
      {:ok, %{"classes" => classes}} when is_list(classes) ->
283
        Enum.reduce(classes, %{}, fn raw, acc ->
284
          if is_map(raw) and is_binary(raw["id"]) do
285
            Map.put(acc, raw["id"], if(raw["private"] == true, do: :private, else: raw))
286
          else
287
            acc
288
          end
289
        end)
290
291
      classes when is_map(classes) ->
292
        classes
293
294
      _error ->
295
        %{}
296
    end
297
  end
298
299
  defp fetch_connected(viewer) do
300
    case Connected.fetch(viewer) do
301
      {:ok, %{"classes" => [raw | _]}} -> %{"connected" => raw}
302
      _error -> %{}
303
    end
304
  end
305
306
  defp merge_evidence(broker, connected), do: Map.merge(broker, connected)
307
308
  defp config, do: Application.get_env(:openagents, OpenAgents.Capacity, [])
309
310
  defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
311
312
  defp parse_datetime(value) when is_binary(value) do
313
    case DateTime.from_iso8601(value) do
314
      {:ok, datetime, _offset} -> datetime
315
      _invalid -> nil
316
    end
317
  end
318
319
  defp parse_datetime(%DateTime{} = value), do: value
320
  defp parse_datetime(_invalid), do: nil
321
322
  defp age_seconds(nil), do: nil
323
  defp age_seconds(datetime), do: max(DateTime.diff(DateTime.utc_now(), datetime, :second), 0)
324
325
  defp freshness(nil, _age, _maximum), do: "unavailable"
326
  defp freshness(_datetime, age, maximum) when age <= maximum, do: "fresh"
327
  defp freshness(_datetime, _age, _maximum), do: "stale"
328
329
  defp safe_wait(%{"low" => low, "high" => high})
330
       when is_integer(low) and low >= 0 and is_integer(high) and high >= low,
331
       do: %{"low" => low, "high" => high}
332
333
  defp safe_wait(_invalid), do: nil
334
end
lib/openagents/capacity/broker.ex added +76

@@ -0,0 +1,76 @@

1
defmodule OpenAgents.Capacity.Broker do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Capacity.Evidence
5
6
  @impl true
7
  def fetch(_viewer) do
8
    config = Application.get_env(:openagents, OpenAgents.Capacity, [])
9
    url = Keyword.get(config, :broker_url)
10
11
    if is_binary(url) and String.trim(url) != "" do
12
      request_options = [
13
        url: String.trim_trailing(url, "/") <> "/capacity",
14
        method: :get,
15
        receive_timeout: Keyword.get(config, :broker_timeout_ms, 2_000),
16
        retry: false
17
      ]
18
19
      request_options =
20
        case Keyword.get(config, :broker_token) do
21
          token when is_binary(token) and token != "" ->
22
            Keyword.put(request_options, :headers, [{"authorization", "Bearer " <> token}])
23
24
          _missing ->
25
            request_options
26
        end
27
28
      case Req.request(request_options) do
29
        {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
30
          parse(body)
31
32
        {:ok, %Req.Response{}} ->
33
          {:error, :broker_unavailable}
34
35
        {:error, _reason} ->
36
          {:error, :broker_unavailable}
37
      end
38
    else
39
      {:error, :unconfigured}
40
    end
41
  end
42
43
  defp parse(%{"classes" => classes}) when is_list(classes) do
44
    {:ok, %{"classes" => Enum.flat_map(classes, &parse_class/1)}}
45
  end
46
47
  defp parse(_invalid), do: {:error, :invalid_broker_response}
48
49
  defp parse_class(class) when is_map(class) do
50
    id = Map.get(class, "id")
51
52
    if is_binary(id) do
53
      allowed = [
54
        "id",
55
        "logical",
56
        "active_reservations",
57
        "reported_free",
58
        "queued",
59
        "observed_limit",
60
        "budget_limit",
61
        "drain_limit",
62
        "observed_at",
63
        "estimated_wait_seconds",
64
        "private",
65
        "incident_drained"
66
      ]
67
68
      {Map.take(class, allowed), id}
69
      |> then(fn {safe, _id} -> [safe] end)
70
    else
71
      []
72
    end
73
  end
74
75
  defp parse_class(_invalid), do: []
76
end
lib/openagents/capacity/catalog.ex added +50

@@ -0,0 +1,50 @@

1
defmodule OpenAgents.Capacity.Catalog do
2
  @moduledoc false
3
4
  @classes [
5
    %{
6
      "id" => "standard",
7
      "label" => "Standard",
8
      "isolation" => "managed_standard",
9
      "egress" => "policy_broker",
10
      "data_location" => "openagents_managed",
11
      "explicit_target_only" => false,
12
      "unit" => %{"vcpu" => 1, "memory_gib" => 2, "scratch_gib" => 20},
13
      "tools" => ["shell", "coding_agent"]
14
    },
15
    %{
16
      "id" => "strong",
17
      "label" => "Strong",
18
      "isolation" => "managed_strong",
19
      "egress" => "policy_broker",
20
      "data_location" => "openagents_managed",
21
      "explicit_target_only" => false,
22
      "unit" => %{"vcpu" => 2, "memory_gib" => 4, "scratch_gib" => 40},
23
      "tools" => ["shell", "coding_agent"]
24
    },
25
    %{
26
      "id" => "batch",
27
      "label" => "Batch",
28
      "isolation" => "managed_standard",
29
      "egress" => "policy_broker",
30
      "data_location" => "openagents_managed",
31
      "explicit_target_only" => false,
32
      "unit" => %{"vcpu" => 1, "memory_gib" => 2, "scratch_gib" => 20},
33
      "tools" => ["shell", "coding_agent"]
34
    },
35
    %{
36
      "id" => "connected",
37
      "label" => "Connected",
38
      "isolation" => "customer_controlled",
39
      "egress" => "customer_network",
40
      "data_location" => "customer_premises",
41
      "explicit_target_only" => true,
42
      "unit" => %{"vcpu" => nil, "memory_gib" => nil, "scratch_gib" => nil},
43
      "tools" => ["shell", "coding_agent"]
44
    }
45
  ]
46
47
  def all, do: @classes
48
49
  def get(id), do: Enum.find(@classes, &(&1["id"] == id))
50
end
lib/openagents/capacity/connected.ex added +63

@@ -0,0 +1,63 @@

1
defmodule OpenAgents.Capacity.Connected do
2
  @moduledoc false
3
4
  import Ecto.Query
5
6
  alias OpenAgents.Conversations.{Visitor}
7
  alias OpenAgents.Machines
8
  alias OpenAgents.Repo
9
  alias OpenAgents.Work.Job
10
11
  @behaviour OpenAgents.Capacity.Evidence
12
13
  @impl true
14
  def fetch(%{id: user_id}) when is_binary(user_id) do
15
    machines = Machines.list_machines(user_id)
16
    logical = length(machines)
17
    machine_ids = Enum.map(machines, & &1.id)
18
19
    active_reservations =
20
      Repo.aggregate(
21
        from(job in Job,
22
          join: visitor in Visitor,
23
          on: visitor.id == job.owner_visitor_id,
24
          where:
25
            visitor.user_id == ^user_id and job.machine_id in ^machine_ids and
26
              job.status in ["queued", "running"]
27
        ),
28
        :count
29
      )
30
31
    queued =
32
      Repo.aggregate(
33
        from(job in Job,
34
          join: visitor in Visitor,
35
          on: visitor.id == job.owner_visitor_id,
36
          where:
37
            visitor.user_id == ^user_id and job.machine_id in ^machine_ids and
38
              job.status == "queued"
39
        ),
40
        :count
41
      )
42
43
    observed_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
44
45
    {:ok,
46
     %{
47
       "classes" => [
48
         %{
49
           "id" => "connected",
50
           "logical" => logical,
51
           "active_reservations" => active_reservations,
52
           "reported_free" => max(logical - active_reservations, 0),
53
           "queued" => queued,
54
           "observed_limit" => logical,
55
           "observed_at" => observed_at,
56
           "estimated_wait_seconds" => %{"low" => 0, "high" => 0}
57
         }
58
       ]
59
     }}
60
  end
61
62
  def fetch(_viewer), do: {:error, :invalid_viewer}
63
end
lib/openagents/capacity/estimate.ex added +64

@@ -0,0 +1,64 @@

1
defmodule OpenAgents.Capacity.Estimate do
2
  @moduledoc false
3
4
  def build(class, requirement, queued, age_seconds, config) do
5
    hourly = unit_cost(class["id"], config)
6
    base = ceil(hourly * requirement["quantity"] * requirement["duration_seconds"] / 3_600)
7
    wait_low = if is_integer(queued), do: queued * 30, else: 0
8
    wait_high = if is_integer(queued), do: queued * 180, else: 180
9
    buyer = Keyword.get(config, :buyer)
10
    earnings = earnings(buyer, base, requirement)
11
12
    %{
13
      "cost" => %{
14
        "currency" => "usd_cents",
15
        "low" => base,
16
        "high" => max(base, base * 4),
17
        "basis" => "requested_quantity"
18
      },
19
      "completion_seconds" => %{
20
        "low" => 120 + wait_low,
21
        "high" => 960 + wait_high
22
      },
23
      "confidence" => if(age_seconds && age_seconds <= 120, do: "medium", else: "low"),
24
      "evidence_age_seconds" => age_seconds,
25
      "assumptions" => [
26
        "One unit of the class runs the whole job.",
27
        "Queue wait uses the current observed queue depth."
28
      ],
29
      "earnings" => earnings[:value],
30
      "earnings_reason" => earnings[:reason]
31
    }
32
  end
33
34
  def unit_cost(class_id, config) do
35
    costs =
36
      Keyword.get(config, :unit_cost_usd_cents_per_hour, %{
37
        "standard" => 16,
38
        "strong" => 32,
39
        "batch" => 8,
40
        "connected" => 0
41
      })
42
43
    value = costs[class_id] || 0
44
    if is_number(value) and value >= 0, do: value, else: 0
45
  end
46
47
  defp earnings(buyer, base, _requirement) when is_map(buyer) do
48
    if is_binary(buyer["name"]) and buyer["name"] != "" and
49
         buyer["verified_payout_policy"] == true do
50
      %{value: %{"currency" => "usd_cents", "low" => base, "high" => base * 2}, reason: nil}
51
    else
52
      %{
53
        value: nil,
54
        reason:
55
          if(is_binary(buyer["name"]) and buyer["name"] != "",
56
            do: "no_verified_payout_policy",
57
            else: "no_named_buyer"
58
          )
59
      }
60
    end
61
  end
62
63
  defp earnings(_buyer, _base, _requirement), do: %{value: nil, reason: "no_named_buyer"}
64
end
lib/openagents/capacity/evidence.ex added +8

@@ -0,0 +1,8 @@

1
defmodule OpenAgents.Capacity.Evidence do
2
  @moduledoc false
3
4
  @callback fetch(term()) :: {:ok, map()} | {:error, term()}
5
  @callback read(term()) :: {:ok, map()} | {:error, term()}
6
7
  @optional_callbacks read: 1
8
end
lib/openagents/capacity/matcher.ex added +115

@@ -0,0 +1,115 @@

1
defmodule OpenAgents.Capacity.Matcher do
2
  @moduledoc false
3
4
  alias OpenAgents.Capacity.{Catalog, Estimate}
5
6
  def match(projection, requirement, config) do
7
    classes = projection["classes"]
8
    compatible = Enum.filter(classes, &compatible?(&1, requirement))
9
10
    candidates =
11
      compatible
12
      |> Enum.filter(&admissible?(&1, requirement["quantity"]))
13
      |> Enum.map(fn class ->
14
        %{
15
          "class" => class["id"],
16
          "admissible_quantity" => requirement["quantity"],
17
          "quantities" => %{
18
            "allocatable" => class["quantities"]["allocatable"],
19
            "queued" => class["quantities"]["queued"]
20
          },
21
          "evidence" => %{
22
            "freshness" => class["evidence"]["freshness"],
23
            "age_seconds" => class["evidence"]["age_seconds"]
24
          },
25
          "estimate" =>
26
            Estimate.build(
27
              class,
28
              requirement,
29
              class["quantities"]["queued"],
30
              class["evidence"]["age_seconds"],
31
              config
32
            )
33
        }
34
      end)
35
      |> Enum.sort_by(&sort_key(&1))
36
      |> Enum.with_index(1)
37
      |> Enum.map(fn {candidate, rank} -> Map.put(candidate, "rank", rank) end)
38
39
    excluded =
40
      classes
41
      |> Enum.reject(&Enum.any?(candidates, fn candidate -> candidate["class"] == &1["id"] end))
42
      |> Enum.map(&exclusion(&1, requirement))
43
44
    %{candidates: candidates, excluded: excluded}
45
  end
46
47
  defp compatible?(class, requirement) do
48
    isolation_compatible?(class["id"], requirement["isolation"]) and
49
      class["egress"] == requirement["egress"] and
50
      class["data_location"] == requirement["data_location"] and
51
      Enum.all?(requirement["tools"], &(&1 in Catalog.get(class["id"])["tools"])) and
52
      (requirement["target"] == "customer_computer" or not class["explicit_target_only"])
53
  end
54
55
  defp isolation_compatible?("strong", "managed_strong"), do: true
56
  defp isolation_compatible?("strong", "managed_standard"), do: true
57
  defp isolation_compatible?(class_id, "managed_standard"), do: class_id in ["standard", "batch"]
58
  defp isolation_compatible?("connected", "customer_controlled"), do: true
59
  defp isolation_compatible?(_class_id, _isolation), do: false
60
61
  defp admissible?(class, quantity) do
62
    class["admits"] == true and is_integer(class["quantities"]["allocatable"]) and
63
      class["quantities"]["allocatable"] >= quantity
64
  end
65
66
  defp exclusion(class, requirement) do
67
    cond do
68
      requirement["target"] != "customer_computer" and class["explicit_target_only"] ->
69
        %{
70
          "class" => class["id"],
71
          "code" => "explicit_target_required",
72
          "detail" => "A connected computer is never an implicit target."
73
        }
74
75
      class["evidence"]["freshness"] == "unavailable" ->
76
        %{
77
          "class" => class["id"],
78
          "code" => "evidence_unavailable",
79
          "detail" => "The class has no capacity evidence."
80
        }
81
82
      class["evidence"]["freshness"] == "stale" ->
83
        %{
84
          "class" => class["id"],
85
          "code" => "evidence_stale",
86
          "detail" => "The class capacity evidence is stale."
87
        }
88
89
      class["admits"] == false and class["refusal"]["code"] == "incident_drained" ->
90
        %{
91
          "class" => class["id"],
92
          "code" => "incident_drained",
93
          "detail" => "The class is incident-drained."
94
        }
95
96
      true ->
97
        available = class["quantities"]["allocatable"] || 0
98
99
        %{
100
          "class" => class["id"],
101
          "code" => "quantity_unavailable",
102
          "detail" =>
103
            "The class admits #{available} of #{requirement["quantity"]} requested units."
104
        }
105
    end
106
  end
107
108
  defp sort_key(candidate) do
109
    estimate = candidate["estimate"]
110
111
    {if(candidate["evidence"]["freshness"] == "fresh", do: 0, else: 1),
112
     if(candidate["admissible_quantity"] > 0, do: 0, else: 1), estimate["cost"]["low"],
113
     estimate["completion_seconds"]["low"]}
114
  end
115
end
lib/openagents/capacity/math.ex added +50

@@ -0,0 +1,50 @@

1
defmodule OpenAgents.Capacity.Math do
2
  @moduledoc false
3
4
  def quantities(class, evidence, config) do
5
    logical = integer_or_nil(evidence["logical"])
6
    active = integer_or_nil(evidence["active_reservations"])
7
    reported_free = integer_or_nil(evidence["reported_free"])
8
    observed = integer_or_nil(evidence["observed_limit"])
9
    budget_limit = integer_or_nil(evidence["budget_limit"])
10
    drain_limit = integer_or_nil(evidence["drain_limit"])
11
    ceiling = integer_or_nil(Keyword.get(config, :class_ceilings, %{})[class["id"]])
12
    fraction = Keyword.get(config, :reserved_headroom_fraction, 0.25)
13
    safety_headroom = if is_integer(observed), do: floor(observed * fraction), else: nil
14
15
    effective_limit =
16
      [ceiling, subtract(observed, safety_headroom), budget_limit, drain_limit]
17
      |> Enum.reject(&is_nil/1)
18
      |> case do
19
        [] -> nil
20
        values -> Enum.min(values)
21
      end
22
23
    limit_derived = subtract(effective_limit, active)
24
25
    allocatable =
26
      case {limit_derived, reported_free} do
27
        {nil, _} -> nil
28
        {derived, nil} -> max(derived, 0)
29
        {derived, free} -> max(min(derived, free), 0)
30
      end
31
32
    %{
33
      "logical" => logical,
34
      "active_reservations" => active,
35
      "allocatable" => allocatable,
36
      "queued" => integer_or_nil(evidence["queued"]),
37
      "safety_headroom" => safety_headroom,
38
      "configured_ceiling" => ceiling,
39
      "observed_limit" => observed
40
    }
41
  end
42
43
  defp subtract(nil, _value), do: nil
44
  defp subtract(value, nil), do: value
45
  defp subtract(value, other), do: value - other
46
47
  defp integer_or_nil(value) when is_integer(value) and value >= 0, do: value
48
  defp integer_or_nil(value) when is_float(value) and value >= 0, do: trunc(value)
49
  defp integer_or_nil(_value), do: nil
50
end
lib/openagents/capacity/requirement.ex added +94

@@ -0,0 +1,94 @@

1
defmodule OpenAgents.Capacity.Requirement do
2
  @moduledoc false
3
4
  @isolations ["managed_standard", "managed_strong", "customer_controlled"]
5
  @egresses ["policy_broker", "customer_network"]
6
  @locations ["openagents_managed", "customer_premises"]
7
  @targets ["openagents_managed", "customer_computer"]
8
9
  def normalize(%{"requirement" => requirement}) when is_map(requirement),
10
    do: normalize(requirement)
11
12
  def normalize(requirement) when is_map(requirement) do
13
    quantity = Map.get(requirement, "quantity", 1)
14
    target = Map.get(requirement, "target", "openagents_managed")
15
    tools = Map.get(requirement, "tools", [])
16
    duration = Map.get(requirement, "duration_seconds", 900)
17
    isolation = Map.get(requirement, "isolation")
18
    egress = Map.get(requirement, "egress")
19
    location = Map.get(requirement, "data_location")
20
    computer_id = Map.get(requirement, "computer_id")
21
    budget = Map.get(requirement, "budget")
22
23
    cond do
24
      not positive_integer?(quantity) or quantity > 1_000 or
25
        not positive_integer?(duration) or duration > 86_400 ->
26
        {:error, :invalid_requirement, "Quantity and duration are out of bounds."}
27
28
      not is_binary(isolation) ->
29
        {:error, :invalid_requirement, "Isolation is required."}
30
31
      isolation not in @isolations ->
32
        {:error, :unsupported_isolation, "No admitted class provides #{isolation}."}
33
34
      not is_binary(egress) ->
35
        {:error, :invalid_requirement, "Egress is required."}
36
37
      egress not in @egresses ->
38
        {:error, :unsupported_egress, "No admitted class provides #{egress}."}
39
40
      not is_binary(location) ->
41
        {:error, :invalid_requirement, "Data location is required."}
42
43
      location not in @locations ->
44
        {:error, :unsupported_data_location, "No class runs in #{location}."}
45
46
      target not in @targets ->
47
        {:error, :invalid_requirement, "Target is invalid."}
48
49
      target == "customer_computer" and not is_binary(computer_id) ->
50
        {:error, :explicit_target_required, "A customer computer target requires computer_id."}
51
52
      not is_list(tools) or Enum.any?(tools, &(not is_binary(&1))) ->
53
        {:error, :invalid_requirement, "Tools must be a list of strings."}
54
55
      not valid_budget?(budget) ->
56
        {:error, :invalid_requirement, "Budget must use usd_cents."}
57
58
      true ->
59
        {:ok,
60
         %{
61
           "quantity" => quantity,
62
           "isolation" => isolation,
63
           "egress" => egress,
64
           "data_location" => location,
65
           "target" => target,
66
           "tools" => Enum.uniq(tools),
67
           "duration_seconds" => duration,
68
           "budget" => normalize_budget(budget)
69
         }
70
         |> maybe_put_computer(computer_id)}
71
    end
72
  end
73
74
  def normalize(_invalid), do: {:error, :invalid_requirement, "Requirement must be an object."}
75
76
  defp maybe_put_computer(requirement, computer_id) when is_binary(computer_id),
77
    do: Map.put(requirement, "computer_id", computer_id)
78
79
  defp maybe_put_computer(requirement, _computer_id), do: requirement
80
81
  defp normalize_budget(nil), do: nil
82
83
  defp normalize_budget(%{"currency" => currency, "amount" => amount}),
84
    do: %{"currency" => currency, "amount" => amount}
85
86
  defp valid_budget?(nil), do: true
87
88
  defp valid_budget?(%{"currency" => "usd_cents", "amount" => amount})
89
       when is_integer(amount) and amount >= 0, do: true
90
91
  defp valid_budget?(_budget), do: false
92
93
  defp positive_integer?(value), do: is_integer(value) and value > 0
94
end
lib/openagents_web/api_route_authority.ex modified +2

@@ -80,6 +80,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

80 80
      # Scoped bearer pipelines require the route-specific token authority.
81 81
      "get /api/v3/chat/events" => :required_bearer,
82 82
      "post /api/v3/chat/turns" => :required_bearer,
83
      "get /api/v3/capacity" => :required_bearer,
84
      "post /api/v3/capacity/matches" => :required_bearer,
83 85
      "delete /api/v3/repos/:owner/:repo" => :required_bearer,
84 86
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
85 87
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/labels/:name" => :required_bearer,
lib/openagents_web/controllers/api_extension_controller.ex modified +14

@@ -20,6 +20,20 @@ defmodule OpenAgentsWeb.ApiExtensionController do

20 20
  }
21 21
22 22
  @extensions %{
23
    "capacity.openagents" => %{
24
      "version" => "2026-08-23",
25
      "description" => "Owner-safe quantity-based capacity and matching projections.",
26
      "endpoints" => [
27
        "GET /api/capacity",
28
        "GET /api/v3/capacity",
29
        "POST /api/v3/capacity/matches"
30
      ],
31
      "schemas" => [
32
        "openagents.capacity.v1",
33
        "openagents.capacity_match.v1",
34
        "openagents.capacity_refusal.v1"
35
      ]
36
    },
23 37
    "issue.openagents" => %{
24 38
      "version" => "2026-08-23",
25 39
      "description" => "OpenAgents-specific issue fields, namespaced away from the GitHub shape.",
lib/openagents_web/controllers/capacity_controller.ex added +34

@@ -0,0 +1,34 @@

1
defmodule OpenAgentsWeb.CapacityController do
2
  @moduledoc """
3
  Owner-authenticated capacity and device-to-job matching endpoints.
4
  """
5
6
  use OpenAgentsWeb, :controller
7
8
  alias OpenAgents.Capacity
9
10
  def show(conn, _params) do
11
    json(conn, Capacity.projection(conn.assigns.current_user))
12
  end
13
14
  def matches(conn, params) do
15
    case Capacity.match(conn.assigns.current_user, params) do
16
      {:ok, response} ->
17
        json(conn, response)
18
19
      {:error, %{"error" => %{"code" => code}} = response} ->
20
        conn
21
        |> put_status(status_for(code))
22
        |> json(response)
23
    end
24
  end
25
26
  defp status_for("computer_not_found"), do: :not_found
27
  defp status_for("quantity_unavailable"), do: :conflict
28
29
  defp status_for(code)
30
       when code in ["evidence_stale", "evidence_unavailable", "incident_drained"],
31
       do: :service_unavailable
32
33
  defp status_for(_code), do: :unprocessable_entity
34
end
lib/openagents_web/route_authority.ex modified +11

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

50 50
    "/github/connection",
51 51
    "/api/tokens",
52 52
    "/api/computers",
53
    "/api/capacity",
53 54
    "/api/computer-agent-jobs/"
54 55
  ]
55 56

@@ -191,6 +192,10 @@ defmodule OpenAgentsWeb.RouteAuthority do

191 192
  defp policy(%{path: "/api/status", verb: verb}) when verb in [:get, :head],
192 193
    do: declaration(:public_read, "anonymous", "published:status", false)
193 194
195
  defp policy(%{path: "/api/capacity", verb: verb}) when verb in [:get, :head],
196
    do:
197
      declaration(:authenticated_api, "active encrypted browser session", "capacity:self", false)
198
194 199
  defp policy(%{path: "/api/changelog", verb: verb}) when verb in [:get, :head],
195 200
    do: declaration(:public_read, "anonymous", "published:changelog", false)
196 201

@@ -262,6 +267,12 @@ defmodule OpenAgentsWeb.RouteAuthority do

262 267
  defp policy(%{path: "/api/v3/chat/turns", verb: :post}),
263 268
    do: declaration(:authenticated_api, "first-party bearer token", "chat:account", true)
264 269
270
  defp policy(%{path: "/api/v3/capacity", verb: verb}) when verb in [:get, :head],
271
    do: declaration(:authenticated_api, "first-party bearer token", "chat:account", false)
272
273
  defp policy(%{path: "/api/v3/capacity/matches", verb: :post}),
274
    do: declaration(:authenticated_api, "first-party bearer token", "chat:account", true)
275
265 276
  defp policy(%{path: path, verb: verb})
266 277
       when path in @optional_forge_read_paths and verb in [:get, :head],
267 278
       do:
lib/openagents_web/router.ex modified +3

@@ -205,6 +205,7 @@ defmodule OpenAgentsWeb.Router do

205 205
    delete "/tokens/:id", ApiTokenController, :delete
206 206
207 207
    get "/computers", ComputersController, :index
208
    get "/capacity", CapacityController, :show
208 209
    post "/computers/pairings/:id/approve", ComputersController, :approve_pairing
209 210
    delete "/computers/:id", ComputersController, :delete
210 211
    post "/computers/:machine_id/agent-jobs", ComputerAgentJobsController, :create

@@ -304,6 +305,8 @@ defmodule OpenAgentsWeb.Router do

304 305
305 306
    get "/chat/events", ChatTurnController, :index
306 307
    post "/chat/turns", ChatTurnController, :create
308
    get "/capacity", CapacityController, :show
309
    post "/capacity/matches", CapacityController, :matches
307 310
  end
308 311
309 312
  scope "/api/v3", OpenAgentsWeb do
test/openagents/capacity_test.exs added +299

@@ -0,0 +1,299 @@

1
defmodule OpenAgents.CapacityTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.Capacity
5
  alias OpenAgents.Capacity.Math
6
7
  setup do
8
    original_capacity = Application.get_env(:openagents, OpenAgents.Capacity, [])
9
    original_evidence = Application.get_env(:openagents, :capacity_test_evidence)
10
11
    Application.put_env(
12
      :openagents,
13
      OpenAgents.Capacity,
14
      Keyword.merge(original_capacity, evidence_source: OpenAgents.CapacityEvidenceStub)
15
    )
16
17
    on_exit(fn ->
18
      Application.put_env(:openagents, OpenAgents.Capacity, original_capacity)
19
20
      if is_nil(original_evidence) do
21
        Application.delete_env(:openagents, :capacity_test_evidence)
22
      else
23
        Application.put_env(:openagents, :capacity_test_evidence, original_evidence)
24
      end
25
    end)
26
27
    :ok
28
  end
29
30
  test "caps limit-derived capacity with already-free broker evidence" do
31
    config = [
32
      class_ceilings: %{"standard" => 16},
33
      reserved_headroom_fraction: 0.25
34
    ]
35
36
    quantities =
37
      Math.quantities(
38
        %{"id" => "standard"},
39
        %{
40
          "logical" => 30,
41
          "active_reservations" => 4,
42
          "observed_limit" => 24,
43
          "reported_free" => 8
44
        },
45
        config
46
      )
47
48
    assert quantities["allocatable"] == 8
49
    assert quantities["safety_headroom"] == 6
50
  end
51
52
  test "publishes fresh and stale evidence without inventing unavailable quantities" do
53
    now = DateTime.utc_now() |> DateTime.truncate(:second)
54
55
    Application.put_env(
56
      :openagents,
57
      :capacity_test_evidence,
58
      {:ok,
59
       %{
60
         "classes" => [
61
           %{
62
             "id" => "standard",
63
             "logical" => 30,
64
             "active_reservations" => 4,
65
             "observed_limit" => 24,
66
             "reported_free" => 8,
67
             "queued" => 2,
68
             "observed_at" => DateTime.to_iso8601(now)
69
           },
70
           %{
71
             "id" => "strong",
72
             "logical" => 2,
73
             "active_reservations" => 0,
74
             "observed_limit" => 2,
75
             "reported_free" => 2,
76
             "observed_at" => DateTime.to_iso8601(DateTime.add(now, -121, :second))
77
           },
78
           %{"id" => "batch", "private" => true}
79
         ]
80
       }}
81
    )
82
83
    projection = Capacity.projection(%{id: Ecto.UUID.generate()})
84
    standard = Enum.find(projection["classes"], &(&1["id"] == "standard"))
85
    strong = Enum.find(projection["classes"], &(&1["id"] == "strong"))
86
87
    assert standard["admits"] == true
88
    assert standard["quantities"]["allocatable"] == 8
89
    assert strong["admits"] == false
90
    assert strong["quantities"]["allocatable"] == 0
91
    assert strong["refusal"]["code"] == "evidence_stale"
92
    refute Enum.any?(projection["classes"], &(&1["id"] == "batch"))
93
  end
94
95
  test "returns a typed refusal and never includes broker-only sensitive fields" do
96
    secret = "project-123 eu-west-1 host.example 192.0.2.1 secret-token image/path"
97
98
    Application.put_env(
99
      :openagents,
100
      :capacity_test_evidence,
101
      {:ok,
102
       %{
103
         "classes" => [
104
           %{
105
             "id" => "standard",
106
             "observed_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
107
             "observed_limit" => 0,
108
             "reported_free" => 0,
109
             "logical" => 0,
110
             "active_reservations" => 0,
111
             "provider" => secret,
112
             "project_id" => secret,
113
             "region" => secret,
114
             "zone" => secret,
115
             "hostname" => secret,
116
             "guest_ip" => secret,
117
             "credentials" => secret,
118
             "image_path" => secret,
119
             "raw_error" => secret
120
           }
121
         ]
122
       }}
123
    )
124
125
    projection = Capacity.projection(%{id: Ecto.UUID.generate()})
126
    serialized = Jason.encode!(projection)
127
128
    refute serialized =~ secret
129
    refute serialized =~ "project_id"
130
131
    assert projection["classes"] |> Enum.find(&(&1["id"] == "standard")) |> Map.get("admits") ==
132
             true
133
134
    assert {:error, %{"error" => %{"code" => "quantity_unavailable"}}} =
135
             Capacity.match(
136
               %{id: Ecto.UUID.generate()},
137
               %{
138
                 "quantity" => 1,
139
                 "isolation" => "managed_standard",
140
                 "egress" => "policy_broker",
141
                 "data_location" => "openagents_managed"
142
               }
143
             )
144
  end
145
146
  test "managed matching never falls back to connected computers" do
147
    Application.put_env(
148
      :openagents,
149
      :capacity_test_evidence,
150
      {:ok,
151
       %{
152
         "classes" => [
153
           %{
154
             "id" => "standard",
155
             "observed_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
156
             "observed_limit" => 0,
157
             "reported_free" => 0,
158
             "logical" => 0,
159
             "active_reservations" => 0
160
           }
161
         ]
162
       }}
163
    )
164
165
    assert {:error, %{"error" => %{"code" => "quantity_unavailable"}}} =
166
             Capacity.match(
167
               %{id: Ecto.UUID.generate()},
168
               %{
169
                 "quantity" => 1,
170
                 "isolation" => "managed_standard",
171
                 "egress" => "policy_broker",
172
                 "data_location" => "openagents_managed"
173
               }
174
             )
175
  end
176
177
  test "managed standard matching can rank strong runtimes" do
178
    now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
179
180
    Application.put_env(
181
      :openagents,
182
      :capacity_test_evidence,
183
      {:ok,
184
       %{
185
         "classes" => [
186
           %{
187
             "id" => "strong",
188
             "observed_at" => now,
189
             "observed_limit" => 2,
190
             "reported_free" => 2,
191
             "logical" => 2,
192
             "active_reservations" => 0
193
           }
194
         ]
195
       }}
196
    )
197
198
    assert {:ok, %{"candidates" => [%{"class" => "strong"}]}} =
199
             Capacity.match(
200
               %{id: Ecto.UUID.generate()},
201
               %{
202
                 "quantity" => 1,
203
                 "isolation" => "managed_standard",
204
                 "egress" => "policy_broker",
205
                 "data_location" => "openagents_managed"
206
               }
207
             )
208
  end
209
210
  test "unsupported requirement fields and budget return typed refusals" do
211
    viewer = %{id: Ecto.UUID.generate()}
212
213
    for {field, value, code} <- [
214
          {"egress", "private_network", "unsupported_egress"},
215
          {"data_location", "restricted_zone", "unsupported_data_location"},
216
          {"tools", ["unsupported_tool"], "unsupported_tool"}
217
        ] do
218
      requirement = %{
219
        "isolation" => "managed_standard",
220
        "egress" => "policy_broker",
221
        "data_location" => "openagents_managed"
222
      }
223
224
      requirement =
225
        if field == "tools" do
226
          Map.put(requirement, field, value)
227
        else
228
          Map.put(requirement, field, value)
229
        end
230
231
      assert {:error, %{"error" => %{"code" => ^code}}} =
232
               Capacity.match(viewer, requirement)
233
    end
234
235
    assert {:error, %{"error" => %{"code" => "budget_below_minimum"}}} =
236
             Capacity.match(viewer, %{
237
               "isolation" => "managed_standard",
238
               "egress" => "policy_broker",
239
               "data_location" => "openagents_managed",
240
               "budget" => %{"currency" => "usd_cents", "amount" => 0}
241
             })
242
  end
243
244
  test "a customer computer target requires ownership" do
245
    assert {:error, %{"error" => %{"code" => "computer_not_found"}}} =
246
             Capacity.match(
247
               %{id: Ecto.UUID.generate()},
248
               %{
249
                 "isolation" => "customer_controlled",
250
                 "egress" => "customer_network",
251
                 "data_location" => "customer_premises",
252
                 "target" => "customer_computer",
253
                 "computer_id" => Ecto.UUID.generate()
254
               }
255
             )
256
  end
257
258
  test "incident-drained and exhausted classes expose refusal evidence" do
259
    now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
260
261
    Application.put_env(
262
      :openagents,
263
      :capacity_test_evidence,
264
      {:ok,
265
       %{
266
         "classes" => [
267
           %{
268
             "id" => "standard",
269
             "observed_at" => now,
270
             "observed_limit" => 16,
271
             "reported_free" => 0,
272
             "logical" => 16,
273
             "active_reservations" => 16,
274
             "queued" => 5
275
           },
276
           %{
277
             "id" => "batch",
278
             "observed_at" => now,
279
             "observed_limit" => 8,
280
             "reported_free" => 8,
281
             "logical" => 8,
282
             "active_reservations" => 0,
283
             "incident_drained" => true
284
           }
285
         ]
286
       }}
287
    )
288
289
    projection = Capacity.projection(%{id: Ecto.UUID.generate()})
290
    standard = Enum.find(projection["classes"], &(&1["id"] == "standard"))
291
    batch = Enum.find(projection["classes"], &(&1["id"] == "batch"))
292
293
    assert standard["quantities"]["allocatable"] == 0
294
    assert standard["quantities"]["queued"] == 5
295
    assert standard["admits"] == true
296
    assert batch["admits"] == false
297
    assert batch["refusal"]["code"] == "incident_drained"
298
  end
299
end
test/openagents_web/controllers/capacity_controller_test.exs added +65

@@ -0,0 +1,65 @@

1
defmodule OpenAgentsWeb.CapacityControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  setup do
5
    original_capacity = Application.get_env(:openagents, OpenAgents.Capacity, [])
6
    original_evidence = Application.get_env(:openagents, :capacity_test_evidence)
7
8
    Application.put_env(
9
      :openagents,
10
      OpenAgents.Capacity,
11
      Keyword.merge(original_capacity, evidence_source: OpenAgents.CapacityEvidenceStub)
12
    )
13
14
    Application.put_env(
15
      :openagents,
16
      :capacity_test_evidence,
17
      {:ok, %{"classes" => []}}
18
    )
19
20
    on_exit(fn ->
21
      Application.put_env(:openagents, OpenAgents.Capacity, original_capacity)
22
23
      if is_nil(original_evidence) do
24
        Application.delete_env(:openagents, :capacity_test_evidence)
25
      else
26
        Application.put_env(:openagents, :capacity_test_evidence, original_evidence)
27
      end
28
    end)
29
30
    :ok
31
  end
32
33
  test "capacity projection is shared by session and bearer routes", %{conn: conn} do
34
    session_conn =
35
      conn
36
      |> log_in_github_user("capacity-session")
37
      |> get("/api/capacity")
38
39
    bearer_conn =
40
      build_conn()
41
      |> put_chat_api_token("capacity-bearer")
42
      |> get("/api/v3/capacity")
43
44
    assert json_response(session_conn, 200) == json_response(bearer_conn, 200)
45
  end
46
47
  test "matching returns the contract status for unsupported isolation", %{conn: conn} do
48
    conn =
49
      conn
50
      |> put_chat_api_token("capacity-unsupported")
51
      |> post("/api/v3/capacity/matches", %{
52
        "requirement" => %{
53
          "isolation" => "managed_confidential",
54
          "egress" => "policy_broker",
55
          "data_location" => "openagents_managed"
56
        }
57
      })
58
59
    assert %{
60
             "schema" => "openagents.capacity_refusal.v1",
61
             "error" => %{"code" => "unsupported_isolation"}
62
           } =
63
             json_response(conn, 422)
64
  end
65
end
test/support/capacity_evidence_stub.ex added +6

@@ -0,0 +1,6 @@

1
defmodule OpenAgents.CapacityEvidenceStub do
2
  @moduledoc false
3
4
  def fetch(_viewer),
5
    do: Application.get_env(:openagents, :capacity_test_evidence, {:error, :unset})
6
end

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