withruntime 0.9.0 → 0.16.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: dcc3df961b7af1a6f8c2941b1e829ce2e7b4f0e1dfcc8a57a5b32159c89996e7
4
- data.tar.gz: e664bcedb285f6edda1adc3e5fd05fc5a4bd2b304e2e8d64d5dd46420128984f
3
+ metadata.gz: 92aca2f2d4a8d94b0871172606a8608e07d916e2e7236adab4b5752409fb466e
4
+ data.tar.gz: 87b4a3b19035d177f07c5a98b5adf256e7eab48769c1f803850d10b552800f64
5
5
  SHA512:
6
- metadata.gz: 28da69ad5124df7ff53dc9b2ce0d17b7bd3e9ef934512884433bfe1aea3fa0983b259ec5dc2ea50f2f9c138e19e8d296221ef6b895115762c13e5a69ef082d63
7
- data.tar.gz: 5d468e2f92368d91bea955b684fffc05f8be9404aa54c3dae3434baa1e3882de718416ce341ea26a30a49966cf416d5f986a188ade46888971315018afc2ebf0
6
+ metadata.gz: 28866196e471465715beba471c141ae01a1de33f5b4fc66266a53dc5fc27f170c1d0f67920bbeff930ee4bcaf00a574768e4800d71b292b8cbe88426cca049c8
7
+ data.tar.gz: e1339ba49b930b10c79f043d5187906dfff71c0a2832f7dc5569b2cd0b5a62b1544f6b312ca9944d9bc384d7718eab8cfb32f11a8642e3782d29d06e6c96aee5
@@ -59,8 +59,10 @@ module WithRuntime
59
59
  # session.events.each { |event| puts event.text if event.kind == "reply" }
60
60
  #
61
61
  # A definition is a Hash, by snake_case keyword (+memory_mib:+) or by the
62
- # API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: } or
63
- # { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set; a
62
+ # API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: },
63
+ # { snapshot: }, or { repo: "owner/name" }: a repository connected to
64
+ # CI/CD, each session a copy of its warm snapshot, checked out at
65
+ # /workspace/repo), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set; a
64
66
  # definition from a snapshot takes neither, InvalidRequestError, since a
65
67
  # snapshot keeps its own size), +instructions+, +env+, +secrets+ (names of
66
68
  # egress secrets: model keys go here), +mcp+ ([{ name:, url: }]), +volumes+
@@ -69,17 +71,18 @@ module WithRuntime
69
71
  # unless set: the session and every session under it, the root's covering
70
72
  # its tree; a tree at it stops, reason "budget"), +max_day_dollars+ (more
71
73
  # than 0, at most 100000, 100 unless set: the agent's sessions started that
72
- # UTC day; an agent at it starts nothing new, ConflictError
73
- # +budget_reached+, until the next day) and +template+ ("support" or
74
+ # UTC day; an agent at it starts nothing new, +budget_reached+
75
+ # (402), until the next day) and +template+ ("support" or
74
76
  # "team").
75
77
  class Agents < Product
76
- attr_reader :sessions, :triggers, :templates
78
+ attr_reader :sessions, :triggers, :templates, :memory
77
79
 
78
80
  def initialize(transport)
79
81
  super
80
82
  @sessions = AgentSessions.new(transport)
81
83
  @triggers = AgentTriggers.new(transport)
82
84
  @templates = AgentTemplates.new
85
+ @memory = AgentMemory.new(transport)
83
86
  # Approvals come later, here: agents.approvals.list / answer.
84
87
  end
85
88
 
@@ -111,7 +114,7 @@ module WithRuntime
111
114
  # name for the conversation (a Slack thread, a pull request, one of your
112
115
  # customers such as "customer:42"), words with the same key join the open
113
116
  # session instead of starting a second; +session.joined+ says so. An agent
114
- # at its day's budget is refused +budget_reached+ (ConflictError).
117
+ # at its day's budget is refused +budget_reached+ (402).
115
118
  def start(agent, input, key: nil, labels: nil, idempotency_key: nil)
116
119
  body = Fields.body(input: input, key: key, labels: labels)
117
120
  answer = @t.json("POST", "/v1/agents/#{seg(agent)}/sessions", body: body, key: idempotency_key)
@@ -310,6 +313,69 @@ module WithRuntime
310
313
  def delete(id) = @t.json("DELETE", "/v1/agents/triggers/#{seg(id)}") && nil
311
314
  end
312
315
 
316
+ # A page of an agent's memory files; +usage+ is the agent's whole memory,
317
+ # every scope: +bytes+, +files+, +limit_bytes+ and +limit_files+.
318
+ class AgentMemoryPage < Page
319
+ attr_reader :usage
320
+
321
+ def initialize(data, next_cursor, usage, &fetch)
322
+ super(data, next_cursor, &fetch)
323
+ @usage = usage
324
+ end
325
+ end
326
+
327
+ # +runtime.agents.memory+: an agent's built-in memory, text files its
328
+ # sessions keep across sessions and search. A +scope+ is whose: "" (the
329
+ # default) for what every session of the agent shares, or a session key such
330
+ # as "customer:42" for that key's alone. Up to 1 MiB a file, 1 GiB and
331
+ # 100,000 files an agent, billed as storage; past them a write is refused
332
+ # +memory_full+ (ConflictError).
333
+ #
334
+ # runtime.agents.memory.write("helpdesk", "faq/refunds.md", "Refunds within 30 days.")
335
+ # runtime.agents.memory.search("helpdesk", "refund").each { |hit| puts "#{hit.path}: #{hit.snippet}" }
336
+ class AgentMemory < Product
337
+ # The first page of an agent's files by scope and path, each with +scope+,
338
+ # +path+, +bytes+, +updated_at+ and +updated_by+; every scope unless
339
+ # +scope:+ is given. +each+ walks every page.
340
+ def list(agent, scope: nil, prefix: nil, cursor: nil, limit: nil)
341
+ memory_page(agent, { "scope" => scope, "prefix" => prefix, "limit" => limit }, cursor)
342
+ end
343
+
344
+ # The files whose words or path match +q+ (words, "a phrase", or, -not),
345
+ # best first, each with a +snippet+ and +rank+; every scope unless
346
+ # +scope:+ is given.
347
+ def search(agent, q, scope: nil, limit: nil)
348
+ body = @t.json("GET", "/v1/agents/#{seg(agent)}/memory", query: { "scope" => scope, "q" => q, "limit" => limit })
349
+ body["data"].map { |row| Record.new(row) }
350
+ end
351
+
352
+ # A file with its +content+; none there is NotFoundError.
353
+ def read(agent, path, scope: "")
354
+ Record.new(@t.json("GET", file(agent), query: { "scope" => scope, "path" => path }))
355
+ end
356
+
357
+ # Seeds or replaces a file; +created+ says which.
358
+ def write(agent, path, content, scope: "")
359
+ Record.new(@t.json("PUT", file(agent), query: { "scope" => scope, "path" => path }, body: { "content" => content }))
360
+ end
361
+
362
+ # Deletes a file; whether there was one.
363
+ def delete(agent, path, scope: "")
364
+ @t.json("DELETE", file(agent), query: { "scope" => scope, "path" => path })["deleted"] == true
365
+ end
366
+
367
+ private
368
+
369
+ def file(agent) = "/v1/agents/#{seg(agent)}/memory/content"
370
+
371
+ def memory_page(agent, query, cursor)
372
+ body = @t.json("GET", "/v1/agents/#{seg(agent)}/memory", query: query.merge("cursor" => cursor))
373
+ AgentMemoryPage.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"], Record.new(body["usage"] || {})) do |after|
374
+ memory_page(agent, query, after)
375
+ end
376
+ end
377
+ end
378
+
313
379
  # +runtime.agents.templates+: the five harnesses an agent can run, each
