withruntime 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f471d00ffe082a8a507a514dda86ce4062342e2b5f0e27153309d094a27fb35b
4
- data.tar.gz: 988ff09e0b418238acb50eba94f81bfa59c579e8103a81bd8b43e551c692f70e
3
+ metadata.gz: c855551b3670978d87aab0540d48736b4f9f0935cb8109cbb9a6bdbf85b05d96
4
+ data.tar.gz: c2e545740a78cc37fdcdec6de993c4b12402906f68a0bd3692d07f357bd17c4e
5
5
  SHA512:
6
- metadata.gz: e4e8625a16d34f127de6bda5733b2645b733cb7a06ec3af65d87fc48a6b804d17c6d22f03cc657bd3cb64d8c29e3269cc42047aed28b0cf084f25a6cba9a048e
7
- data.tar.gz: 33311be1f0d80a9ff3fd613acd21a02e0535945271e08af9cdf4b83d1021310479681bb7f07e8acb36663654d130fa29c635dcf796b1489cb9fbeec73d1f9a25
6
+ metadata.gz: 9ada037d0a4b3ddc819067452b0688e027df4ffdc2378de1dcc96a509e1aa0243ce8f58e2d00b458861d6b15afc27d1e708a0f03181fbbfb2a016e5f4691c1be
7
+ data.tar.gz: 316571ca7aae8d6714b2998e8a78b15a76ba2de7eb6c6a638efcb7652db98ff59435a9b05146e74e406900c7639176557ee1912a36578eedc7b0109668ea086d
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # An agent: +id+, +name+, +version+ (one more on each update), +definition+,
5
+ # +labels+, +created_at+, +updated_at+, and +warnings+, such as
6
+ # "subscription_for_customers" for an agent that serves your customers on a
7
+ # subscription login (an empty list when there are none).
8
+ class Agent < Record
9
+ def warnings = self["warnings"] || []
10
+ end
11
+
12
+ # One entry of a session's log: +seq+, +kind+ (input, answer, reply, ask,
13
+ # status, result, error or note), +actor+, +body+ and +created_at+. +text+
14
+ # is its words, +body.text+.
15
+ class AgentEvent < Record
16
+ def text = self["body"].is_a?(Hash) ? self["body"]["text"] : nil
17
+ end
18
+
19
+ # +runtime.agents+: agents that run in their own sandbox per session, started
20
+ # by your own app through the API, Slack, GitHub, a schedule or a webhook. A private
21
+ # beta: an account outside it is refused +product_in_beta+
22
+ # (PermissionDeniedError).
23
+ #
24
+ # definition = runtime.agents.templates.definition("claude-code", use: "team")
25
+ # runtime.agents.create("reviewer", definition)
26
+ # session = runtime.agents.start("reviewer", "Review the open pull requests.")
27
+ # session.events.each { |event| puts event.text if event.kind == "reply" }
28
+ #
29
+ # A definition is a Hash, by snake_case keyword (+memory_mib:+) or by the
30
+ # API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: } or
31
+ # { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set),
32
+ # +instructions+, +env+, +secrets+ (names of egress secrets: model keys go
33
+ # here), +mcp+ ([{ name:, url: }]), +volumes+ ([{ volume:, path: }]),
34
+ # +idle_pause_seconds+, +max_session_seconds+, +max_concurrent+,
35
+ # +max_session_dollars+ (5 unless set: a session at it stops, reason
36
+ # "budget"), +max_day_dollars+ (100 unless set: an agent at it starts nothing
37
+ # new, ConflictError +budget_reached+, until the next day) and +template+
38
+ # ("support" or "team").
39
+ class Agents < Product
40
+ attr_reader :sessions, :triggers, :templates
41
+
42
+ def initialize(transport)
43
+ super
44
+ @sessions = AgentSessions.new(transport)
45
+ @triggers = AgentTriggers.new(transport)
46
+ @templates = AgentTemplates.new
47
+ # Approvals come later, here: agents.approvals.list / answer.
48
+ end
49
+
50
+ # Makes an agent at version 1. A name already used is a ConflictError
51
+ # (name_taken); the names sessions, triggers, templates and approvals are
52
+ # refused, since they are paths beside the agents.
53
+ def create(name, definition, labels: nil, idempotency_key: nil)
54
+ body = Fields.body(name: name, definition: AgentTemplates.plain(definition), labels: labels)
55
+ Agent.new(@t.json("POST", "/v1/agents", body: body, key: idempotency_key))
56
+ end
57
+
58
+ # By id or by name.
59
+ def get(agent) = Agent.new(@t.json("GET", "/v1/agents/#{seg(agent)}"))
60
+
61
+ # The first page of agents, at most +limit+ a page; +each+ walks every page.
62
+ def list(cursor: nil, limit: nil) = agents_page(cursor, limit)
63
+
64
+ # Replaces the whole definition: a new version. Sessions already running
65
+ # keep the version they started with.
66
+ def update(agent, definition)
67
+ body = { "definition" => Fields.convert(AgentTemplates.plain(definition)) }
68
+ Agent.new(@t.json("PATCH", "/v1/agents/#{seg(agent)}", body: body))
69
+ end
70
+
71
+ # Deletes an agent: its triggers go and its open sessions are asked to stop.
72
+ def delete(agent) = @t.json("DELETE", "/v1/agents/#{seg(agent)}") && nil
73
+
74
+ # Starts a session with +input+, its first words. With +key:+, your own
75
+ # name for the conversation (a Slack thread, a pull request, one of your
76
+ # customers such as "customer:42"), words with the same key join the open
77
+ # session instead of starting a second; +session.joined+ says so. An agent
78
+ # at its day's budget is refused +budget_reached+ (ConflictError).
79
+ def start(agent, input, key: nil, labels: nil, idempotency_key: nil)
80
+ body = Fields.body(input: input, key: key, labels: labels)
81
+ answer = @t.json("POST", "/v1/agents/#{seg(agent)}/sessions", body: body, key: idempotency_key)
82
+ AgentSession.new(@t, answer["session"], joined: answer["joined"] == true, seq: answer["seq"])
83
+ end
84
+
85
+ private
86
+
87
+ def agents_page(cursor, limit)
88
+ body = @t.json("GET", "/v1/agents", query: { "cursor" => cursor, "limit" => limit })
89
+ Page.new(body["data"].map { |item| Agent.new(item) }, body["nextCursor"]) { |after| agents_page(after, limit) }
90
+ end
91
+ end
92
+
93
+ # +runtime.agents.sessions+: find sessions. A session is one piece of work
94
+ # or one conversation; it has its own sandbox while it runs.
95
+ class AgentSessions < Product
96
+ # A session to follow, send to, stop or wait for.
97
+ def get(id)
98
+ info = @t.json("GET", "/v1/agents/sessions/#{seg(id)}")
99
+ AgentSession.new(@t, info, joined: false, seq: info["lastSeq"])
100
+ end
101
+
102
+ # Deletes an ended session and its history; until then it is kept,
103
+ # counted as storage. An open session is refused +session_open+
104
+ # (ConflictError): stop it first.
105
+ def delete(id) = @t.json("DELETE", "/v1/agents/sessions/#{seg(id)}") && nil
106
+
107
+ # The first page of sessions, as records; +each+ walks every page.
108
+ # +agent+ is an id or a name; +state+ is queued, running, waiting, done,
109
+ # failed or stopped; at most +limit+ a page.
110
+ def list(agent: nil, state: nil, cursor: nil, limit: nil)
111
+ sessions_page({ "agent" => agent, "state" => state, "limit" => limit }, cursor)
112
+ end
113
+
114
+ private
115
+
116
+ def sessions_page(query, cursor)
117
+ body = @t.json("GET", "/v1/agents/sessions", query: query.merge("cursor" => cursor))
118
+ Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) { |after| sessions_page(query, after) }
119
+ end
120
+ end
121
+
122
+ # A session of an agent. +info+ is its latest record: +state+ (queued,
123
+ # running, waiting, done, failed or stopped), +reason+, +result+,
124
+ # +last_seq+ and the rest; once the API sends them, +parent_session_id+ and
125
+ # +root_session_id+ (a child session's parent and its tree's root), +cost+
126
+ # ({ dollars, vcpuSeconds, gibSeconds } so far), +tree_cost+ (its children's
127
+ # summed in), +history_bytes+ (text history, counted as storage) and
128
+ # +attachment_bytes+ (files and screenshots, counted as storage), each nil
129
+ # until then; history is kept until the session is deleted
130
+ # (+sessions.delete+). +joined+ is whether +start+
131
+ # joined an open session with the same key, and +seq+ the number of the
132
+ # words it sent.
133
+ class AgentSession
134
+ ENDED = %w[done failed stopped].freeze
135
+ # How long the API holds an events read open, and the margin past it a
136
+ # read waits for its answer.
137
+ POLL_SECONDS = 30
138
+ POLL_MARGIN = 15
139
+
140
+ attr_reader :info, :joined, :seq
141
+
142
+ def initialize(transport, info, joined: false, seq: nil)
143
+ @t = transport
144
+ @info = Record.new(info)
145
+ @joined = joined
146
+ @seq = seq
147
+ end
148
+
149
+ def id = @info["id"]
150
+ def state = @info["state"]
151
+ def joined? = @joined
152
+ def ended? = ENDED.include?(state)
153
+
154
+ # Reads the session again.
155
+ def refresh(timeout: nil)
156
+ @info = Record.new(@t.json("GET", path, timeout: timeout))
157
+ self
158
+ end
159
+
160
+ # Sends words into a running or waiting session and answers their seq.
161
+ # The idempotency key, made here when not given, means a retried send is
162
+ # never two messages. Named send_message, not send, so Ruby's own
163
+ # Object#send stays as it is.
164
+ def send_message(text, idempotency_key: nil)
165
+ @t.json("POST", path("/messages"), body: { "text" => text }, key: idempotency_key)["seq"]
166
+ end
167
+
168
+ # The session's log after +after+ (0, the whole log), in order, each an
169
+ # AgentEvent; without a block, an Enumerator. It waits for new events and
170
+ # ends once the session has ended and its last event has been given.
171
+ def events(after: 0, &block)
172
+ return enum_for(:events, after: after) unless block
173
+
174
+ wait = POLL_SECONDS
175
+ loop do
176
+ page = read_events(after, wait)
177
+ data = page["data"] || []
178
+ data.each do |item|
179
+ event = AgentEvent.new(item)
180
+ after = event.seq
181
+ yield event
182
+ end
183
+ # Ended: read on at once, without waiting, until nothing is left.
184
+ if page["ended"]
185
+ return nil if wait.zero? && data.empty?
186
+
187
+ wait = 0
188
+ end
189
+ end
190
+ end
191
+
192
+ # Asks the session to stop and keeps the answer. A running session ends
193
+ # once its sandbox is deleted; this does not wait for that (+wait+ does).
194
+ def stop
195
+ @info = Record.new(@t.json("POST", path("/stop"), body: {}))
196
+ end
197
+
198
+ # The session once it has ended (done, failed or stopped); a failed
199
+ # session is returned, not raised. Waits on the server, with no polling.
200
+ # +timeout+ in seconds (nil or 0: no deadline) raises a ConnectionError
201
+ # with code "timeout" when it passes first.
202
+ def wait(timeout: nil)
203
+ deadline = timeout&.positive? ? now + timeout : nil
204
+ loop do
205
+ refresh(timeout: deadline && left(deadline))
206
+ return @info if ended?
207
+
208
+ loop do
209
+ seconds = deadline ? [POLL_SECONDS, left(deadline).ceil].min : POLL_SECONDS
210
+ page = read_events(@info["lastSeq"] || 0, seconds)
211
+ break if page["ended"] || !(page["data"] || []).empty?
212
+
213
+ left(deadline) if deadline
214
+ end
215
+ end
216
+ end
217
+
218
+ def inspect = "#<#{self.class.name} #{id} #{state}>"
219
+
220
+ private
221
+
222
+ def path(suffix = "") = "/v1/agents/sessions/#{Transport.segment(id)}#{suffix}"
223
+
224
+ # One long poll: its own deadline past the API's hold, whatever the client's.
225
+ def read_events(after, wait)
226
+ @t.json("GET", path("/events"), query: { "after" => after, "wait" => wait }, timeout: [@t.timeout, wait + POLL_MARGIN].max)
227
+ end
228
+
229
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+
231
+ def left(deadline)
232
+ seconds = deadline - now
233
+ raise @t.connection_error(true, false, nil) unless seconds.positive?
234
+
235
+ seconds
236
+ end
237
+ end
238
+
239
+ # +runtime.agents.triggers+: what starts or feeds an agent's sessions
240
+ # besides the API. A trigger is +id+, +agent_id+, +kind+, +config+,
241
+ # +paused_at+, +paused_why+ and +created_at+. The kinds and their configs:
242
+ #
243
+ # slack { team: "T…", channel: "C…" }, once Runtime's Slack app is installed
244
+ # github { repository: "owner/name", installation: <Runtime's GitHub App
245
+ # installation id, the organization's own>, events: [...] (optional) }
246
+ # schedule { cron: "0 9 * * 1-5", timezone: "America/Denver" (optional), input: "words" }
247
+ # webhook {}
248
+ #
249
+ # A slack or github trigger whose app is not installed for the account is
250
+ # refused +not_installed+ (ConflictError).
251
+ class AgentTriggers < Product
252
+ def create(agent, kind, config = {})
253
+ Record.new(@t.json("POST", "/v1/agents/#{seg(agent)}/triggers", body: { "kind" => kind, "config" => Fields.convert(config.to_h) }))
254
+ end
255
+
256
+ def list(agent) = @t.json("GET", "/v1/agents/#{seg(agent)}/triggers")["data"].map { |row| Record.new(row) }
257
+
258
+ # Holds this trigger alone; its events are refused +trigger_paused+, with
259
+ # +why+, where they came from. Nothing else is held.
260
+ def pause(id, why) = Record.new(@t.json("POST", "/v1/agents/triggers/#{seg(id)}/pause", body: { "why" => why }))
261
+
262
+ def resume(id) = Record.new(@t.json("POST", "/v1/agents/triggers/#{seg(id)}/resume", body: {}))
263
+
264
+ def delete(id) = @t.json("DELETE", "/v1/agents/triggers/#{seg(id)}") && nil
265
+ end
266
+
267
+ # +runtime.agents.templates+: the five harnesses an agent can run, each
268
+ # usable for either use, and the two uses.
269
+ #
270
+ # runtime.agents.templates.map(&:key) # claude-code, codex, gemini-cli, claude-agent-sdk, openai-agents-sdk
271
+ # runtime.agents.templates["codex"].api_key_secret
272
+ # runtime.agents.templates.uses["support"].session_key
273
+ # runtime.agents.templates.definition("claude-code", use: "support")
274
+ #
275
+ # A subscription is for your own team's agents. An agent that serves your
276
+ # customers (api or webhook sessions for end users) uses an API key: those
277
+ # are the providers' terms, and the API warns +subscription_for_customers+
278
+ # on an agent that does not.
279
+ class AgentTemplates
280
+ include Enumerable
281
+
282
+ # The same values as every Runtime SDK and the guide
283
+ # (packages/cloud-guide/agent-templates.json).
284
+ DATA = {
285
+ "templates" => {
286
+ "claude-code" => {
287
+ "key" => "claude-code",
288
+ "title" => "Claude Code",
289
+ "command" => "npx -y @anthropic-ai/claude-code --dangerously-skip-permissions --mcp-config ~/.runtime-agent/mcp.json -p \"$(cat ~/.runtime-agent/start.md)\"",
290
+ "apiKeySecret" => "ANTHROPIC_API_KEY",
291
+ "apiKeyHost" => "api.anthropic.com",
292
+ "subscription" => "token: run `claude setup-token` on your own computer (Pro/Max), then `runtime secrets set CLAUDE_CODE_OAUTH_TOKEN` scoped to api.anthropic.com",
293
+ "definition" => {
294
+ "command" => "npx -y @anthropic-ai/claude-code --dangerously-skip-permissions --mcp-config ~/.runtime-agent/mcp.json -p \"$(cat ~/.runtime-agent/start.md)\"",
295
+ "secrets" => ["ANTHROPIC_API_KEY"]
296
+ }
297
+ },
298
+ "codex" => {
299
+ "key" => "codex",
300
+ "title" => "Codex CLI",
301
+ "command" => "npx -y @openai/codex exec --dangerously-bypass-approvals-and-sandbox \"$(cat ~/.runtime-agent/start.md)\"",
302
+ "apiKeySecret" => "OPENAI_API_KEY",
303
+ "apiKeyHost" => "api.openai.com",
304
+ "subscription" => "login once in the sandbox's terminal: `npx -y @openai/codex login --device-auth`, kept in the snapshot or a volume at `~/.codex`",
305
+ "definition" => {
306
+ "command" => "npx -y @openai/codex exec --dangerously-bypass-approvals-and-sandbox \"$(cat ~/.runtime-agent/start.md)\"",
307
+ "secrets" => ["OPENAI_API_KEY"]
308
+ }
309
+ },
310
+ "gemini-cli" => {
311
+ "key" => "gemini-cli",
312
+ "title" => "Gemini CLI",
313
+ "command" => "npx -y @google/gemini-cli --yolo -p \"$(cat ~/.runtime-agent/start.md)\"",
314
+ "apiKeySecret" => "GEMINI_API_KEY",
315
+ "apiKeyHost" => "generativelanguage.googleapis.com",
316
+ "subscription" => "login once in the sandbox's terminal (Sign in with Google), kept at `~/.gemini`",
317
+ "definition" => {
318
+ "command" => "npx -y @google/gemini-cli --yolo -p \"$(cat ~/.runtime-agent/start.md)\"",
319
+ "secrets" => ["GEMINI_API_KEY"]
320
+ }
321
+ },
322
+ "claude-agent-sdk" => {
323
+ "key" => "claude-agent-sdk",
324
+ "title" => "Claude Agent SDK",
325
+ "command" => "python3 ~/agent.py",
326
+ "apiKeySecret" => "ANTHROPIC_API_KEY",
327
+ "apiKeyHost" => "api.anthropic.com",
328
+ "subscription" => nil,
329
+ "definition" => {
330
+ "command" => "python3 ~/agent.py",
331
+ "secrets" => ["ANTHROPIC_API_KEY"]
332
+ }
333
+ },
334
+ "openai-agents-sdk" => {
335
+ "key" => "openai-agents-sdk",
336
+ "title" => "OpenAI Agents SDK",
337
+ "command" => "python3 ~/agent.py",
338
+ "apiKeySecret" => "OPENAI_API_KEY",
339
+ "apiKeyHost" => "api.openai.com",
340
+ "subscription" => nil,
341
+ "definition" => {
342
+ "command" => "python3 ~/agent.py",
343
+ "secrets" => ["OPENAI_API_KEY"]
344
+ }
345
+ }
346
+ },
347
+ "uses" => {
348
+ "support" => {
349
+ "key" => "support",
350
+ "title" => "Your customers",
351
+ "sessionKey" => "customer:<id>",
352
+ "triggers" => %w[api webhook],
353
+ "definition" => {
354
+ "template" => "support",
355
+ "vcpu" => 1,
356
+ "memoryMiB" => 1024,
357
+ "idlePauseSeconds" => 10,
358
+ "maxConcurrent" => 1000
359
+ }
360
+ },
361
+ "team" => {
362
+ "key" => "team",
363
+ "title" => "Your team",
364
+ "sessionKey" => nil,
365
+ "triggers" => %w[slack github],
366
+ "definition" => {
367
+ "template" => "team",
368
+ "vcpu" => 4,
369
+ "memoryMiB" => 8192
370
+ }
371
+ }
372
+ }
373
+ }.then { |data| JSON.parse(JSON.generate(data), freeze: true) }
374
+
375
+ # A definition given as a Record (a template's own) as the Hash it holds.
376
+ def self.plain(definition) = definition.is_a?(Record) ? definition.to_h : definition
377
+
378
+ # The harness template +key+ (+key+, +title+, +command+, +api_key_secret+,
379
+ # +api_key_host+, +subscription+: how to log in on one, or nil, and
380
+ # +definition+), or nil.
381
+ def [](key) = DATA["templates"][key.to_s]&.then { |template| Record.new(template) }
382
+
383
+ def keys = DATA["templates"].keys
384
+
385
+ # The harness templates, in order.
386
+ def each(&block)
387
+ return enum_for(:each) unless block
388
+
389
+ DATA["templates"].each_value { |template| block.call(Record.new(template)) }
390
+ end
391
+
392
+ # The uses, "support" (your customers, driven by your own app: one session
393
+ # per end customer under your own key such as "customer:42", paused between
394
+ # their messages) and "team" (your team's
395
+ # code, from Slack and GitHub): +key+, +title+, +session_key+, +triggers+
396
+ # and the +definition+ preset merged over a template's.
397
+ def uses = DATA["uses"].transform_values { |use| Record.new(use) }
398
+
399
+ # A new definition: the template's, with the use's preset over it. Ready
400
+ # for +agents.create(name, definition)+. An unknown key raises
401
+ # InvalidRequestError before anything is sent.
402
+ def definition(template, use: nil)
403
+ found = DATA["templates"][template.to_s] or raise unknown("template", template, DATA["templates"].keys)
404
+ out = JSON.parse(JSON.generate(found["definition"]))
405
+ return out if use.nil?
406
+
407
+ preset = DATA["uses"][use.to_s] or raise unknown("use", use, DATA["uses"].keys)
408
+ out.merge(JSON.parse(JSON.generate(preset["definition"])))
409
+ end
410
+
411
+ # Every template and use as plain JSON data: { "templates" => ..., "uses" => ... }.
412
+ def to_h = JSON.parse(JSON.generate(DATA))
413
+
414
+ def inspect = "#<#{self.class.name} #{keys.join(", ")}; uses #{DATA["uses"].keys.join(", ")}>"
415
+
416
+ private
417
+
418
+ def unknown(what, value, known)
419
+ InvalidRequestError.new("No agent #{what} is called #{value.inspect}.", code: "invalid_request", status: 400,
420
+ hint: "One of: #{known.join(", ")}.")
421
+ end
422
+ end
423
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module WithRuntime
6
+ # A product built on the five verbs every resource shares (create, get,
7
+ # list, update, delete) and following one to a state: the same rules as the
8
+ # JavaScript SDK's resources.ts. One deadline covers a write, its retries
9
+ # and every read after it.
10
+ class ResourceProduct < Product
11
+ # A whole wait's deadline and its pause between reads.
12
+ class Following
13
+ attr_reader :key
14
+
15
+ def initialize(timeout, poll, key)
16
+ @poll = poll
17
+ @key = key
18
+ @deadline = timeout.positive? ? now + timeout : nil
19
+ end
20
+
21
+ # Seconds left for a call, or nil when the wait has no deadline (the
22
+ # client's own timeout then bounds each call).
23
+ def left = @deadline && [@deadline - now, 0.001].max
24
+
25
+ def expired? = !@deadline.nil? && now >= @deadline
26
+
27
+ def pause
28
+ sleep(@deadline ? [@poll, [@deadline - now, 0].max].min : @poll)
29
+ raise timed_out if expired?
30
+ end
31
+
32
+ def timed_out
33
+ ConnectionError.new("Waiting was cancelled or reached its deadline; the resource may still change.",
34
+ code: "timeout", idempotency_key: key,
35
+ hint: "Read the resource to resume waiting. Retry a write with the same idempotency_key.")
36
+ end
37
+
38
+ private
39
+
40
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ end
42
+
43
+ # +path+ is the product's path (/v1/apps); a wait ends at its target or at
44
+ # any of +settled+, which raises the resource's own error.
45
+ def initialize(transport, path, settled)
46
+ super(transport)
47
+ @base = path
48
+ @settled = settled
49
+ end
50
+
51
+ # Creates one; defaults belong to the API, so the SDK sends none.
52
+ def create(idempotency_key: nil, **fields)
53
+ Record.new(@t.json("POST", @base, body: Fields.body(fields), key: idempotency_key))
54
+ end
55
+
56
+ # With +wait_for:+ the API holds the answer until that state or
57
+ # +timeout_seconds+ (1 to 60; 30 by default) pass. Check the state it returns.
58
+ def get(id, wait_for: nil, timeout_seconds: nil)
59
+ read(id, { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds },
60
+ wait_for && ((timeout_seconds || 30) + 60))
61
+ end
62
+
63
+ # +label:+ is key:value filters that all must match; +state:+ defaults to
64
+ # the product's live states; +limit:+ 1 to 100 (50).
65
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
66
+ page(@base, { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
67
+ end
68
+
69
+ # Fields left out stay as they are; +name: :remove+ clears the name.
70
+ def update(id, idempotency_key: nil, **changes)
71
+ Record.new(@t.json("POST", "#{@base}/#{seg(id)}:update", body: patch(changes), key: idempotency_key))
72
+ end
73
+
74
+ # Follows the resource's state to +target+, reading it every +poll+
75
+ # seconds (0.25) for at most +timeout+ seconds in all (300; 0 waits as long
76
+ # as it takes). Raises the resource's error when it settles elsewhere, and
77
+ # ConnectionError with code timeout at the deadline.
78
+ def wait(id, target, timeout: 300, poll: 0.25)
79
+ following(timeout, poll, nil) { |wait| follow(id, target, wait) }
80
+ end
81
+
82
+ # Deletes it. With +wait: true+, follows it to deleted under one
83
+ # idempotency key made before the first try, so a timed-out delete can be
84
+ # retried after a restart with the key its error carries.
85
+ def delete(id, wait: false, timeout: 300, poll: 0.25, idempotency_key: nil)
86
+ return remove(id, idempotency_key, nil) unless wait
87
+
88
+ key = idempotency_key || SecureRandom.uuid
89
+ following(timeout, poll, key) { |follow| follow(id, "deleted", follow, remove(id, key, follow.left)) }
90
+ end
91
+
92
+ private
93
+
94
+ def read(id, query = nil, timeout = nil)
95
+ Record.new(@t.json("GET", "#{@base}/#{seg(id)}", query: query, timeout: timeout))
96
+ end
97
+
98
+ def remove(id, key, timeout) = Record.new(@t.json("DELETE", "#{@base}/#{seg(id)}", key: key, timeout: timeout))
99
+
100
+ def follow(id, target, wait, initial = nil)
101
+ record = initial || read(id, nil, wait.left)
102
+ loop do
103
+ return record if record.state == target
104
+ if @settled.include?(record.state)
105
+ error = record["error"].is_a?(Hash) ? record["error"] : {}
106
+ raise Error.new(error["message"] || "Expected #{target}, but #{id} reached #{record.state}.",
107
+ code: error["code"] || "unexpected_state", idempotency_key: wait.key,
108
+ hint: "Read the resource and inspect its error before starting another change.",
109
+ details: { "id" => id, "state" => record.state, "target" => target, "resource" => record.to_h })
110
+ end
111
+ wait.pause
112
+ record = read(id, nil, wait.left)
113
+ end
114
+ end
115
+
116
+ # One deadline covers the write, retries and every read. A read's failure
117
+ # still belongs to the write that started the wait, so it carries its key.
118
+ def following(timeout, poll, key)
119
+ check(timeout, "timeout")
120
+ check(poll, "poll")
121
+ wait = Following.new(timeout, poll, key)
122
+ begin
123
+ result = yield wait
124
+ raise wait.timed_out if wait.expired?
125
+
126
+ result
127
+ rescue Error => e
128
+ raise wait.timed_out if wait.expired?
129
+ raise e if key.nil? || e.idempotency_key
130
+
131
+ raise e.class.new(e.message, code: e.code, status: e.status, hint: e.hint, request_id: e.request_id,
132
+ details: e.details, idempotency_key: key, retry_after: e.retry_after)
133
+ end
134
+ end
135
+
136
+ def check(value, field)
137
+ return if value.is_a?(Numeric) && value.finite? && !value.negative?
138
+
139
+ raise Error.new("#{field} must be a finite, nonnegative number.", code: "invalid_request",
140
+ hint: "Fix the option and call again; nothing was sent.",
141
+ details: { "field" => field })
142
+ end
143
+ end
144
+
145
+ # +runtime.apps+ and +runtime.functions+: your server, deployed from an
146
+ # image or a build, kept running in copies (an App keeps +min_copies+ awake;
147
+ # a Function sleeps to zero). In a private beta: an account outside it is
148
+ # refused with product_in_beta (403, PermissionDeniedError).
149
+ #
150
+ # app = runtime.apps.create(name: "web", max_copies: 3, vcpu: 1, memory_mib: 1024)
151
+ # deploy = runtime.apps.deploy(app.id, image: "web:latest", env: { "MODE" => "prod" }, wait: true)
152
+ # puts deploy.url
153
+ #
154
+ # An App or Function: id, name, labels, state (waiting, live, failed,
155
+ # deleting, deleted), min_copies, max_copies, concurrency, spares, vcpu,
156
+ # memory_mib, port, health_path, live_version, previous_version, error
157
+ # ({code, message} or nil), created_at, updated_at, deleted_at, and
158
+ # release_watch (rollback, alert or off) where the API sends it.
159
+ #
160
+ # A deploy: id, version, url and state (building, warming, switching, live,
161
+ # failed, superseded); a failed or superseded one has error with code
162
+ # (deploy_build_failed, deploy_health_failed, deploy_superseded), message,
163
+ # hint and last_output. A version: version, live, previous, saved, deploy
164
+ # (its deploy's id) and build (nil for an image). A copy: id, version,
165
+ # region, state (starting, serving, draining, frozen, parked) and since.
166
+ class Apps < ResourceProduct
167
+ DEPLOY_ENDED = %w[live failed superseded].freeze
168
+
169
+ # +path+: /v1/apps or /v1/functions.
170
+ def initialize(transport, path = "/v1/apps")
171
+ super(transport, path, %w[failed deleted live])
172
+ end
173
+
174
+ # Creates one waiting for its first version: +max_copies:+ (1 to 1000),
175
+ # +vcpu:+ (1 to 16) and +memory_mib:+ (128 to 65536) are required;
176
+ # +concurrency:+, +spares:+, +port:+ (8080), +health_path:+, +name:+,
177
+ # +labels:+, and for an App +min_copies:+ (0). A Function leaves
178
+ # +min_copies+ out, zero included.
179
+ def create(idempotency_key: nil, **fields) = super
180
+
181
+ # Follows resource state, not a deploy: an older live version can survive
182
+ # a failed rollout of a newer one. +target+ is "live" or "deleted".
183
+ def wait(id, target, timeout: 300, poll: 0.25) = super
184
+
185
+ # Deploys a new version from +image:+ (an image of this account: id,
186
+ # name, name:tag or name@version, ready) or +build:+ (a build's id), with
187
+ # +env:+, +port:+ and +health_path:+. +keep_env: true+ carries the last
188
+ # deploy's environment, still sealed, with +env:+'s names set over it.
189
+ # With +wait: true+, follows the deploy, under one idempotency key, until
190
+ # it is live, failed or superseded, and returns it (check its state).
191
+ def deploy(id, image: nil, build: nil, env: nil, keep_env: nil, port: nil, health_path: nil, wait: false,
192
+ timeout: 300, poll: 0.25, idempotency_key: nil)
193
+ unless image.nil? ^ build.nil?
194
+ raise ArgumentError, "Deploy exactly one of image: or build:; nothing was sent."
195
+ end
196
+
197
+ body = Fields.body(image: image, build: build, env: env, keep_env: keep_env, port: port, health_path: health_path)
198
+ write_deploy(id, "deploys", body, wait, timeout, poll, idempotency_key)
199
+ end
200
+
201
+ # A deploy, by the App's id and its own. With +wait_for:+ the API holds
202
+ # the answer until that state or +timeout_seconds+ pass.
203
+ def get_deploy(id, deploy_id, wait_for: nil, timeout_seconds: nil)
204
+ read_deploy(id, deploy_id, wait_for, timeout_seconds, wait_for && ((timeout_seconds || 30) + 60))
205
+ end
206
+
207
+ # Resumes following a deploy by id after a restart, without making another version.
208
+ def wait_deploy(id, deploy_id, timeout: 300, poll: 0.25)
209
+ following(timeout, poll, nil) do |wait|
210
+ follow_deploy(id, read_deploy(id, deploy_id, nil, nil, wait.left), wait)
211
+ end
212
+ end
213
+
214
+ # Goes back to the previous version, as a new deploy; +wait: true+ as for +deploy+.
215
+ def rollback(id, wait: false, timeout: 300, poll: 0.25, idempotency_key: nil)
216
+ write_deploy(id, "rollback", {}, wait, timeout, poll, idempotency_key)
217
+ end
218
+
219
+ def versions(id) = @t.json("GET", "#{@base}/#{seg(id)}/versions").map { |version| Record.new(version) }
220
+
221
+ def copies(id) = @t.json("GET", "#{@base}/#{seg(id)}/copies").map { |copy| Record.new(copy) }
222
+
223
+ private
224
+
225
+ def read_deploy(id, deploy_id, wait_for, timeout_seconds, timeout)
226
+ Record.new(@t.json("GET", "#{@base}/#{seg(id)}/deploys/#{seg(deploy_id)}",
227
+ query: { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds }, timeout: timeout))
228
+ end
229
+
230
+ def follow_deploy(id, record, wait)
231
+ until DEPLOY_ENDED.include?(record.state)
232
+ record = read_deploy(id, record.id, "live", 60, wait.left)
233
+ wait.pause unless DEPLOY_ENDED.include?(record.state)
234
+ end
235
+ record
236
+ end
237
+
238
+ def write_deploy(id, verb, body, wait, timeout, poll, idempotency_key)
239
+ write = ->(key, left) { Record.new(@t.json("POST", "#{@base}/#{seg(id)}/#{verb}", body: body, key: key, timeout: left)) }
240
+ return write.call(idempotency_key, nil) unless wait
241
+
242
+ key = idempotency_key || SecureRandom.uuid
243
+ following(timeout, poll, key) { |follow| follow_deploy(id, write.call(key, follow.left), follow) }
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # +runtime.builds+: a connected repository's commit built into the server an
5
+ # App or Function deploys. In a private beta: an account outside it is
6
+ # refused with product_in_beta (403, PermissionDeniedError).
7
+ #
8
+ # repo = runtime.builds.repositories.connect(provider: "github", repository: "acme/web", deploy: "app:#{app.id}")
9
+ # build = runtime.builds.create(repository_id: repo.id, ref: "main")
10
+ # runtime.builds.logs(build.id, follow: true) { |event| puts event.text if event.type == "line" }
11
+ #
12
+ # A build: id, name, labels, repository_id, ref, branch, commit, state
13
+ # (queued, fetching, building, deploying, succeeded, failed, cancelled,
14
+ # superseded), reason, timings (milliseconds per step once done, or nil),
15
+ # output_snapshot_id, error, created_at, updated_at and deleted_at.
16
+ class Builds < Product
17
+ TERMINAL = %w[succeeded failed cancelled superseded].freeze
18
+ # Reconnections in a row with no new line before a follow gives up.
19
+ RECONNECTS = 3
20
+ SEEN = 10_000
21
+ SAFE_INTEGER = (2**53) - 1
22
+
23
+ attr_reader :repositories
24
+
25
+ def initialize(transport)
26
+ super
27
+ @repositories = Repositories.new(transport)
28
+ end
29
+
30
+ # Builds +ref+ of a connected repository. The same +branch:+ and commit is
31
+ # one build; a newer build of the branch supersedes older ones not ended.
32
+ def create(repository_id:, ref:, branch: nil, name: nil, labels: nil, idempotency_key: nil)
33
+ body = Fields.body(repository_id: repository_id, ref: ref, branch: branch, name: name, labels: labels)
34
+ Record.new(@t.json("POST", "/v1/builds", body: body, key: idempotency_key))
35
+ end
36
+
37
+ def get(id) = Record.new(@t.json("GET", path(id)))
38
+
39
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
40
+ page("/v1/builds", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
41
+ end
42
+
43
+ # Cancels a build; one that already ended stays as it is.
44
+ def cancel(id, idempotency_key: nil) = Record.new(@t.json("POST", "#{path(id)}:cancel", key: idempotency_key))
45
+
46
+ # The last 200 lines and the failing step: lines (cursor, at, step, text,
47
+ # stream), cursor, state, reason, failing_step (name, exit_code,
48
+ # last_lines), has_more and next_cursor; +page_cursor:+ pages older lines.
49
+ #
50
+ # With +follow: true+, each event to the block as it is written (or an
51
+ # Enumerator without one), until the build ends: a line (type "line"), a
52
+ # gap where lines were lost (type "gap", earliest_cursor), then one
53
+ # terminal event (state, reason, failing_step). +cursor:+ resumes after
54
+ # that line. A lost connection reconnects from the last line, up to three
55
+ # times in a row; then build_log_interrupted, whose details cursor resumes.
56
+ # +timeout:+ bounds the whole follow, reconnections included (120 seconds;
57
+ # 0 for none).
58
+ def logs(id, follow: false, cursor: nil, page_cursor: nil, timeout: 120, &block)
59
+ unless follow
60
+ return Record.new(@t.json("GET", "#{path(id)}/logs", query: { "cursor" => cursor, "pageCursor" => page_cursor }))
61
+ end
62
+ return enum_for(:logs, id, follow: true, cursor: cursor, page_cursor: page_cursor, timeout: timeout) unless block
63
+
64
+ follow_logs(id, cursor, page_cursor, timeout, &block)
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ def path(id) = "/v1/builds/#{seg(id)}"
71
+
72
+ def failure(code, hint, cursor) = Error.new(hint, code: code, hint: hint, details: { "cursor" => cursor })
73
+
74
+ def invalid(cursor) = failure("build_log_invalid", "Retry the log from the last received cursor.", cursor)
75
+
76
+ def valid_cursor?(value)
77
+ value.is_a?(String) && !value.empty? && value.length <= 4096 && !value.match?(/[\x00-\x1f\x7f]/)
78
+ end
79
+
80
+ def valid_failure?(value)
81
+ return true if value.nil?
82
+ return false unless value.is_a?(Hash)
83
+
84
+ lines = value["lastLines"]
85
+ value["name"].is_a?(String) && (value["exitCode"].nil? || safe_integer?(value["exitCode"])) &&
86
+ lines.is_a?(Array) && lines.size <= 20 && lines.all?(String)
87
+ end
88
+
89
+ def safe_integer?(value) = value.is_a?(Integer) && value.abs <= SAFE_INTEGER
90
+
91
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
+
93
+ # Cursors are opaque physical positions: they cannot be ordered or
94
+ # counted on, so lines already given are known by their cursor.
95
+ def follow_logs(id, cursor, page_cursor, timeout)
96
+ if (cursor && !valid_cursor?(cursor)) || page_cursor
97
+ raise failure("invalid_log_options", "Use an opaque cursor for follow; page_cursor is only for search pages.", cursor)
98
+ end
99
+
100
+ seen = cursor ? { cursor => true } : {}
101
+ failures = 0
102
+ deadline = timeout.positive? ? now + timeout : nil
103
+ timed_out = lambda do
104
+ ConnectionError.new("The log follow reached its deadline.", code: "timeout", details: { "cursor" => cursor },
105
+ hint: "Resume with logs(id, follow: true, cursor: details[\"cursor\"]).")
106
+ end
107
+ loop do
108
+ raise timed_out.call if deadline && now >= deadline
109
+
110
+ continued = false
111
+ begin
112
+ @t.events("GET", "#{path(id)}/logs", query: { "follow" => true, "cursor" => cursor },
113
+ timeout: deadline && (deadline - now)) do |event|
114
+ raise invalid(cursor) unless event.is_a?(Hash)
115
+ raise invalid(cursor) unless event.key?("cursor") && (event["cursor"].nil? || valid_cursor?(event["cursor"]))
116
+
117
+ case event["type"]
118
+ when "line"
119
+ unless event["text"].is_a?(String) && %w[stdout stderr].include?(event["stream"]) &&
120
+ safe_integer?(event["at"]) && !event["at"].negative? &&
121
+ (event["step"].nil? || event["step"].is_a?(String)) && valid_cursor?(event["cursor"])
122
+ raise invalid(cursor)
123
+ end
124
+ next if seen.key?(event["cursor"])
125
+
126
+ seen[event["cursor"]] = true
127
+ seen.delete(seen.first[0]) if seen.size > SEEN
128
+ cursor = event["cursor"]
129
+ failures = 0
130
+ yield Record.new(event)
131
+ when "gap"
132
+ raise invalid(cursor) if !valid_cursor?(event["earliestCursor"]) || (cursor && event["cursor"] != cursor)
133
+
134
+ cursor = event["cursor"]
135
+ yield Record.new(event)
136
+ when "terminal"
137
+ if (cursor && event["cursor"] != cursor) || !TERMINAL.include?(event["state"]) ||
138
+ !event["reason"].is_a?(String) || event["reason"].empty? || !valid_failure?(event["failingStep"]) ||
139
+ (event["state"] != "failed" && !event["failingStep"].nil?)
140
+ raise invalid(cursor)
141
+ end
142
+
143
+ yield Record.new(event)
144
+ return
145
+ when "continue"
146
+ if cursor && event["cursor"] != cursor
147
+ raise failure("build_log_gap", "Resume from details.cursor; report a persistent missing log line.", cursor)
148
+ end
149
+
150
+ cursor = event["cursor"]
151
+ continued = true
152
+ break
153
+ when "error"
154
+ unless event["cursor"] == cursor && event["code"].is_a?(String) && !event["code"].empty? &&
155
+ event["hint"].is_a?(String) && !event["hint"].empty?
156
+ raise invalid(cursor)
157
+ end
158
+
159
+ raise failure(event["code"], event["hint"], cursor)
160
+ else
161
+ raise invalid(cursor)
162
+ end
163
+ end
164
+ rescue ConnectionError
165
+ # A lost connection, not a refusal: the transport itself retries
166
+ # retryable answers. Reconnect from the last line.
167
+ raise timed_out.call if deadline && now >= deadline
168
+ rescue Error => e
169
+ raise e unless e.code == "unexpected_answer" && e.status.zero?
170
+
171
+ raise failure("build_log_invalid", "The log contained invalid JSON; retry from details.cursor.", cursor)
172
+ end
173
+ failures += 1 unless continued
174
+ if failures > RECONNECTS
175
+ raise failure("build_log_interrupted", "Reconnect with logs(id, follow: true, cursor: details[\"cursor\"]).", cursor)
176
+ end
177
+
178
+ # A service that is down, or continuations one after another, never busy-loop.
179
+ pause = 0.05 * [1, failures].max
180
+ pause = [pause, [deadline - now, 0].max].min if deadline
181
+ sleep(pause)
182
+ end
183
+ end
184
+ end
185
+
186
+ # +runtime.builds.repositories+: the repositories Runtime builds. Every push
187
+ # to a repository's production branch builds it and deploys it to its
188
+ # +deploy+ target. In the Builds private beta (product_in_beta, 403, outside it).
189
+ #
190
+ # A repository: id, name, labels, provider (github or git), repository,
191
+ # deploy (app:<id>, function:<id> or container:<id>), production_branch,
192
+ # previews, root_directory, install_command, build_command, start_command,
193
+ # port, health_path, build_env, state (connected or disconnected), error,
194
+ # created_at, updated_at and deleted_at.
195
+ class Repositories < Product
196
+ # Connects +repository+ from +provider+ ("github" or "git") to +deploy+.
197
+ # Optional: production_branch, previews, root_directory, install_command
198
+ # (chosen by the lockfile when left out), build_command, start_command
199
+ # (the build then ends as that server, answering health_path with 200),
200
+ # port, health_path, build_env (never a secret: anyone who reads the
201
+ # repository sees it), name and labels.
202
+ def connect(provider:, repository:, deploy:, idempotency_key: nil, **settings)
203
+ body = Fields.body(settings.merge(provider: provider, repository: repository, deploy: deploy))
204
+ Record.new(@t.json("POST", "/v1/repositories", body: body, key: idempotency_key))
205
+ end
206
+
207
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
208
+ page("/v1/repositories", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
209
+ end
210
+
211
+ # Changes settings in place; its id and builds stay. Fields left out stay
212
+ # as they are; +:remove+ clears name, install_command, build_command,
213
+ # start_command or health_path.
214
+ def update(id, idempotency_key: nil, **changes)
215
+ Record.new(@t.json("POST", "/v1/repositories/#{seg(id)}:update", body: patch(changes), key: idempotency_key))
216
+ end
217
+
218
+ def disconnect(id, idempotency_key: nil) = Record.new(@t.json("DELETE", "/v1/repositories/#{seg(id)}", key: idempotency_key))
219
+
220
+ # A link (url, expires_at) to install Runtime's GitHub App, so private
221
+ # repositories build. Open it in a browser within 15 minutes, install the
222
+ # App where Runtime may read, then connect the repository with provider github.
223
+ def install_github = Record.new(@t.json("POST", "/v1/repositories/github:install"))
224
+ end
225
+ end
@@ -23,8 +23,9 @@ module WithRuntime
23
23
  RENAME_RETIRES = nil
24
24
 
25
25
  class Client
26
- attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :referrals,
27
- :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp
26
+ attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :emails, :referrals,
27
+ :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents,
28
+ :apps, :functions, :builds
28
29
 
29
30
  # +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
30
31
  # +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
@@ -56,6 +57,7 @@ module WithRuntime
56
57
  @volumes = Volumes.new(@transport)
57
58
  @secrets = Secrets.new(@transport)
58
59
  @limits = Limits.new(@transport)
60
+ @emails = Emails.new(@transport)
59
61
  @referrals = Referrals.new(@transport)
60
62
  @switching = Switching.new(@transport)
61
63
  @events = Events.new(@transport)
@@ -70,6 +72,11 @@ module WithRuntime
70
72
  @networks = Networks.new(PrivateTunnel.new(@transport))
71
73
  @sso = SSO.new(@transport)
72
74
  @mcp = MCP.new(@transport)
75
+ @agents = Agents.new(@transport)
76
+ # Private beta: outside it, product_in_beta (403).
77
+ @apps = Apps.new(@transport, "/v1/apps")
78
+ @functions = Apps.new(@transport, "/v1/functions")
79
+ @builds = Builds.new(@transport)
73
80
  end
74
81
 
75
82
  # Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
@@ -3,9 +3,9 @@
3
3
  module WithRuntime
4
4
  # Ruby's snake_case keywords as the API's camelCase fields: +timeout_seconds:+
5
5
  # is timeoutSeconds, +memory_mib:+ is memoryMiB. User data (labels, env,
6
- # build_args, headers and the like) is sent as it is.
6
+ # build_args, build_env, headers and the like) is sent as it is.
7
7
  module Fields
8
- OPAQUE = %w[labels env buildArgs headers data context detail].freeze
8
+ OPAQUE = %w[labels env buildArgs buildEnv headers data context detail].freeze
9
9
 
10
10
  module_function
11
11
 
@@ -13,6 +13,14 @@ module WithRuntime
13
13
 
14
14
  def seg(value) = Transport.segment(value)
15
15
 
16
+ # A change's body from keyword arguments: nil is left out, +:remove+ sends
17
+ # null, which clears the field.
18
+ def patch(changes)
19
+ body = Fields.body(changes.reject { |_key, value| value == :remove })
20
+ changes.each { |key, value| body[key.is_a?(Symbol) ? Fields.camel(key) : key.to_s] = nil if value == :remove }
21
+ body
22
+ end
23
+
16
24
  def page(path, query)
17
25
  body = @t.json("GET", path, query: query)
18
26
  Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) do |cursor|
@@ -88,12 +96,22 @@ module WithRuntime
88
96
  class Secrets < Product
89
97
  # Stores or replaces a secret for +hosts+ ("api.openai.com", "*.github.com").
90
98
  # With +header:+, that header is set on every request to them, +format:+'s
91
- # {value} replaced.
92
- def set(name, value:, hosts:, header: nil, format: nil)
93
- body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format }.compact
99
+ # {value} replaced. With +only:+ (<tt>{ labels: [{ role: "hub" }], sandboxes: [id] }</tt>),
100
+ # only those sandboxes get it instead of every sandbox of the organization: those whose
101
+ # labels hold every pair of any one selector, and those named by id. Left out when replacing
102
+ # a secret, the scope it has stays, so a new value never widens who gets it; +{}+ is every
103
+ # sandbox again.
104
+ def set(name, value:, hosts:, header: nil, format: nil, only: nil)
105
+ body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format, "only" => only }.compact
94
106
  Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}", body: body))
