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 +4 -4
- data/lib/withruntime/agents.rb +72 -6
- data/lib/withruntime/apps.rb +131 -10
- data/lib/withruntime/builds.rb +0 -9
- data/lib/withruntime/ci.rb +122 -0
- data/lib/withruntime/client.rb +5 -2
- data/lib/withruntime/errors.rb +31 -7
- data/lib/withruntime/products.rb +80 -7
- data/lib/withruntime/record.rb +14 -0
- data/lib/withruntime/reservations.rb +76 -0
- data/lib/withruntime/sandbox.rb +51 -15
- data/lib/withruntime/transport.rb +50 -12
- data/lib/withruntime/version.rb +1 -1
- data/lib/withruntime.rb +6 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 92aca2f2d4a8d94b0871172606a8608e07d916e2e7236adab4b5752409fb466e
|
|
4
|
+
data.tar.gz: 87b4a3b19035d177f07c5a98b5adf256e7eab48769c1f803850d10b552800f64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 28866196e471465715beba471c141ae01a1de33f5b4fc66266a53dc5fc27f170c1d0f67920bbeff930ee4bcaf00a574768e4800d71b292b8cbe88426cca049c8
|
|
7
|
+
data.tar.gz: e1339ba49b930b10c79f043d5187906dfff71c0a2832f7dc5569b2cd0b5a62b1544f6b312ca9944d9bc384d7718eab8cfb32f11a8642e3782d29d06e6c96aee5
|
data/lib/withruntime/agents.rb
CHANGED
|
@@ -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: }
|
|
63
|
-
# { snapshot: }
|
|
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,
|
|
73
|
-
#
|
|
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+ (
|
|
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
|
#
|
data/lib/withruntime/apps.rb
CHANGED
|
@@ -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,
|
|
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),
|
|
158
|
-
#
|
|
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,
|
|
163
|
-
# hint and last_output. A version: version,
|
|
164
|
-
# (its deploy's id) and build (nil for an
|
|
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:+, +
|
|
177
|
-
#
|
|
178
|
-
# +
|
|
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))
|
data/lib/withruntime/builds.rb
CHANGED
|
@@ -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
|
data/lib/withruntime/client.rb
CHANGED
|
@@ -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
|
|
data/lib/withruntime/errors.rb
CHANGED
|
@@ -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
|
-
#
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
data/lib/withruntime/products.rb
CHANGED
|
@@ -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.
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
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
|
data/lib/withruntime/record.rb
CHANGED
|
@@ -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
|
data/lib/withruntime/sandbox.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
data/lib/withruntime/version.rb
CHANGED
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.
|
|
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-
|
|
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
|