314
380
  # usable for either use, and the two uses.
315
381
  #
@@ -151,20 +151,28 @@ module WithRuntime
151
151
  # deploy = runtime.apps.deploy(app.id, image: "web:latest", env: { "MODE" => "prod" }, wait: true)
152
152
  # puts deploy.url
153
153
  #
154
- # An App or Function: id, name, labels, state (waiting, live, failed,
154
+ # An App or Function: id, name, url (its address for life, its name then
155
+ # its account's: https://shop-3f9a1c2e7b04d915-acme1.runtimehost.com/; a rename leaves it,
156
+ # and its id's address answers too), labels, state (waiting, live, failed,
155
157
  # 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.
158
+ # memory_mib, port, health_path, cdn, live_version, previous_version, error
159
+ # ({code, message} or nil), line (while a copy waits for room: lane,
160
+ # place, ahead by lane and words; nil otherwise), created_at, updated_at,
161
+ # deleted_at, and release_watch (alert, the default; rollback; or off). A Function
162
+ # also has sleep_after_ms (250 to 5000).
159
163
  #
160
164
  # A deploy: id, version, url and state (building, warming, switching, live,
161
165
  # 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,
166
+ # (deploy_build_failed, deploy_health_failed, deploy_stop_failed,
167
+ # deploy_switch_failed, deploy_superseded), message, hint and last_output. A version: version,
168
+ # live, previous, saved, deploy (its deploy's id) and build (nil for an
169
+ # image). A copy: id, version,
165
170
  # region, state (starting, serving, draining, frozen, parked) and since.
166
171
  class Apps < ResourceProduct
167
172
  DEPLOY_ENDED = %w[live failed superseded].freeze
173
+ # Reconnections in a row with no new line before a log follow gives up.
174
+ LOG_RECONNECTS = 3
175
+ LOG_SEEN = 10_000
168
176
 
169
177
  # +path+: /v1/apps or /v1/functions.
170
178
  def initialize(transport, path = "/v1/apps")
@@ -173,9 +181,15 @@ module WithRuntime
173
181
 
174
182
  # Creates one waiting for its first version: +max_copies:+ (1 to 1000),
175
183
  # +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.
184
+ # +concurrency:+, +spares:+, +port:+ (8080), +health_path:+, +cdn:+ (true: Cloudflare's CDN in
185
+ # front of its addresses; false sends visitors straight to Runtime's routers,
186
+ # nothing cached), +name:+, +labels:+, +release_watch:+ ("alert" by default: tell the
187
+ # owners when a new version gets clearly worse in its first 15 minutes; "rollback" also
188
+ # goes back to the previous version; "off" does neither), and for an App
189
+ # +min_copies:+ (0). A Function leaves +min_copies+ out, zero included.
190
+ # A Function also takes +sleep_after_ms:+ (250 to 5000, default 1000): at
191
+ # most how long a copy stays awake after its last request ends; awake time
192
+ # with no request in flight is not billed. An App is refused it.
179
193
  def create(idempotency_key: nil, **fields) = super
180
194
 
181
195
  # Follows resource state, not a deploy: an older live version can survive
@@ -220,8 +234,115 @@ module WithRuntime
220
234
 
221
235
  def copies(id) = @t.json("GET", "#{@base}/#{seg(id)}/copies").map { |copy| Record.new(copy) }
222
236
 
237
+ # What its copies printed: lines (at, Unix milliseconds; stream, stdout
238
+ # or stderr; copy, a copy's id or nil; version or nil; text), oldest
239
+ # first, the newest +limit:+ (1 to 500; 100) in the window, with
240
+ # next_cursor and has_more. +since:+ is an RFC 3339 time or a duration
241
+ # back from now ("30s", "15m", "2h", "7d"; "1h" by default; 14 days at
242
+ # most). +version:+, +copy:+ and +stream:+ narrow it. Pass next_cursor as
243
+ # +cursor:+, with the same filters, for the page of older lines.
244
+ def logs(id, since: nil, version: nil, copy: nil, stream: nil, limit: nil, cursor: nil)
245
+ Record.new(@t.json("GET", "#{@base}/#{seg(id)}/logs", query: log_query(since, version, copy, stream, limit, cursor)))
246
+ end
247
+
248
+ # Each event to the block as its copies print (or an Enumerator without
249
+ # one), until the caller breaks out: a line (type "line", cursor, and a
250
+ # line's fields as for +logs+) or a gap where the store dropped lines
251
+ # (type "gap"); the stream goes on after it. A first follow sends the
252
+ # newest +limit:+ lines since +since:+, then new ones; +cursor:+ resumes
253
+ # after that line. Each follow the API ends after about two minutes is
254
+ # resumed from its cursor; a lost connection reconnects from the last
255
+ # line, up to three times in a row, then app_log_interrupted, whose details
256
+ # cursor resumes. An error event raises its own code and hint.
257
+ def follow_logs(id, since: nil, version: nil, copy: nil, stream: nil, limit: nil, cursor: nil, &block)
258
+ unless block
259
+ return enum_for(:follow_logs, id, since: since, version: version, copy: copy, stream: stream, limit: limit,
260
+ cursor: cursor)
261
+ end
262
+
263
+ follow_lines(id, log_query(since, version, copy, stream, limit, nil), cursor, &block)
264
+ nil
265
+ end
266
+
223
267
  private
224
268
 
269
+ def log_query(since, version, copy, stream, limit, cursor)
270
+ { "since" => since, "version" => version, "copy" => copy, "stream" => stream, "limit" => limit, "cursor" => cursor }
271
+ end
272
+
273
+ def log_invalid(cursor) = failure("app_log_invalid", "Retry the log from the last received cursor.", cursor)
274
+
275
+ def valid_log_line?(event)
276
+ event["text"].is_a?(String) && %w[stdout stderr].include?(event["stream"]) && valid_cursor?(event["cursor"]) &&
277
+ safe_integer?(event["at"]) && !event["at"].negative? && (event["copy"].nil? || event["copy"].is_a?(String)) &&
278
+ (event["version"].nil? || safe_integer?(event["version"]))
279
+ end
280
+
281
+ # Cursors are opaque positions: they cannot be ordered or counted on, so
282
+ # lines already given are known by their cursor.
283
+ def follow_lines(id, filters, cursor)
284
+ if cursor && !valid_cursor?(cursor)
285
+ raise failure("invalid_log_options", "Use the opaque cursor of a line or a continue event to resume.", cursor)
286
+ end
287
+
288
+ seen = cursor ? { cursor => true } : {}
289
+ failures = 0
290
+ loop do
291
+ continued = false
292
+ begin
293
+ # The API ends each follow after about two minutes, with a continue event.
294
+ @t.events("GET", "#{@base}/#{seg(id)}/logs", query: { "follow" => true }.merge(filters, { "cursor" => cursor }),
295
+ timeout: 180) do |event|
296
+ raise log_invalid(cursor) unless event.is_a?(Hash)
297
+
298
+ case event["type"]
299
+ when "line"
300
+ raise log_invalid(cursor) unless valid_log_line?(event)
301
+ next if seen.key?(event["cursor"])
302
+
303
+ seen[event["cursor"]] = true
304
+ seen.delete(seen.first[0]) if seen.size > LOG_SEEN
305
+ cursor = event["cursor"]
306
+ failures = 0
307
+ yield Record.new(event)
308
+ when "gap"
309
+ raise log_invalid(cursor) unless valid_cursor?(event["cursor"])
310
+
311
+ cursor = event["cursor"]
312
+ yield Record.new(event)
313
+ when "continue"
314
+ raise log_invalid(cursor) unless event["cursor"].nil? || valid_cursor?(event["cursor"])
315
+
316
+ cursor = event["cursor"] || cursor
317
+ continued = true
318
+ break
319
+ when "error"
320
+ unless event["code"].is_a?(String) && !event["code"].empty? && event["hint"].is_a?(String) && !event["hint"].empty?
321
+ raise log_invalid(cursor)
322
+ end
323
+
324
+ raise failure(event["code"], event["hint"], cursor)
325
+ else
326
+ raise log_invalid(cursor)
327
+ end
328
+ end
329
+ rescue ConnectionError
330
+ nil # A lost connection, not a refusal: the transport retries retryable answers. Reconnect from the last line.
331
+ rescue Error => e
332
+ raise e unless e.code == "unexpected_answer" && e.status.zero?
333
+
334
+ raise failure("app_log_invalid", "The log contained invalid JSON; retry from details.cursor.", cursor)
335
+ end
336
+ failures += 1 unless continued
337
+ if failures > LOG_RECONNECTS
338
+ raise failure("app_log_interrupted", "Reconnect with follow_logs(id, cursor: details[\"cursor\"]).", cursor)
339
+ end
340
+
341
+ # A service that is down, or continuations one after another, never busy-loop.
342
+ sleep(0.05 * [1, failures].max)
343
+ end
344
+ end
345
+
225
346
  def read_deploy(id, deploy_id, wait_for, timeout_seconds, timeout)