95
107
  end
96
108
 
109
+ # Changes which sandboxes get a secret, without its value: +{}+ is every sandbox of the
110
+ # organization again. Returns the secret, with +only+ and +enforced+.
111
+ def scope(name, only)
112
+ Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}/sandboxes", body: only))
113
+ end
114
+
97
115
  # Names, hosts and placeholders. Never values.
98
116
  def list = @t.json("GET", "/v1/egress-secrets")["secrets"].map { |secret| Record.new(secret) }
99
117
 
@@ -115,6 +133,21 @@ module WithRuntime
115
133
  def get = Record.new(@t.json("GET", "/v1/referrals"))
116
134
  end
117
135
 
136
+ # +runtime.emails+: which of Runtime's optional emails this key's person gets
137
+ # for this account: own-deletions (warnings for dates the account's own
138
+ # retention set), billing, usage and tips. Sign-in, support replies, credit
139
+ # expiry and deletions Runtime decides are always sent and are not listed.
140
+ class Emails < Product
141
+ def get = @t.json("GET", "/v1/account/emails")["settings"].map { |row| Record.new(row) }
142
+
143
+ # Turns one on or off for this key's person only, and answers all of them.
144
+ # Needs a key for every action.
145
+ def set(category, enabled)
146
+ body = { "category" => category, "enabled" => enabled }
147
+ @t.json("POST", "/v1/account/emails:update", body: body)["settings"].map { |row| Record.new(row) }
148
+ end
149
+ end
150
+
118
151
  # +runtime.switching+: compare usage with a rival's published rates, and record a switch.