226
347
  Record.new(@t.json("GET", "#{@base}/#{seg(id)}/deploys/#{seg(deploy_id)}",
227
348
  query: { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds }, timeout: timeout))
@@ -18,7 +18,6 @@ module WithRuntime
18
18
  # Reconnections in a row with no new line before a follow gives up.
19
19
  RECONNECTS = 3
20
20
  SEEN = 10_000
21
- SAFE_INTEGER = (2**53) - 1
22
21
 
23
22
  attr_reader :repositories
24
23
 
@@ -69,14 +68,8 @@ module WithRuntime
69
68
 
70
69
  def path(id) = "/v1/builds/#{seg(id)}"
71
70
 
72
- def failure(code, hint, cursor) = Error.new(hint, code: code, hint: hint, details: { "cursor" => cursor })
73
-
74
71
  def invalid(cursor) = failure("build_log_invalid", "Retry the log from the last received cursor.", cursor)
75
72
 
76
- def valid_cursor?(value)
77
- value.is_a?(String) && !value.empty? && value.length <= 4096 && !value.match?(/[\x00-\x1f\x7f]/)
78
- end
79
-
80
73
  def valid_failure?(value)
81
74
  return true if value.nil?
82
75
  return false unless value.is_a?(Hash)
@@ -86,8 +79,6 @@ module WithRuntime
86
79
  lines.is_a?(Array) && lines.size <= 20 && lines.all?(String)
87
80
  end
88
81
 
89
- def safe_integer?(value) = value.is_a?(Integer) && value.abs <= SAFE_INTEGER
90
-
91
82
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
83
 
93
84
  # Cursors are opaque physical positions: they cannot be ordered or
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # +runtime.ci+: CI/CD. A connected repository's checks run as jobs in
5
+ # sandboxes. In a private beta: an account outside it is refused with
6
+ # product_in_beta (403, PermissionDeniedError). Times are Unix milliseconds.
7
+ #
8
+ # repo = runtime.ci.repositories.connect("acme/web")
9
+ # check = runtime.ci.checks.list(repository_id: repo.id, state: ["failed"]).first
10
+ # job = runtime.ci.checks.get(check.id).jobs.first
11
+ # puts runtime.ci.jobs.get(job.id).log
12
+ class CI < Product
13
+ attr_reader :repositories, :checks, :jobs, :tests, :flakes
14
+
15
+ def initialize(transport)
16
+ super
17
+ @repositories = CIRepositories.new(transport)
18
+ @checks = CIChecks.new(transport)
19
+ @jobs = CIJobs.new(transport)
20
+ @tests = CITests.new(transport)
21
+ @flakes = CIFlakes.new(transport)
22
+ end
23
+ end
24
+
25
+ # +runtime.ci.repositories+: the repositories whose checks run.
26
+ #
27
+ # A repository: id, name, labels, repository (owner/name), production_branch,
28
+ # config_path, installation, github_repository_id, state (connected or
29
+ # disconnected), error, created_at, updated_at and deleted_at.
30
+ class CIRepositories < Product
31
+ # Connects +repository+ ("owner/name"), read through Runtime's GitHub App.
32
+ # Pushes and pull requests then run the checks written in +config_path:+
33
+ # (runtime.yml). Optional: production_branch (main), config_path,
34
+ # installation (the GitHub App installation to read it through; the newest
35
+ # when left out), name and labels.
36
+ def connect(repository, production_branch: nil, config_path: nil, installation: nil, name: nil, labels: nil,
37
+ idempotency_key: nil)
38
+ body = Fields.body(repository: repository, production_branch: production_branch, config_path: config_path,
39
+ installation: installation, name: name, labels: labels)
40
+ Record.new(@t.json("POST", "/v1/ci/repositories", body: body, key: idempotency_key))
41
+ end
42
+
43
+ # +label+ is "key:value" strings, +state+ connected or disconnected.
44
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
45
+ page("/v1/ci/repositories", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
46
+ end
47
+
48
+ # Stops running its checks; the checks already run stay readable.
49
+ def disconnect(id, idempotency_key: nil)
50
+ Record.new(@t.json("DELETE", "/v1/ci/repositories/#{seg(id)}", key: idempotency_key))
51
+ end
52
+ end
53
+
54
+ # +runtime.ci.checks+: one run of a commit's jobs.
55
+ #
56
+ # A check: id, repository_id, repository, commit, branch, event, pull_request,
57
+ # base_commit, actor, trusted, waiting, approved_by, approved_at, landed,
58
+ # state (queued, running, passed, failed or cancelled), reason, counts (jobs
59
+ # per state), machines (where its machines came from and how soon, or nil),
60
+ # created_at, updated_at, started_at and ended_at.
61
+ class CIChecks < Product
62
+ # Checks, newest first. +state+ is any of queued, running, passed, failed,
63
+ # cancelled; +commit+ is a full sha.
64
+ def list(repository_id: nil, state: nil, branch: nil, commit: nil, pull_request: nil, limit: nil, cursor: nil)
65
+ page("/v1/ci/checks", { "repositoryId" => repository_id, "state" => state, "branch" => branch, "commit" => commit,
66
+ "pullRequest" => pull_request, "limit" => limit, "cursor" => cursor })
67
+ end
68
+
69
+ # The check with its +jobs+ in plan order (no log).
70
+ def get(id) = Record.new(@t.json("GET", "/v1/ci/checks/#{seg(id)}"))
71
+ end
72
+
73
+ # +runtime.ci.jobs+: one command of a check, run in sandboxes.
74
+ class CIJobs < Product
75
+ # The job with its +log+ (the tail, at most 256 KiB). A failed job's
76
+ # sandbox_ids may be kept paused: wake one or exec in it with
77
+ # +runtime.sandboxes+. A review step's job has its +review+: the verdict
78
+ # (pass or block), summary, findings (kind, file, line, what, fix), notes,
79
+ # and the agent, account, model and session that gave it; nil otherwise.
80
+ def get(id) = Record.new(@t.json("GET", "/v1/ci/jobs/#{seg(id)}"))
81
+ end
82
+
83
+ # +runtime.ci.tests+: a repository's test files and their recent runs.
84
+ class CITests < Product
85
+ # Test files by name: file, ms, quarantined_since (their failures do not
86
+ # fail a check), runs and updated_at.
87
+ def list(repository_id, quarantined: nil, limit: nil, cursor: nil)
88
+ page("/v1/ci/tests", { "repositoryId" => repository_id, "quarantined" => quarantined, "limit" => limit, "cursor" => cursor })
89
+ end
90
+ end
91
+
92
+ # +runtime.ci.flakes+: tests that passed and failed on the same inputs, one
93
+ # run main's. Each is quarantined at once (it still runs and reports, and
94
+ # blocks nothing), or still gating when the repository protects it; handed
95
+ # to an owner with 48 hours to fix it or delete it with a reason; fixed once
96
+ # 100 runs of a main commit in parallel forks all pass.
97
+ #
98
+ # A flake: id, repository_id, file, state (quarantined, gating, fixed or
99
+ # deleted), gates, proof, again, owner (login, from, pull_request, branch),
100
+ # since, due_at, overdue, tried, prove_asked_at, delete_reason, ended_at,
101
+ # ended_check_id and updated_at; with a get or an update, its runs and
102
+ # failures too.
103
+ class CIFlakes < Product
104
+ # Flaky tests, newest first. +state+ is any of quarantined, gating (the
105
+ # open ones), fixed, deleted.
106
+ def list(repository_id: nil, state: nil, limit: nil, cursor: nil)
107
+ page("/v1/ci/flakes", { "repositoryId" => repository_id, "state" => state, "limit" => limit, "cursor" => cursor })
108
+ end
109
+
110
+ # The flake with its test file's newest 200 +runs+ (by commit and content
111
+ # key) and its +failures+' jobs, whose logs +runtime.ci.jobs.get+ reads.
112
+ def get(id) = Record.new(@t.json("GET", "/v1/ci/flakes/#{seg(id)}"))
113
+
114
+ # +prove: true+ asks for its 100 proof runs at main's next commit; +owner:+
115
+ # hands it to a GitHub login or org/team; +delete_reason:+ says why its
116
+ # test is deleted (it ends once main no longer holds the file).
117
+ def update(id, prove: nil, owner: nil, delete_reason: nil, idempotency_key: nil)
118
+ body = Fields.body(prove: prove, owner: owner, delete_reason: delete_reason)
119
+ Record.new(@t.json("POST", "/v1/ci/flakes/#{seg(id)}:update", body: body, key: idempotency_key))
120
+ end
121
+ end
122
+ end
@@ -23,9 +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, :emails, :referrals,
26
+ attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :reservations, :limits, :emails, :referrals, :plans,
27
27
  :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents,
28
- :apps, :functions, :builds, :containers
28
+ :apps, :functions, :builds, :ci, :containers
29
29
 
30
30
  # +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
31
31
  # +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
@@ -56,9 +56,11 @@ module WithRuntime
56
56
  @images = Images.new(@transport)
57
57
  @volumes = Volumes.new(@transport)
58
58
  @secrets = Secrets.new(@transport)
59
+ @reservations = Reservations.new(@transport)
59
60
  @limits = Limits.new(@transport)
60
61
  @emails = Emails.new(@transport)
61
62
  @referrals = Referrals.new(@transport)
63
+ @plans = Plans.new(@transport)
62
64
  @switching = Switching.new(@transport)
63
65
  @events = Events.new(@transport)
64
66
  @webhooks = Webhooks.new(@transport)
@@ -77,6 +79,7 @@ module WithRuntime
77
79
  @apps = Apps.new(@transport, "/v1/apps")
78
80
  @functions = Apps.new(@transport, "/v1/functions")
79
81
  @builds = Builds.new(@transport)
82
+ @ci = CI.new(@transport)
80
83
  @containers = Containers.new(@transport)
81
84
  end
82
85
 
@@ -6,22 +6,33 @@ module WithRuntime
6
6
  # subclasses name the class of failure, as in the JavaScript, Python, Go and
7
7
  # Java SDKs.
8
8
  class Error < StandardError
9
- # 503s that are a deliberate state, not a passing one: retrying cannot change them.
9
+ # Products switched off on this deployment (501; 503 from an older
10
+ # server): a deliberate state, not a passing one, so retrying cannot
11
+ # change them. Read only when the answer carries no +retryable+.
10
12
  DELIBERATE = %w[
11
- unavailable unsupported fork_unavailable previews_unavailable
13
+ unavailable unsupported fork_unavailable previews_unavailable file_links_unavailable
12
14
  network_unavailable network_rules_unavailable secrets_unavailable identity_unavailable env_unavailable
13
- resize_unavailable
15
+ resize_unavailable feature_unavailable delete_unavailable switch_image_unavailable switching_paused
16
+ schema_migration_required storage_unavailable
14
17
  ].freeze
15
18
  # Refusals that pass on their own: a host frees room, a sandbox of an
16
19
  # account without credit stops.
17
20
  PASSING = %w[no_capacity no_credit_running_limit no_credit_total_limit].freeze
18
21
  # Refusals of a create that clear when a sandbox stops or a host frees room.
19
22
  WAITS_FOR_ROOM = %w[no_credit_running_limit no_credit_total_limit no_credit_domain_limit no_credit_capacity_full quota_exceeded no_capacity volume_releasing].freeze
23
+ # Refusals that say nothing ran (a guest still busy, the API briefly
24
+ # busy, the caller's rate): sent again past max_retries, with growing
25
+ # waits, until the call's deadline.
26
+ NOTHING_RAN = %w[guest_busy busy rate_limited].freeze
20
27
 
21
- attr_reader :code, :status, :hint, :request_id, :details, :idempotency_key, :retry_after
28
+ attr_reader :code, :status, :hint, :request_id, :details, :idempotency_key, :retry_after,
29
+ :retryable
22
30
 
23
- def initialize(message, code:, status: 0, hint: nil, request_id: nil, details: nil, idempotency_key: nil, retry_after: nil)
31
+ def initialize(message, code:, status: 0, hint: nil, request_id: nil, details: nil, idempotency_key: nil,
32
+ retry_after: nil, retryable: nil)
24
33
  super(message)
34
+ # The server's own word (true or false), or nil from an older server.
35
+ @retryable = [true, false].include?(retryable) ? retryable : nil
25
36
  @code = code
26
37
  @status = status
27
38
  @hint = hint
@@ -31,11 +42,23 @@ module WithRuntime
31
42
  @retry_after = retry_after
32
43
  end
33
44
 
34
- # Whether retrying this exact call, with the same idempotency key, is safe and may work.
45
+ # The answer's own +retryable+, or nil when it sent none (an older server).
46
+ def sent_retryable
47
+ @retryable
48
+ end
49
+
50
+ # Whether retrying this exact call, with the same idempotency key, is safe
51
+ # and may work: the server's +retryable+ when it sent one, else worked out
52
+ # from the code and status as older servers need.
35
53
  def retryable?
54
+ return @retryable unless @retryable.nil?
36
55
  return false if DELIBERATE.include?(code)
37
56
  return details["field"] != "count" if PASSING.include?(code)
38
57
 
58
+ # The account at one of its limits (a 429): some free when its own work ends,
59
+ # some only when something is deleted, so the same call again is no answer by itself.
60
+ return false if code == "quota_exceeded"
61
+
39
62
  [0, 429, 502, 503, 504].include?(status)
40
63
  end
41
64
 
@@ -67,7 +90,8 @@ module WithRuntime
67
90
  request_id: error["requestId"],
68
91
  details: error["details"].is_a?(Hash) ? error["details"] : nil,
69
92
  idempotency_key: idempotency_key,
70
- retry_after: retry_ms.is_a?(Numeric) ? retry_ms / 1000.0 : nil
93
+ retry_after: retry_ms.is_a?(Numeric) ? retry_ms / 1000.0 : nil,
94
+ retryable: error["retryable"]
71
95
  )