119
152
  class Switching < Product
120
153
  # Your settled usage over the last +days+ (30 by default, at most 90) priced
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/withruntime.rb CHANGED
@@ -26,7 +26,10 @@ require_relative "withruntime/wallet"
26
26
  require_relative "withruntime/tar"
27
27
  require_relative "withruntime/sandbox"
28
28
  require_relative "withruntime/products"
29
+ require_relative "withruntime/agents"
29
30
  require_relative "withruntime/images"
31
+ require_relative "withruntime/apps"
32
+ require_relative "withruntime/builds"
30
33
  require_relative "withruntime/network_products"
31
34
  require_relative "withruntime/parity_products"
32
35
  require_relative "withruntime/file_watch"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: withruntime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Runtime
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Linux sandboxes for agents, and every other Runtime Cloud product: streaming
14
14
  commands and terminals, files and watches, images, durable volumes and backups,
@@ -20,7 +20,10 @@ extra_rdoc_files: []
20
20
  files:
21
21
  - LICENSE
22
22
  - lib/withruntime.rb
23
+ - lib/withruntime/agents.rb
23
24
  - lib/withruntime/api_defaults.rb
25
+ - lib/withruntime/apps.rb
26
+ - lib/withruntime/builds.rb
24
27
  - lib/withruntime/client.rb
25
28
  - lib/withruntime/credentials.rb
26
29
  - lib/withruntime/errors.rb