72
96
  end
73
97
 
@@ -5,6 +5,8 @@ require "openssl"
5
5
  module WithRuntime
6
6
  # The pieces every product shares.
7
7
  class Product
8
+ SAFE_INTEGER = (2**53) - 1
9
+
8
10
  def initialize(transport)
9
11
  @t = transport
10
12
  end
@@ -13,6 +15,16 @@ module WithRuntime
13
15
 
14
16
  def seg(value) = Transport.segment(value)
15
17
 
18
+ # A followed log's refusal, with the cursor it resumes from in its details.
19
+ def failure(code, hint, cursor) = Error.new(hint, code: code, hint: hint, details: { "cursor" => cursor })
20
+
21
+ # A log cursor is opaque: a short string with no control characters.
22
+ def valid_cursor?(value)
23
+ value.is_a?(String) && !value.empty? && value.length <= 4096 && !value.match?(/[\x00-\x1f\x7f]/)
24
+ end
25
+
26
+ def safe_integer?(value) = value.is_a?(Integer) && value.abs <= SAFE_INTEGER
27
+
16
28
  # A change's body from keyword arguments: nil is left out, +:remove+ sends
17
29
  # null, which clears the field.
18
30
  def patch(changes)
@@ -96,13 +108,28 @@ module WithRuntime
96
108
  class Secrets < Product
97
109
  # Stores or replaces a secret for +hosts+ ("api.openai.com", "*.github.com").
98
110
  # With +header:+, that header is set on every request to them, +format:+'s
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
111
+ # {value} replaced.
112
+ #
113
+ # +rules:+ limits the value to the requests a rule allows, such as
114
+ # [{ "methods" => ["GET"], "paths" => ["/repos/acme/*"] }]. +only:+ gives it to
115
+ # those sandboxes alone ({ "labels" => [{ "role" => "hub" }], "sandboxes" => [ids],
116
+ # "apps" => [App ids] }; left out when replacing, the scope it has stays; {} is every
117
+ # sandbox again). +github_app:+ ({ "id" =>, "installation" =>,
118
+ # "repositories" =>, "permissions" =>, "api" => }) makes +value+ a GitHub App's
119
+ # private key (PEM); the servers make its installation tokens themselves, and the
120
+ # secret takes no +header:+ or +format:+. +git:+ ({ "push" => [branch patterns] };
121
+ # an empty list is no push at all) limits what a git push may update. +api:+
122
+ # ({ "callsPerHour" =>, "writesPerHour" =>, "keep" =>, "copies" =>, "fresh" =>,
123
+ # "graphql" => { "path" =>, "mutations" => [] } }) sets each sandbox's hour with the
124
+ # secret, the far end's reserve, GraphQL's mutations and shared copies. The answer
125
+ # and +list+ return them as +githubApp+, +git+ and +api+. +env:+ is the environment
126
+ # variable sandboxes see it in, when not the secret's name. Secrets scoped to different
127
+ # sandboxes may share one, such as one GITHUB_TOKEN per repository; a write that would
128
+ # put two in one sandbox is refused (409). Absent when replacing: the variable it has stays.
129
+ def set(name, value:, hosts:, header: nil, format: nil, rules: nil, only: nil, github_app: nil, git: nil,
130
+ api: nil, env: nil)
131
+ body = { "value" => value, "hosts" => hosts, "header" => header, "format" => format, "rules" => rules,
132
+ "only" => only, "githubApp" => github_app, "git" => git, "api" => api, "env" => env }.compact
106
133
  Record.new(@t.json("PUT", "/v1/egress-secrets/#{seg(name)}", body: body))
107
134
  end
108
135
 
@@ -155,6 +182,52 @@ module WithRuntime
155
182
  end
156
183
  end
157
184
 
185
+ # +runtime.plans+: monthly plans whose fee is prepaid usage. The fee comes back as plan usage, spent first
186
+ # each month at the same measured prices, plus a bonus for committing; plan usage left at the period's end
187
+ # ends with it. Money is integer microdollars in strings.
188
+ #
189
+ # Every change answers +checkout_url+ (where a person starts a first plan or a trial in the browser, nil
190
+ # once the change was made) and +current+ (the plan as it is now). Each takes an +idempotency_key+; one is
191
+ # made per call when left out.
192
+ class Plans < Product
193
+ # +current+ (the plan you hold, or nil: plan_id, name, status, interval, period_start, period_end,
194
+ # trial_ends_at, cancel_at_period_end, next_plan_id, started_at, add_ons, auto_upgrade, plan_usage) and
195
+ # +plans+ (every plan you may choose: id, name, card, fee_micros, yearly_fee_micros, bonus_micros,
196
+ # trial_days, first_period_double, support, collection, contract, position, included, limits, features,
197
+ # add_ons).
198
+ def list = Record.new(@t.json("GET", "/v1/plans"))
199
+
200
+ # Start a plan, or move to another. With no plan, answers +checkout_url+. A dearer plan starts at once,
201
+ # prorated; a cheaper one at +period_end+ (+next_plan_id+). +interval: "year"+ pays ten months for
202
+ # twelve; monthly unless asked.
203
+ def choose(plan, interval: nil, idempotency_key: nil)
204
+ body = { "plan" => plan }
205
+ body["interval"] = interval unless interval.nil?
206
+ Record.new(@t.json("POST", "/v1/plans/choose", body: body, key: idempotency_key))
207
+ end
208
+
209
+ # End the plan at +period_end+.
210
+ def cancel(idempotency_key: nil) = Record.new(@t.json("POST", "/v1/plans/cancel", body: {}, key: idempotency_key))
211
+
212
+ # Keep a plan you cancelled, before +period_end+.
213
+ def resume(idempotency_key: nil) = Record.new(@t.json("POST", "/v1/plans/resume", body: {}, key: idempotency_key))
214
+
215
+ # Add an add-on by id: charged now for the rest of the period, prorated, then with the plan.
216
+ def add_on(id, idempotency_key: nil)
217
+ Record.new(@t.json("POST", "/v1/plans/add-ons", body: { "addOn" => id }, key: idempotency_key))
218
+ end
219
+
220
+ # Remove an add-on: its charge stops now, and it holds to +period_end+.
221
+ def remove_add_on(id, idempotency_key: nil)
222
+ Record.new(@t.json("DELETE", "/v1/plans/add-ons/#{seg(id)}", key: idempotency_key))
223
+ end
224
+
225
+ # Move up a plan by itself when a limit refuses (true), or not. Owners, and keys an owner made.
226
+ def auto_upgrade(on, idempotency_key: nil)
227
+ Record.new(@t.json("PUT", "/v1/plans/auto-upgrade", body: { "on" => on }, key: idempotency_key))
228
+ end
229
+ end
230
+
158
231
  # +runtime.switching+: compare usage with a rival's published rates, and record a switch.
159
232
  class Switching < Product
160
233
  # Your settled usage over the last +days+ (30 by default, at most 90) priced
@@ -47,6 +47,17 @@ module WithRuntime
47
47
  end
48
48
  end
49
49
 
50
+ # The files a traced command (+trace_files: true+) opened: +read+ is every
51
+ # file and directory it, and anything it started, opened (sorted absolute
52
+ # paths with links resolved, directories ending in "/"); +written+ every file
53
+ # it wrote. +truncated+ means some paths are missing, so do not treat the
54
+ # lists as complete.
55
+ class TracedFiles < Record
56
+ def read = self["read"] || []
57
+ def written = self["written"] || []
58
+ def truncated = self["truncated"] == true
59
+ end
60
+
50
61
  # A finished command. A timeout is a result (+timed_out+), not an exception.
51
62
  class CommandResult < Record
52
63
  def exit_code = self["exitCode"]
@@ -54,6 +65,9 @@ module WithRuntime
54
65
  def stderr = self["stderr"] || ""
55
66
  def timed_out = self["timedOut"] == true
56
67
  def ok? = !timed_out && exit_code == 0
68
+
69
+ # The files it read and wrote when it ran with +trace_files: true+; nil otherwise.
70
+ def files = self["files"] && TracedFiles.new(self["files"])
57
71
  end
58
72
 
59
73
  # One page of a list. +next_page+ reads the one after it; +each+ walks every
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module WithRuntime
6
+ # +runtime.reservations+: book room for a burst. Holds room for +sandboxes+
7
+ # sandboxes of one size for a window (up to 24 hours, starting within 14 days)
8
+ # and answers at once, confirmed or declined. A decline is a normal answer
9
+ # (+status+ is "declined"; +reason+, +message+, +largest_sandboxes+ and
10
+ # +nearest_window+ say why and what would fit), not an error. In the window,
11
+ # creates are never refused no_capacity or quota_exceeded up to what you
12
+ # booked; running sandboxes are billed as usual and booked room none of your
13
+ # sandboxes use costs a quarter of the memory rate. +estimate_micros+ (the most
14
+ # it can cost: every sandbox running the whole window), +unused_rate_micros+
15
+ # (unused room per GiB-hour of memory, as quoted when booked),
16
+ # +unused_max_micros+ (the most it costs if none run) and
17
+ # +unused_charged_micros+ (charged so far) are microdollars as a string.
18
+ #
19
+ # booking = runtime.reservations.create(sandboxes: 500, starts_at: Time.now + 3600, ends_at: Time.now + 3 * 3600)
20
+ # booking.status # => "confirmed"
21
+ #
22
+ # Standing room (+keep+) is held from now until you cancel it, for a pool that
23
+ # deletes a sandbox after each use and makes another: a replacement is never
24
+ # refused up to it. With +snapshots+ it is held on the servers holding them,
25
+ # at their size; +for_key+ keeps it for this key's sandboxes alone. Its
26
+ # +ends_at+, +held_until+ and +unused_max_micros+ are nil;
27
+ # +unused_per_hour_micros+ is the most its unused room costs an hour.
28
+ #
29
+ # pool = runtime.reservations.keep(sandboxes: 40, snapshots: [snapshot_id], for_key: true)
30
+ class Reservations < Product
31
+ # +starts_at+ and +ends_at+ are a Time or an ISO 8601 string. +vcpu+,
32
+ # +memory_mib+, +disk_mib+ and +region+ default to a create's defaults
33
+ # (2 vCPU, 4096 MiB each, the default region).
34
+ def create(sandboxes:, starts_at:, ends_at:, vcpu: nil, memory_mib: nil, disk_mib: nil, region: nil, idempotency_key: nil)
35
+ body = Fields.body(sandboxes: sandboxes, starts_at: iso(starts_at), ends_at: iso(ends_at),
36
+ vcpu: vcpu, memory_mib: memory_mib, disk_mib: disk_mib, region: region)
37
+ Record.new(@t.json("POST", "/v1/reservations", body: body, key: idempotency_key))
38
+ end
39
+
40
+ # Keeps standing room for +sandboxes+ sandboxes until it is cancelled. Keep
41
+ # a few more than the pool runs, for deletes still finishing.
42
+ def keep(sandboxes:, snapshots: nil, for_key: nil, vcpu: nil, memory_mib: nil, disk_mib: nil, region: nil,
43
+ idempotency_key: nil)
44
+ body = Fields.body(standing: true, sandboxes: sandboxes, snapshots: snapshots, for_key: for_key || nil,
45
+ vcpu: vcpu, memory_mib: memory_mib, disk_mib: disk_mib, region: region)
46
+ Record.new(@t.json("POST", "/v1/reservations", body: body, key: idempotency_key))
47
+ end
48
+
49
+ # Changes standing room in place: how many it holds, its snapshots, or
50
+ # both. It moves or grows only when all of it fits now; otherwise it raises
51
+ # no_capacity and nothing changes.
52
+ def update(id, sandboxes: nil, snapshots: nil, idempotency_key: nil)
53
+ body = Fields.body(sandboxes: sandboxes, snapshots: snapshots)
54
+ Record.new(@t.json("POST", "/v1/reservations/#{seg(id)}:update", body: body, key: idempotency_key))
55
+ end
56
+
57
+ # Bookings, newest first, as a Page (+data+, +next_cursor+, +more?+,
58
+ # +next_page+); iterating it walks every page.
59
+ def list(limit: nil, cursor: nil)
60
+ query = { "limit" => limit, "cursor" => cursor }
61
+ body = @t.json("GET", "/v1/reservations", query: query)
62
+ Page.new(body["items"].map { |item| Record.new(item) }, body["nextCursor"]) do |next_cursor|
63
+ list(limit: limit, cursor: next_cursor)
64
+ end
65
+ end
66
+
67
+ def get(id) = Record.new(@t.json("GET", "/v1/reservations/#{seg(id)}"))
68
+
69
+ # Gives the room back. The booking comes back with status "cancelled".
70
+ def cancel(id) = Record.new(@t.json("DELETE", "/v1/reservations/#{seg(id)}"))
71
+
72
+ private
73
+
74
+ def iso(at) = at.respond_to?(:iso8601) ? at.iso8601(3) : at
75
+ end
76
+ end
@@ -165,7 +165,7 @@ module WithRuntime
165
165
  # limit so that +margin+ seconds remain, never more than the hour ahead the
166
166
  # API allows. A sandbox with no time limit needs none and is only watched.
167
167
  # Running time is billed as it is used. A paused sandbox is left paused; a
168
- # stopped one ends the loop.
168
+ # stopped or deleted one ends the loop.
169
169
  def keep_alive(every: 60, margin: ApiDefaults::KEEP_ALIVE_MARGIN_SECONDS, &on_error)
170
170
  stop_keep_alive
171
171
  every = [10, every].max
@@ -182,6 +182,10 @@ module WithRuntime
182
182
  extend_lease([3600, need].min) if need >= 1
183
183
  end
184
184
  rescue Error => e
185
+ # Gone (deleted elsewhere, or by its own time limit): nothing to
186
+ # keep alive, and asking again would only be refused.
187
+ break if e.status == 404
188
+
185
189
  on_error&.call(e)
186
190
  end
187
191
  sleep(every)
@@ -222,7 +226,8 @@ module WithRuntime
222
226
  idempotency_key: nil)
223
227
  body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot,
224
228
  secrets: secrets&.to_a)
225
- reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
229
+ # Its key replays a refusal for room or a limit, so it is told at once.
230
+ reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key, room: 0)
226
231
  copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
227
232
  count.nil? ? copies.first : copies
228
233
  end
@@ -258,15 +263,17 @@ module WithRuntime
258
263
  # sandbox's; put secrets here), +stdin+, +timeout+ (seconds, default 60, up
259
264
  # to a day; a timeout is a result, not an error), +on_stdout+ and
260
265
  # +on_stderr+ (stream the command; the result keeps everything), and
261
- # +check+ (raise CommandError on a non-zero exit or a timeout).
266
+ # +check+ (raise CommandError on a non-zero exit or a timeout), and
267
+ # +trace_files+ (the result's +files+ are the files it read and wrote; an
268
+ # older sandbox says to stop and start it, and no command runs).
262
269
  def exec(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, on_stdout: nil, on_stderr: nil,
263
- check: false, idempotency_key: nil)
270
+ check: false, idempotency_key: nil, trace_files: false)
264
271
  streamed = on_stdout || on_stderr || (timeout && timeout > 60)
265
272
  result = if streamed
266
273
  collect(exec_stream(command, cwd: cwd, user: user, env: env, stdin: stdin, timeout: timeout,
267
- idempotency_key: idempotency_key), on_stdout, on_stderr)
274
+ idempotency_key: idempotency_key, trace_files: trace_files), on_stdout, on_stderr)
268
275
  else
269
- body = command_body(command, cwd, env, stdin, timeout, user)
276
+ body = command_body(command, cwd, env, stdin, timeout, user, trace_files)
270
277
  CommandResult.new(@t.json("POST", path(":exec"), body: body, key: idempotency_key,
271
278
  timeout: timeout && (timeout + 60)))
272
279
  end
@@ -278,14 +285,14 @@ module WithRuntime
278
285
  # Runs a command and yields its events as they happen: start, stdout,
279
286
  # stderr, exit (each a Hash). Resumes by itself when the server ends a long
280
287
  # stream, so it never drops output. Without a block, an Enumerator.
281
- def exec_stream(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, &block)
288
+ def exec_stream(command, cwd: nil, user: nil, env: nil, stdin: nil, timeout: nil, idempotency_key: nil, trace_files: false, &block)
282
289
  unless block
283
290
  return enum_for(:exec_stream, command, cwd: cwd, user: user, env: env, stdin: stdin, timeout: timeout,
284
- idempotency_key: idempotency_key)
291
+ idempotency_key: idempotency_key, trace_files: trace_files)
285
292
  end
286
293
 
287
294
  longest = ApiDefaults::STREAMED_EXEC_TIMEOUT_SECONDS
288
- body = command_body(command, cwd, env, stdin, timeout || longest, user).merge("stream" => true)
295
+ body = command_body(command, cwd, env, stdin, timeout || longest, user, trace_files).merge("stream" => true)
289
296
  handed_over = nil
290
297
  process_id = nil
291
298
  cursor = 0
@@ -373,9 +380,11 @@ module WithRuntime
373
380
  # Starts a background process (a server, a watcher, a REPL) and returns at
374
381
  # once. It outlives your connection; +process(id)+ gets it back.
375
382
  # +pipe_stdin: true+ keeps input open for +write+; +pty: { cols:, rows: }+
376
- # gives it a terminal.
377
- def spawn(command, cwd: nil, user: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil, idempotency_key: nil)
378
- body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout, user)
383
+ # gives it a terminal; +trace_files: true+ records the files it opens, read
384
+ # them with +files+ once it has ended.
385
+ def spawn(command, cwd: nil, user: nil, env: nil, timeout: nil, stdin: nil, pipe_stdin: false, pty: nil,
386
+ idempotency_key: nil, trace_files: false)
387
+ body = command_body(command, cwd, env, pipe_stdin ? nil : stdin, timeout, user, trace_files)
379
388
  body["stdinMode"] = "pipe" if pipe_stdin
380
389
  body["pty"] = Fields.body(pty) if pty
381
390
  SandboxProcess.new(self, @t.json("POST", path("/processes"), body: body, key: idempotency_key))
@@ -391,20 +400,21 @@ module WithRuntime
391
400
 
392
401
  private
393
402
 
394
- def lifecycle(verb, body, wait, key, room: 0)
403
+ def lifecycle(verb, body, wait, key, room: nil)
395
404
  @info = Record.new(@t.json("POST", path(":#{verb}"), body: body, wait: wait ? 60 : 0, key: key, room: room))
396
405
  self
397
406
  end
398
407
 
399
408
  def room_for(wait_for_capacity) = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
400
409
 
401
- def command_body(command, cwd, env, stdin, timeout, user)
410
+ def command_body(command, cwd, env, stdin, timeout, user, trace_files = false)
402
411
  body = command.is_a?(Array) ? { "argv" => command.map(&:to_s) } : { "command" => command.to_s }
403
412
  body["cwd"] = cwd if cwd
404
413
  body["user"] = user.to_s if user
405
414
  body["env"] = env.to_h { |key, value| [key.to_s, value.to_s] } if env
406
415
  body["stdinBase64"] = [stdin.to_s.b].pack("m0") unless stdin.nil?
407
416
  body["timeoutMs"] = (timeout * 1000).round if timeout
417
+ body["traceFiles"] = true if trace_files
408
418
  body
409
419
  end
410
420
 
@@ -423,7 +433,7 @@ module WithRuntime
423
433
  stderr << event["data"]
424
434
  on_stderr&.call(event["data"])
425
435
  when "truncated" then dropped = true
426
- when "exit" then fields.merge!(event.slice("exitCode", "timedOut", "durationMs"))
436
+ when "exit" then fields.merge!(event.slice("exitCode", "timedOut", "durationMs", "files"))
427
437
  end
428
438
  end
429
439
  # A truncated event does not say which stream lost bytes, so both flags carry it.
@@ -495,6 +505,10 @@ module WithRuntime
495
505
  # Changes a PTY process's terminal size.
496
506
  def resize(cols, rows) = @sandbox.transport.json("POST", path(":resize"), body: { "cols" => cols, "rows" => rows })
497
507
 
508
+ # The files a process spawned with +trace_files: true+ read and wrote. Fails
509
+ # with process_running while it runs.
510
+ def files = TracedFiles.new(@sandbox.transport.json("GET", path("/files")))
511
+
498
512
  # Reads the process again.
499
513
  def refresh
500
514
  @info = Record.new(@sandbox.transport.json("GET", path))
@@ -630,6 +644,28 @@ module WithRuntime
630
644
  nil
631
645
  end
632
646
 
647
+ # A link anyone can open with no API key to download +path+ (a Hash with
648
+ # "url" and "expiresAt"). Range works. It lasts an hour by default, at most
649
+ # 7 days (+expires_in_seconds+). +filename+ saves it under that name;
650
+ # +wake+ true wakes a paused sandbox, false never does; +archive+ links a
651
+ # folder as a .tar.gz.
652
+ def download_url(path, expires_in_seconds: nil, filename: nil, wake: nil, archive: nil)
653
+ body = { "path" => path, "method" => "GET", "expiresInSeconds" => expires_in_seconds,
654
+ "filename" => filename, "wake" => wake, "archive" => archive }.compact
655
+ t.json("POST", @sandbox.path("/files:link"), body: body)
656
+ end
657
+
658
+ # A link anyone can PUT a raw body to with no API key: the body becomes the
659
+ # file at +path+, whole or not at all, up to +max_bytes+ (5 GiB at most).
660
+ def upload_url(path, expires_in_seconds: nil, max_bytes: nil, wake: nil)
661
+ body = { "path" => path, "method" => "PUT", "expiresInSeconds" => expires_in_seconds,
662
+ "maxBytes" => max_bytes, "wake" => wake }.compact
663
+ t.json("POST", @sandbox.path("/files:link"), body: body)
664
+ end
665
+
666
+ # Ends every link made for this sandbox so far.
667
+ def revoke_links = t.json("POST", @sandbox.path("/files:revoke-links"))
668
+
633
669
  # Copies a local file or directory in. A directory travels as one gzipped
634
670
  # tar to the API's folder routes, unpacked by the sandbox's own tar as it
635
671
  # arrives.
@@ -51,6 +51,10 @@ module WithRuntime
51
51
  # could refuse (the leader drill on vin-5, 4 October 2026). It is sent
52
52
  # again under the same key.
53
53
  ANSWER_START = 125.0
54
+ # The longest wait a server names that a call sleeps on (seconds). One
55
+ # longer (a daily cap's hour) is raised at once, still retryable with its
56
+ # retry_after, for the caller to schedule.
57
+ LONGEST_WAIT = 60.0
54
58
  class << self
55
59
  attr_writer :answer_start
56
60
 
@@ -375,14 +379,19 @@ module WithRuntime
375
379
  # Sends a call, retrying as the rules say, and returns the response once it
376
380
  # succeeded. With a block, the body is streamed to it.
377
381
  def send_call(http, method, path, query: nil, body: nil, raw: nil, accept: "application/json", wait: nil,
378
- key: nil, no_retry: false, room: 0, timeout: nil, cancel: nil, &stream)
382
+ key: nil, no_retry: false, room: nil, timeout: nil, cancel: nil, &stream)
379
383
  write = method != "GET"
380
384
  key = write ? (key || SecureRandom.uuid) : nil
381
- deadline = now + (timeout || @timeout) + room
382
- room_until = now + room
385
+ # room: nil for a call with no room wait; 0 for a create, wake, restart
386
+ # or fork told at once.
387
+ deadline = now + (timeout || @timeout) + (room || 0)
388
+ room_until = now + (room || 0)
383
389
  room_attempt = 0
384
390
  payload = raw || (body.nil? ? nil : JSON.generate(body))
385
391
  attempt = 0
392
+ # An answer that ran nothing is sent again until the call's deadline
393
+ # (a Ruby call always has one: its timeout, plus any wait for room).
394
+ idle_attempt = 0
386
395
  # The last try never left, and was sent again at once.
387
396
  resent_at_once = false
388
397
  loop do
@@ -497,7 +506,9 @@ module WithRuntime
497
506
  { "error" => { "message" => text.strip.empty? ? nil : text.strip[0, 500] }.compact }
498
507
  end
499
508
  failure = Error.from(status, parsed, key)
500
- if !no_retry && room.positive? && Error::WAITS_FOR_ROOM.include?(failure.code) && failure.details["field"] != "count"
509
+ # A quota on what is kept says it never clears by waiting.
510
+ if !no_retry && room&.positive? && Error::WAITS_FOR_ROOM.include?(failure.code) && failure.details["field"] != "count" &&
511
+ failure.sent_retryable != false
501
512
  room_left = room_until - now
502
513
  raise failure if room_left <= 0
503
514
 
@@ -506,15 +517,42 @@ module WithRuntime
506
517
  pause([room_left, wait_for].min, deadline, write, key, failure, cancel)
507
518
  next
508
519
  end
509
- retryable = [429, 502, 503, 504].include?(status) && !Error::DELIBERATE.include?(failure.code)
510
- raise failure if no_retry || !retryable || attempt >= max_retries
511
-
512
- wait_for = failure.retry_after
513
- if wait_for.nil?
514
- header = Float(response["Retry-After"], exception: false)
515
- wait_for = header&.positive? ? header : backoff(attempt)
520
+ header = Float(response["Retry-After"], exception: false)
521
+ header = nil unless header&.positive?
522
+ # The wait the server names, when it names one.
523
+ named = failure.retry_after || header
524
+ # Nothing ran: safe to send again for any call, past max_retries,
525
+ # with growing waits, until the deadline; then this refusal stands.
526
+ # A late answer's failure came after the work began, so only a
527
+ # guest's own refusal (guest_busy, before anything is journaled)
528
+ # still says nothing ran.
529
+ if [429, 503].include?(status) && Error::NOTHING_RAN.include?(failure.code) &&
530
+ failure.sent_retryable != false && (!late || failure.code == "guest_busy")
531
+ raise failure if named && named > LONGEST_WAIT
532
+
533
+ pause_for = [30.0, (named || 0.25) * (2**[idle_attempt, 3].min)].min * (0.9 + (rand * 0.2))
534
+ idle_attempt += 1
535
+ raise failure if pause_for >= deadline - now
536
+
537
+ pause(pause_for, deadline, write, key, failure, cancel)
538
+ next
516
539
  end
517
- pause([30.0, wait_for].min * (0.9 + (rand * 0.2)), deadline, write, key, failure, cancel)
540
+ # The answer says whether the same call may work again; an older
541
+ # server that does not say is retried on 429, 502, 503 and 504. A
542
+ # refusal for room or the account's own limit is the room wait's on a
543
+ # create, wake, restart or fork; any other call retries what it says.
544
+ again = if failure.sent_retryable.nil?
545
+ [429, 502, 503, 504].include?(status) && !Error::DELIBERATE.include?(failure.code)
546
+ else
547
+ failure.sent_retryable
548
+ end
549
+ raise failure if no_retry || !again || (room && Error::WAITS_FOR_ROOM.include?(failure.code)) ||
550
+ attempt >= max_retries
551
+ # A wait longer than a minute (a daily cap) is the caller's to
552
+ # schedule: the error says when, in retry_after.
553
+ raise failure if named && named > LONGEST_WAIT
554
+
555
+ pause([30.0, named || backoff(attempt)].min * (0.9 + (rand * 0.2)), deadline, write, key, failure, cancel)
518
556
  attempt += 1
519
557
  end
520
558
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.9.0"
4
+ VERSION = "0.16.0"
5
5
  end
data/lib/withruntime.rb CHANGED
@@ -11,6 +11,10 @@
11
11
  # puts sbx.exec("python3 /workspace/invoice.py", check: true).stdout
12
12
  # sbx.stop
13
13
  #
14
+ # Agents are set up from a runtime.yml in your repo by the CLI, which makes
15
+ # the account's agents match the file: npx withruntime agents deploy. This
16
+ # gem has no deploy of its own.
17
+ #
14
18
  # The guide is at https://withruntime.com/docs/ruby.
15
19
  require "json"
16
20
  require "openssl"
@@ -26,10 +30,12 @@ require_relative "withruntime/wallet"
26
30
  require_relative "withruntime/tar"
27
31
  require_relative "withruntime/sandbox"
28
32
  require_relative "withruntime/products"
33
+ require_relative "withruntime/reservations"
29
34
  require_relative "withruntime/agents"
30
35
  require_relative "withruntime/images"
31
36
  require_relative "withruntime/apps"
32
37
  require_relative "withruntime/builds"
38
+ require_relative "withruntime/ci"
33
39
  require_relative "withruntime/containers"
34
40
  require_relative "withruntime/network_products"
35
41
  require_relative "withruntime/parity_products"
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.9.0
4
+ version: 0.16.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-19 00:00:00.000000000 Z
11
+ date: 2026-10-22 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,
@@ -24,6 +24,7 @@ files:
24
24
  - lib/withruntime/api_defaults.rb
25
25
  - lib/withruntime/apps.rb
26
26
  - lib/withruntime/builds.rb
27
+ - lib/withruntime/ci.rb
27
28
  - lib/withruntime/client.rb
28
29
  - lib/withruntime/containers.rb
29
30
  - lib/withruntime/credentials.rb
@@ -35,6 +36,7 @@ files:
35
36
  - lib/withruntime/parity_products.rb
36
37
  - lib/withruntime/products.rb
37
38
  - lib/withruntime/record.rb
39
+ - lib/withruntime/reservations.rb
38
40
  - lib/withruntime/sandbox.rb
39
41
  - lib/withruntime/tar.rb
40
42
  - lib/withruntime/transport.rb