withruntime 0.5.0 → 0.8.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: 32515cd23a35b91a06324a7af01623f6dd75015c116e041c499e7a9b9548609c
4
- data.tar.gz: 67454492f534d850643392417477f3f3d2782e7564bebb43555f76c6a1c9adfa
3
+ metadata.gz: b82967a1882d29df57dcef740f00077ce3a9afd05a1fdb6b031122438adfb92a
4
+ data.tar.gz: d79d6dc698f67d2e8fb8ee287f9830c243607cb7e127935ec6df6b21b2f2d736
5
5
  SHA512:
6
- metadata.gz: 0ce1fea1e0289719efc5d1ece2a48181e8238a400c4abd25d3ed79c12661b6c28465acb33c43681f32dad71428c86a2bd94c11eae6692fbcc2d97e29d31c95c3
7
- data.tar.gz: 07a2ef09506dd306e8843f1add75482ec75be67e696dcb3b9c66edec9d6b1540208914be83b2f165f89e92f9a54d3c4f27554d435b3ab74c8b5e7f2d67e0d866
6
+ metadata.gz: d3f846d1588e641fa9e781707be27af354a9b474e808b5657c918c6792a468b3cf49a08ea1eed6ecfe98591bec3d6935b3ff964c3382a8863ee143665ad936d0
7
+ data.tar.gz: 8839301368e7bf649663864afba4a313c7c0936b6c228888a6eb3d4ed72a517410502cf231f1f6e9da34d89c3ae8905fcb23307446700a68453a3d4e530bdae0
@@ -10,12 +10,44 @@ module WithRuntime
10
10
  end
11
11
 
12
12
  # One entry of a session's log: +seq+, +kind+ (input, answer, reply, ask,
13
- # status, result, error or note), +actor+, +body+ and +created_at+. +text+
14
- # is its words, +body.text+.
13
+ # status, result, error, note, or child: a child session's end, posted into
14
+ # its parent's log), +actor+, +body+ and +created_at+. +text+ is its words,
15
+ # +body.text+.
15
16
  class AgentEvent < Record
16
17
  def text = self["body"].is_a?(Hash) ? self["body"]["text"] : nil
17
18
  end
18
19
 
20
+ # A session as the API answers it. Times are ISO 8601 strings.
21
+ #
22
+ # +id+, +agent_id+, +agent_version+, +key+, +trigger+ (api, slack, github,
23
+ # schedule, webhook, or spawn: a child its parent's harness started),
24
+ # +state+ (queued, running, waiting, done, failed or stopped), +reason+
25
+ # (why it ended; among others "budget", "parent_ended", and "deleted" on a
26
+ # deleted record), +result+, +sandbox_id+, +last_seq+, +labels+,
27
+ # +created_at+, +started_at+, +ended_at+.
28
+ #
29
+ # Its tree: +parent_session_id+ (nil for a top session), +root_session_id+
30
+ # (itself for a top session) and +depth+ (0 to 3). Its use: +cost+ (this
31
+ # session's so far) and +tree_cost+ (it and every session under it), each
32
+ # an AgentCost. Its storage: +history_bytes+ (its log, kept until the
33
+ # session is deleted) and +attachment_bytes+ (its files and screenshots).
34
+ # +stop_why+ is why a stop was asked ("stopped", "budget" or
35
+ # "parent_ended") or nil, and +deleted_at+ is set once its history was
36
+ # deleted (+sessions.delete+).
37
+ class AgentSessionInfo < Record
38
+ def cost = AgentCost.new(self["cost"])
39
+ def tree_cost = AgentCost.new(self["treeCost"])
40
+ def deleted? = !self["deletedAt"].nil?
41
+ end
42
+
43
+ # What a session, or its tree, has used: +dollars+, +vcpu_seconds+ and
44
+ # +gib_seconds+ (0 when not given).
45
+ class AgentCost < Record
46
+ def dollars = self["dollars"] || 0
47
+ def vcpu_seconds = self["vcpuSeconds"] || 0
48
+ def gib_seconds = self["gibSeconds"] || 0
49
+ end
50
+
19
51
  # +runtime.agents+: agents that run in their own sandbox per session, started
20
52
  # by your own app through the API, Slack, GitHub, a schedule or a webhook. A private
21
53
  # beta: an account outside it is refused +product_in_beta+
@@ -28,14 +60,18 @@ module WithRuntime
28
60
  #
29
61
  # A definition is a Hash, by snake_case keyword (+memory_mib:+) or by the
30
62
  # API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: } or
31
- # { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set),
32
- # +instructions+, +env+, +secrets+ (names of egress secrets: model keys go
33
- # here), +mcp+ ([{ name:, url: }]), +volumes+ ([{ volume:, path: }]),
34
- # +idle_pause_seconds+, +max_session_seconds+, +max_concurrent+,
35
- # +max_session_dollars+ (5 unless set: a session at it stops, reason
36
- # "budget"), +max_day_dollars+ (100 unless set: an agent at it starts nothing
37
- # new, ConflictError +budget_reached+, until the next day) and +template+
38
- # ("support" or "team").
63
+ # { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set; a
64
+ # definition from a snapshot takes neither, InvalidRequestError, since a
65
+ # snapshot keeps its own size), +instructions+, +env+, +secrets+ (names of
66
+ # egress secrets: model keys go here), +mcp+ ([{ name:, url: }]), +volumes+
67
+ # ([{ volume:, path: }]), +idle_pause_seconds+, +max_session_seconds+,
68
+ # +max_concurrent+, +max_session_dollars+ (more than 0, at most 10000, 5
69
+ # unless set: the session and every session under it, the root's covering
70
+ # its tree; a tree at it stops, reason "budget"), +max_day_dollars+ (more
71
+ # 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
+ # "team").
39
75
  class Agents < Product
40
76
  attr_reader :sessions, :triggers, :templates
41
77
 
@@ -99,37 +135,42 @@ module WithRuntime
99
135
  AgentSession.new(@t, info, joined: false, seq: info["lastSeq"])
100
136
  end
101
137
 
102
- # Deletes an ended session and its history; until then it is kept,
103
- # counted as storage. An open session is refused +session_open+
104
- # (ConflictError): stop it first.
138
+ # Deletes an ended session's history: its log, and every log under it in
139
+ # its tree. Until then the history is kept, counted as storage. The
140
+ # session is kept as a deleted record: +get+ still reads it, with
141
+ # +deleted_at+ set, +reason+ "deleted", +key+ and +result+ cleared and its
142
+ # cost kept (a delete never frees budget), and its log reads empty. An
143
+ # open session or tree is refused +session_open+ (ConflictError): stop it
144
+ # first.
105
145
  def delete(id) = @t.json("DELETE", "/v1/agents/sessions/#{seg(id)}") && nil
106
146
 
107
- # The first page of sessions, as records; +each+ walks every page.
108
- # +agent+ is an id or a name; +state+ is queued, running, waiting, done,
109
- # failed or stopped; at most +limit+ a page.
110
- def list(agent: nil, state: nil, cursor: nil, limit: nil)
111
- sessions_page({ "agent" => agent, "state" => state, "limit" => limit }, cursor)
147
+ # The first page of sessions, as AgentSessionInfo records; +each+ walks
148
+ # every page. +agent+ is an id or a name; +state+ is queued, running,
149
+ # waiting, done, failed or stopped; +parent+ (a session's id) lists its
150
+ # direct children; +root+ (a top session's id) its whole tree, the root
151
+ # included; +deleted: true+ includes deleted records, hidden otherwise; at
152
+ # most +limit+ a page.
153
+ def list(agent: nil, state: nil, parent: nil, root: nil, deleted: nil, cursor: nil, limit: nil)
154
+ query = { "agent" => agent, "state" => state, "parent" => parent, "root" => root, "deleted" => deleted, "limit" => limit }
155
+ sessions_page(query, cursor)
112
156
  end
113
157
 
114
158
  private
115
159
 
116
160
  def sessions_page(query, cursor)
117
161
  body = @t.json("GET", "/v1/agents/sessions", query: query.merge("cursor" => cursor))
118
- Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) { |after| sessions_page(query, after) }
162
+ Page.new(body["data"].map { |item| AgentSessionInfo.new(item) }, body["nextCursor"]) { |after| sessions_page(query, after) }
119
163
  end
120
164
  end
121
165
 
122
- # A session of an agent. +info+ is its latest record: +state+ (queued,
123
- # running, waiting, done, failed or stopped), +reason+, +result+,
124
- # +last_seq+ and the rest; once the API sends them, +parent_session_id+ and
125
- # +root_session_id+ (a child session's parent and its tree's root), +cost+
126
- # ({ dollars, vcpuSeconds, gibSeconds } so far), +tree_cost+ (its children's
127
- # summed in), +history_bytes+ (text history, counted as storage) and
128
- # +attachment_bytes+ (files and screenshots, counted as storage), each nil
129
- # until then; history is kept until the session is deleted
130
- # (+sessions.delete+). +joined+ is whether +start+
131
- # joined an open session with the same key, and +seq+ the number of the
132
- # words it sent.
166
+ # A session of an agent. +info+ is its latest record, an AgentSessionInfo:
167
+ # +state+ (queued, running, waiting, done, failed or stopped), +reason+,
168
+ # +result+, +last_seq+, its tree (+parent_session_id+, +root_session_id+,
169
+ # +depth+), its use (+cost+, +tree_cost+), its storage (+history_bytes+,
170
+ # +attachment_bytes+), +stop_why+, +deleted_at+ and the rest; history is
171
+ # kept until the session is deleted (+sessions.delete+). +joined+ is
172
+ # whether +start+ joined an open session with the same key, and +seq+ the
173
+ # number of the words it sent.
133
174
  class AgentSession
134
175
  ENDED = %w[done failed stopped].freeze
135
176
  # How long the API holds an events read open, and the margin past it a
@@ -141,7 +182,7 @@ module WithRuntime
141
182
 
142
183
  def initialize(transport, info, joined: false, seq: nil)
143
184
  @t = transport
144
- @info = Record.new(info)
185
+ @info = AgentSessionInfo.new(info)
145
186
  @joined = joined
146
187
  @seq = seq
147
188
  end
@@ -153,10 +194,15 @@ module WithRuntime
153
194
 
154
195
  # Reads the session again.
155
196
  def refresh(timeout: nil)
156
- @info = Record.new(@t.json("GET", path, timeout: timeout))
197
+ @info = AgentSessionInfo.new(@t.json("GET", path, timeout: timeout))
157
198
  self
158
199
  end
159
200
 
201
+ # The first page of its direct children, the sessions its harness started
202
+ # (+trigger+ "spawn"), newest first; +each+ walks every page.
203
+ # +sessions.list(root:)+ reads a whole tree.
204
+ def children(cursor: nil, limit: nil) = AgentSessions.new(@t).list(parent: id, cursor: cursor, limit: limit)
205
+
160
206
  # Sends words into a running or waiting session and answers their seq.
161
207
  # The idempotency key, made here when not given, means a retried send is
162
208
  # never two messages. Named send_message, not send, so Ruby's own
@@ -192,7 +238,7 @@ module WithRuntime
192
238
  # Asks the session to stop and keeps the answer. A running session ends
193
239
  # once its sandbox is deleted; this does not wait for that (+wait+ does).
194
240
  def stop
195
- @info = Record.new(@t.json("POST", path("/stop"), body: {}))
241
+ @info = AgentSessionInfo.new(@t.json("POST", path("/stop"), body: {}))
196
242
  end
197
243
 
198
244
  # The session once it has ended (done, failed or stopped); a failed
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module WithRuntime
6
+ # A product built on the five verbs every resource shares (create, get,
7
+ # list, update, delete) and following one to a state: the same rules as the
8
+ # JavaScript SDK's resources.ts. One deadline covers a write, its retries
9
+ # and every read after it.
10
+ class ResourceProduct < Product
11
+ # A whole wait's deadline and its pause between reads.
12
+ class Following
13
+ attr_reader :key
14
+
15
+ def initialize(timeout, poll, key)
16
+ @poll = poll
17
+ @key = key
18
+ @deadline = timeout.positive? ? now + timeout : nil
19
+ end
20
+
21
+ # Seconds left for a call, or nil when the wait has no deadline (the
22
+ # client's own timeout then bounds each call).
23
+ def left = @deadline && [@deadline - now, 0.001].max
24
+
25
+ def expired? = !@deadline.nil? && now >= @deadline
26
+
27
+ def pause
28
+ sleep(@deadline ? [@poll, [@deadline - now, 0].max].min : @poll)
29
+ raise timed_out if expired?
30
+ end
31
+
32
+ def timed_out
33
+ ConnectionError.new("Waiting was cancelled or reached its deadline; the resource may still change.",
34
+ code: "timeout", idempotency_key: key,
35
+ hint: "Read the resource to resume waiting. Retry a write with the same idempotency_key.")
36
+ end
37
+
38
+ private
39
+
40
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ end
42
+
43
+ # +path+ is the product's path (/v1/apps); a wait ends at its target or at
44
+ # any of +settled+, which raises the resource's own error.
45
+ def initialize(transport, path, settled)
46
+ super(transport)
47
+ @base = path
48
+ @settled = settled
49
+ end
50
+
51
+ # Creates one; defaults belong to the API, so the SDK sends none.
52
+ def create(idempotency_key: nil, **fields)
53
+ Record.new(@t.json("POST", @base, body: Fields.body(fields), key: idempotency_key))
54
+ end
55
+
56
+ # With +wait_for:+ the API holds the answer until that state or
57
+ # +timeout_seconds+ (1 to 60; 30 by default) pass. Check the state it returns.
58
+ def get(id, wait_for: nil, timeout_seconds: nil)
59
+ read(id, { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds },
60
+ wait_for && ((timeout_seconds || 30) + 60))
61
+ end
62
+
63
+ # +label:+ is key:value filters that all must match; +state:+ defaults to
64
+ # the product's live states; +limit:+ 1 to 100 (50).
65
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
66
+ page(@base, { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
67
+ end
68
+
69
+ # Fields left out stay as they are; +name: :remove+ clears the name.
70
+ def update(id, idempotency_key: nil, **changes)
71
+ Record.new(@t.json("POST", "#{@base}/#{seg(id)}:update", body: patch(changes), key: idempotency_key))
72
+ end
73
+
74
+ # Follows the resource's state to +target+, reading it every +poll+
75
+ # seconds (0.25) for at most +timeout+ seconds in all (300; 0 waits as long
76
+ # as it takes). Raises the resource's error when it settles elsewhere, and
77
+ # ConnectionError with code timeout at the deadline.
78
+ def wait(id, target, timeout: 300, poll: 0.25)
79
+ following(timeout, poll, nil) { |wait| follow(id, target, wait) }
80
+ end
81
+
82
+ # Deletes it. With +wait: true+, follows it to deleted under one
83
+ # idempotency key made before the first try, so a timed-out delete can be
84
+ # retried after a restart with the key its error carries.
85
+ def delete(id, wait: false, timeout: 300, poll: 0.25, idempotency_key: nil)
86
+ return remove(id, idempotency_key, nil) unless wait
87
+
88
+ key = idempotency_key || SecureRandom.uuid
89
+ following(timeout, poll, key) { |follow| follow(id, "deleted", follow, remove(id, key, follow.left)) }
90
+ end
91
+
92
+ private
93
+
94
+ def read(id, query = nil, timeout = nil)
95
+ Record.new(@t.json("GET", "#{@base}/#{seg(id)}", query: query, timeout: timeout))
96
+ end
97
+
98
+ def remove(id, key, timeout) = Record.new(@t.json("DELETE", "#{@base}/#{seg(id)}", key: key, timeout: timeout))
99
+
100
+ def follow(id, target, wait, initial = nil)
101
+ record = initial || read(id, nil, wait.left)
102
+ loop do
103
+ return record if record.state == target
104
+ if @settled.include?(record.state)
105
+ error = record["error"].is_a?(Hash) ? record["error"] : {}
106
+ raise Error.new(error["message"] || "Expected #{target}, but #{id} reached #{record.state}.",
107
+ code: error["code"] || "unexpected_state", idempotency_key: wait.key,
108
+ hint: "Read the resource and inspect its error before starting another change.",
109
+ details: { "id" => id, "state" => record.state, "target" => target, "resource" => record.to_h })
110
+ end
111
+ wait.pause
112
+ record = read(id, nil, wait.left)
113
+ end
114
+ end
115
+
116
+ # One deadline covers the write, retries and every read. A read's failure
117
+ # still belongs to the write that started the wait, so it carries its key.
118
+ def following(timeout, poll, key)
119
+ check(timeout, "timeout")
120
+ check(poll, "poll")
121
+ wait = Following.new(timeout, poll, key)
122
+ begin
123
+ result = yield wait
124
+ raise wait.timed_out if wait.expired?
125
+
126
+ result
127
+ rescue Error => e
128
+ raise wait.timed_out if wait.expired?
129
+ raise e if key.nil? || e.idempotency_key
130
+
131
+ raise e.class.new(e.message, code: e.code, status: e.status, hint: e.hint, request_id: e.request_id,
132
+ details: e.details, idempotency_key: key, retry_after: e.retry_after)
133
+ end
134
+ end
135
+
136
+ def check(value, field)
137
+ return if value.is_a?(Numeric) && value.finite? && !value.negative?
138
+
139
+ raise Error.new("#{field} must be a finite, nonnegative number.", code: "invalid_request",
140
+ hint: "Fix the option and call again; nothing was sent.",
141
+ details: { "field" => field })
142
+ end
143
+ end
144
+
145
+ # +runtime.apps+ and +runtime.functions+: your server, deployed from an
146
+ # image or a build, kept running in copies (an App keeps +min_copies+ awake;
147
+ # a Function sleeps to zero). In a private beta: an account outside it is
148
+ # refused with product_in_beta (403, PermissionDeniedError).
149
+ #
150
+ # app = runtime.apps.create(name: "web", max_copies: 3, vcpu: 1, memory_mib: 1024)
151
+ # deploy = runtime.apps.deploy(app.id, image: "web:latest", env: { "MODE" => "prod" }, wait: true)
152
+ # puts deploy.url
153
+ #
154
+ # An App or Function: id, name, labels, state (waiting, live, failed,
155
+ # deleting, deleted), min_copies, max_copies, concurrency, spares, vcpu,
156
+ # memory_mib, port, health_path, live_version, previous_version, error
157
+ # ({code, message} or nil), created_at, updated_at, deleted_at, and
158
+ # release_watch (rollback, alert or off) where the API sends it.
159
+ #
160
+ # A deploy: id, version, url and state (building, warming, switching, live,
161
+ # failed, superseded); a failed or superseded one has error with code
162
+ # (deploy_build_failed, deploy_health_failed, deploy_superseded), message,
163
+ # hint and last_output. A version: version, live, previous, saved, deploy
164
+ # (its deploy's id) and build (nil for an image). A copy: id, version,
165
+ # region, state (starting, serving, draining, frozen, parked) and since.
166
+ class Apps < ResourceProduct
167
+ DEPLOY_ENDED = %w[live failed superseded].freeze
168
+
169
+ # +path+: /v1/apps or /v1/functions.
170
+ def initialize(transport, path = "/v1/apps")
171
+ super(transport, path, %w[failed deleted live])
172
+ end
173
+
174
+ # Creates one waiting for its first version: +max_copies:+ (1 to 1000),
175
+ # +vcpu:+ (1 to 16) and +memory_mib:+ (128 to 65536) are required;
176
+ # +concurrency:+, +spares:+, +port:+ (8080), +health_path:+, +name:+,
177
+ # +labels:+, and for an App +min_copies:+ (0). A Function leaves
178
+ # +min_copies+ out, zero included.
179
+ def create(idempotency_key: nil, **fields) = super
180
+
181
+ # Follows resource state, not a deploy: an older live version can survive
182
+ # a failed rollout of a newer one. +target+ is "live" or "deleted".
183
+ def wait(id, target, timeout: 300, poll: 0.25) = super
184
+
185
+ # Deploys a new version from +image:+ (an image of this account: id,
186
+ # name, name:tag or name@version, ready) or +build:+ (a build's id), with
187
+ # +env:+, +port:+ and +health_path:+. +keep_env: true+ carries the last
188
+ # deploy's environment, still sealed, with +env:+'s names set over it.
189
+ # With +wait: true+, follows the deploy, under one idempotency key, until
190
+ # it is live, failed or superseded, and returns it (check its state).
191
+ def deploy(id, image: nil, build: nil, env: nil, keep_env: nil, port: nil, health_path: nil, wait: false,
192
+ timeout: 300, poll: 0.25, idempotency_key: nil)
193
+ unless image.nil? ^ build.nil?
194
+ raise ArgumentError, "Deploy exactly one of image: or build:; nothing was sent."
195
+ end
196
+
197
+ body = Fields.body(image: image, build: build, env: env, keep_env: keep_env, port: port, health_path: health_path)
198
+ write_deploy(id, "deploys", body, wait, timeout, poll, idempotency_key)
199
+ end
200
+
201
+ # A deploy, by the App's id and its own. With +wait_for:+ the API holds
202
+ # the answer until that state or +timeout_seconds+ pass.
203
+ def get_deploy(id, deploy_id, wait_for: nil, timeout_seconds: nil)
204
+ read_deploy(id, deploy_id, wait_for, timeout_seconds, wait_for && ((timeout_seconds || 30) + 60))
205
+ end
206
+
207
+ # Resumes following a deploy by id after a restart, without making another version.
208
+ def wait_deploy(id, deploy_id, timeout: 300, poll: 0.25)
209
+ following(timeout, poll, nil) do |wait|
210
+ follow_deploy(id, read_deploy(id, deploy_id, nil, nil, wait.left), wait)
211
+ end
212
+ end
213
+
214
+ # Goes back to the previous version, as a new deploy; +wait: true+ as for +deploy+.
215
+ def rollback(id, wait: false, timeout: 300, poll: 0.25, idempotency_key: nil)
216
+ write_deploy(id, "rollback", {}, wait, timeout, poll, idempotency_key)
217
+ end
218
+
219
+ def versions(id) = @t.json("GET", "#{@base}/#{seg(id)}/versions").map { |version| Record.new(version) }
220
+
221
+ def copies(id) = @t.json("GET", "#{@base}/#{seg(id)}/copies").map { |copy| Record.new(copy) }
222
+
223
+ private
224
+
225
+ def read_deploy(id, deploy_id, wait_for, timeout_seconds, timeout)
226
+ Record.new(@t.json("GET", "#{@base}/#{seg(id)}/deploys/#{seg(deploy_id)}",
227
+ query: { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds }, timeout: timeout))
228
+ end
229
+
230
+ def follow_deploy(id, record, wait)
231
+ until DEPLOY_ENDED.include?(record.state)
232
+ record = read_deploy(id, record.id, "live", 60, wait.left)
233
+ wait.pause unless DEPLOY_ENDED.include?(record.state)
234
+ end
235
+ record
236
+ end
237
+
238
+ def write_deploy(id, verb, body, wait, timeout, poll, idempotency_key)
239
+ write = ->(key, left) { Record.new(@t.json("POST", "#{@base}/#{seg(id)}/#{verb}", body: body, key: key, timeout: left)) }
240
+ return write.call(idempotency_key, nil) unless wait
241
+
242
+ key = idempotency_key || SecureRandom.uuid
243
+ following(timeout, poll, key) { |follow| follow_deploy(id, write.call(key, follow.left), follow) }
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # +runtime.builds+: a connected repository's commit built into the server an
5
+ # App or Function deploys. In a private beta: an account outside it is
6
+ # refused with product_in_beta (403, PermissionDeniedError).
7
+ #
8
+ # repo = runtime.builds.repositories.connect(provider: "github", repository: "acme/web", deploy: "app:#{app.id}")
9
+ # build = runtime.builds.create(repository_id: repo.id, ref: "main")
10
+ # runtime.builds.logs(build.id, follow: true) { |event| puts event.text if event.type == "line" }
11
+ #
12
+ # A build: id, name, labels, repository_id, ref, branch, commit, state
13
+ # (queued, fetching, building, deploying, succeeded, failed, cancelled,
14
+ # superseded), reason, timings (milliseconds per step once done, or nil),
15
+ # output_snapshot_id, error, created_at, updated_at and deleted_at.
16
+ class Builds < Product
17
+ TERMINAL = %w[succeeded failed cancelled superseded].freeze
18
+ # Reconnections in a row with no new line before a follow gives up.
19
+ RECONNECTS = 3
20
+ SEEN = 10_000
21
+ SAFE_INTEGER = (2**53) - 1
22
+
23
+ attr_reader :repositories
24
+
25
+ def initialize(transport)
26
+ super
27
+ @repositories = Repositories.new(transport)
28
+ end
29
+
30
+ # Builds +ref+ of a connected repository. The same +branch:+ and commit is
31
+ # one build; a newer build of the branch supersedes older ones not ended.
32
+ def create(repository_id:, ref:, branch: nil, name: nil, labels: nil, idempotency_key: nil)
33
+ body = Fields.body(repository_id: repository_id, ref: ref, branch: branch, name: name, labels: labels)
34
+ Record.new(@t.json("POST", "/v1/builds", body: body, key: idempotency_key))
35
+ end
36
+
37
+ def get(id) = Record.new(@t.json("GET", path(id)))
38
+
39
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
40
+ page("/v1/builds", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
41
+ end
42
+
43
+ # Cancels a build; one that already ended stays as it is.
44
+ def cancel(id, idempotency_key: nil) = Record.new(@t.json("POST", "#{path(id)}:cancel", key: idempotency_key))
45
+
46
+ # The last 200 lines and the failing step: lines (cursor, at, step, text,
47
+ # stream), cursor, state, reason, failing_step (name, exit_code,
48
+ # last_lines), has_more and next_cursor; +page_cursor:+ pages older lines.
49
+ #
50
+ # With +follow: true+, each event to the block as it is written (or an
51
+ # Enumerator without one), until the build ends: a line (type "line"), a
52
+ # gap where lines were lost (type "gap", earliest_cursor), then one
53
+ # terminal event (state, reason, failing_step). +cursor:+ resumes after
54
+ # that line. A lost connection reconnects from the last line, up to three
55
+ # times in a row; then build_log_interrupted, whose details cursor resumes.
56
+ # +timeout:+ bounds the whole follow, reconnections included (120 seconds;
57
+ # 0 for none).
58
+ def logs(id, follow: false, cursor: nil, page_cursor: nil, timeout: 120, &block)
59
+ unless follow
60
+ return Record.new(@t.json("GET", "#{path(id)}/logs", query: { "cursor" => cursor, "pageCursor" => page_cursor }))
61
+ end
62
+ return enum_for(:logs, id, follow: true, cursor: cursor, page_cursor: page_cursor, timeout: timeout) unless block
63
+
64
+ follow_logs(id, cursor, page_cursor, timeout, &block)
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ def path(id) = "/v1/builds/#{seg(id)}"
71
+
72
+ def failure(code, hint, cursor) = Error.new(hint, code: code, hint: hint, details: { "cursor" => cursor })
73
+
74
+ def invalid(cursor) = failure("build_log_invalid", "Retry the log from the last received cursor.", cursor)
75
+
76
+ def valid_cursor?(value)
77
+ value.is_a?(String) && !value.empty? && value.length <= 4096 && !value.match?(/[\x00-\x1f\x7f]/)
78
+ end
79
+
80
+ def valid_failure?(value)
81
+ return true if value.nil?
82
+ return false unless value.is_a?(Hash)
83
+
84
+ lines = value["lastLines"]
85
+ value["name"].is_a?(String) && (value["exitCode"].nil? || safe_integer?(value["exitCode"])) &&
86
+ lines.is_a?(Array) && lines.size <= 20 && lines.all?(String)
87
+ end
88
+
89
+ def safe_integer?(value) = value.is_a?(Integer) && value.abs <= SAFE_INTEGER
90
+
91
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
+
93
+ # Cursors are opaque physical positions: they cannot be ordered or
94
+ # counted on, so lines already given are known by their cursor.
95
+ def follow_logs(id, cursor, page_cursor, timeout)
96
+ if (cursor && !valid_cursor?(cursor)) || page_cursor
97
+ raise failure("invalid_log_options", "Use an opaque cursor for follow; page_cursor is only for search pages.", cursor)
98
+ end
99
+
100
+ seen = cursor ? { cursor => true } : {}
101
+ failures = 0
102
+ deadline = timeout.positive? ? now + timeout : nil
103
+ timed_out = lambda do
104
+ ConnectionError.new("The log follow reached its deadline.", code: "timeout", details: { "cursor" => cursor },
105
+ hint: "Resume with logs(id, follow: true, cursor: details[\"cursor\"]).")
106
+ end
107
+ loop do
108
+ raise timed_out.call if deadline && now >= deadline
109
+
110
+ continued = false
111
+ begin
112
+ @t.events("GET", "#{path(id)}/logs", query: { "follow" => true, "cursor" => cursor },
113
+ timeout: deadline && (deadline - now)) do |event|
114
+ raise invalid(cursor) unless event.is_a?(Hash)
115
+ raise invalid(cursor) unless event.key?("cursor") && (event["cursor"].nil? || valid_cursor?(event["cursor"]))
116
+
117
+ case event["type"]
118
+ when "line"
119
+ unless event["text"].is_a?(String) && %w[stdout stderr].include?(event["stream"]) &&
120
+ safe_integer?(event["at"]) && !event["at"].negative? &&
121
+ (event["step"].nil? || event["step"].is_a?(String)) && valid_cursor?(event["cursor"])
122
+ raise invalid(cursor)
123
+ end
124
+ next if seen.key?(event["cursor"])
125
+
126
+ seen[event["cursor"]] = true
127
+ seen.delete(seen.first[0]) if seen.size > SEEN
128
+ cursor = event["cursor"]
129
+ failures = 0
130
+ yield Record.new(event)
131
+ when "gap"
132
+ raise invalid(cursor) if !valid_cursor?(event["earliestCursor"]) || (cursor && event["cursor"] != cursor)
133
+
134
+ cursor = event["cursor"]
135
+ yield Record.new(event)
136
+ when "terminal"
137
+ if (cursor && event["cursor"] != cursor) || !TERMINAL.include?(event["state"]) ||
138
+ !event["reason"].is_a?(String) || event["reason"].empty? || !valid_failure?(event["failingStep"]) ||
139
+ (event["state"] != "failed" && !event["failingStep"].nil?)
140
+ raise invalid(cursor)
141
+ end
142
+
143
+ yield Record.new(event)
144
+ return
145
+ when "continue"
146
+ if cursor && event["cursor"] != cursor
147
+ raise failure("build_log_gap", "Resume from details.cursor; report a persistent missing log line.", cursor)
148
+ end
149
+
150
+ cursor = event["cursor"]
151
+ continued = true
152
+ break
153
+ when "error"
154
+ unless event["cursor"] == cursor && event["code"].is_a?(String) && !event["code"].empty? &&
155
+ event["hint"].is_a?(String) && !event["hint"].empty?
156
+ raise invalid(cursor)
157
+ end
158
+
159
+ raise failure(event["code"], event["hint"], cursor)
160
+ else
161
+ raise invalid(cursor)
162
+ end
163
+ end
164
+ rescue ConnectionError
165
+ # A lost connection, not a refusal: the transport itself retries
166
+ # retryable answers. Reconnect from the last line.
167
+ raise timed_out.call if deadline && now >= deadline
168
+ rescue Error => e
169
+ raise e unless e.code == "unexpected_answer" && e.status.zero?
170
+
171
+ raise failure("build_log_invalid", "The log contained invalid JSON; retry from details.cursor.", cursor)
172
+ end
173
+ failures += 1 unless continued
174
+ if failures > RECONNECTS
175
+ raise failure("build_log_interrupted", "Reconnect with logs(id, follow: true, cursor: details[\"cursor\"]).", cursor)
176
+ end
177
+
178
+ # A service that is down, or continuations one after another, never busy-loop.
179
+ pause = 0.05 * [1, failures].max
180
+ pause = [pause, [deadline - now, 0].max].min if deadline
181
+ sleep(pause)
182
+ end
183
+ end
184
+ end
185
+
186
+ # +runtime.builds.repositories+: the repositories Runtime builds. Every push
187
+ # to a repository's production branch builds it and deploys it to its
188
+ # +deploy+ target. In the Builds private beta (product_in_beta, 403, outside it).
189
+ #
190
+ # A repository: id, name, labels, provider (github or git), repository,
191
+ # deploy (app:<id>, function:<id> or container:<id>), production_branch,
192
+ # previews, root_directory, install_command, build_command, start_command,
193
+ # port, health_path, build_env, state (connected or disconnected), error,
194
+ # created_at, updated_at and deleted_at.
195
+ class Repositories < Product
196
+ # Connects +repository+ from +provider+ ("github" or "git") to +deploy+.
197
+ # Optional: production_branch, previews, root_directory, install_command
198
+ # (chosen by the lockfile when left out), build_command, start_command
199
+ # (the build then ends as that server, answering health_path with 200),
200
+ # port, health_path, build_env (never a secret: anyone who reads the
201
+ # repository sees it), name and labels.
202
+ def connect(provider:, repository:, deploy:, idempotency_key: nil, **settings)
203
+ body = Fields.body(settings.merge(provider: provider, repository: repository, deploy: deploy))
204
+ Record.new(@t.json("POST", "/v1/repositories", body: body, key: idempotency_key))
205
+ end
206
+
207
+ def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
208
+ page("/v1/repositories", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
209
+ end
210
+
211
+ # Changes settings in place; its id and builds stay. Fields left out stay
212
+ # as they are; +:remove+ clears name, install_command, build_command,
213
+ # start_command or health_path.
214
+ def update(id, idempotency_key: nil, **changes)
215
+ Record.new(@t.json("POST", "/v1/repositories/#{seg(id)}:update", body: patch(changes), key: idempotency_key))
216
+ end
217
+
218
+ def disconnect(id, idempotency_key: nil) = Record.new(@t.json("DELETE", "/v1/repositories/#{seg(id)}", key: idempotency_key))
219
+
220
+ # A link (url, expires_at) to install Runtime's GitHub App, so private
221
+ # repositories build. Open it in a browser within 15 minutes, install the
222
+ # App where Runtime may read, then connect the repository with provider github.
223
+ def install_github = Record.new(@t.json("POST", "/v1/repositories/github:install"))
224
+ end
225
+ end
@@ -24,7 +24,8 @@ module WithRuntime
24
24
 
25
25
  class Client
26
26
  attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :emails, :referrals,
27
- :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents
27
+ :switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents,
28
+ :apps, :functions, :builds, :containers
28
29
 
29
30
  # +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
30
31
  # +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
@@ -72,6 +73,11 @@ module WithRuntime
72
73
  @sso = SSO.new(@transport)
73
74
  @mcp = MCP.new(@transport)
74
75
  @agents = Agents.new(@transport)
76
+ # Private beta: outside it, product_in_beta (403).
77
+ @apps = Apps.new(@transport, "/v1/apps")
78
+ @functions = Apps.new(@transport, "/v1/functions")
79
+ @builds = Builds.new(@transport)
80
+ @containers = Containers.new(@transport)
75
81
  end
76
82
 
77
83
  # Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # +runtime.containers+: one OCI image's process on its own microVM, billed
5
+ # at the sandbox rates, at /v1/containers. In a private beta: an account
6
+ # outside it is refused with product_in_beta (403, PermissionDeniedError).
7
+ #
8
+ # box = runtime.containers.create(image: "nginx:1.27", vcpu: 1, memory_mib: 512, name: "web")
9
+ # box = runtime.containers.wait(box.id, "running")
10
+ # puts box.port
11
+ #
12
+ # A container: id, name, labels, state (waiting, running, sleeping, stopped,
13
+ # failed, deleting, deleted), image (pinned to its digest), entrypoint,
14
+ # command, working_dir, user, port (or nil), exposed_ports, on_exit, sleep,
15
+ # vcpu, memory_mib, env_names (the environment's names, never its values),
16
+ # error ({code, message} or nil), created_at, updated_at and deleted_at.
17
+ class Containers < ResourceProduct
18
+ def initialize(transport)
19
+ super(transport, "/v1/containers", %w[failed deleted])
20
+ end
21
+
22
+ # Runs one copy of +image:+ (an OCI reference; the answer pins its digest)
23
+ # with +vcpu:+ (1 to 16) and +memory_mib:+ (128 to 65536), all required.
24
+ # +entrypoint:+ replaces the image's Entrypoint and drops its Cmd;
25
+ # +command:+ replaces its Cmd. +env:+ is set over the image's own and never
26
+ # read back, only its names. +port:+ (1 to 65535) is required when the
27
+ # image exposes several TCP ports. +on_exit:+ is "restart" or "stop": by
28
+ # default it restarts when it serves a port, else stops. It never sleeps
29
+ # unless +sleep: true+. Also +name:+, +labels:+, +working_dir:+ and +user:+.
30
+ def create(idempotency_key: nil, **fields) = super
31
+
32
+ # Only +name:+ and +labels:+ change; +name: :remove+ clears the name.
33
+ def update(id, idempotency_key: nil, **changes) = super
34
+
35
+ # Follows its state to +target+ ("running", say); reaching failed or
36
+ # deleted first raises with its error.
37
+ def wait(id, target, timeout: 300, poll: 0.25) = super
38
+ end
39
+ end
@@ -3,9 +3,9 @@
3
3
  module WithRuntime
4
4
  # Ruby's snake_case keywords as the API's camelCase fields: +timeout_seconds:+
5
5
  # is timeoutSeconds, +memory_mib:+ is memoryMiB. User data (labels, env,
6
- # build_args, headers and the like) is sent as it is.
6
+ # build_args, build_env, headers and the like) is sent as it is.
7
7
  module Fields
8
- OPAQUE = %w[labels env buildArgs headers data context detail].freeze
8
+ OPAQUE = %w[labels env buildArgs buildEnv headers data context detail].freeze
9
9
 
10
10
  module_function
11
11
 
@@ -13,6 +13,14 @@ module WithRuntime
13
13
 
14
14
  def seg(value) = Transport.segment(value)
15
15
 
16
+ # A change's body from keyword arguments: nil is left out, +:remove+ sends
17
+ # null, which clears the field.
18
+ def patch(changes)
19
+ body = Fields.body(changes.reject { |_key, value| value == :remove })
20
+ changes.each { |key, value| body[key.is_a?(Symbol) ? Fields.camel(key) : key.to_s] = nil if value == :remove }
21
+ body
22
+ end
23
+
16
24
  def page(path, query)
17
25
  body = @t.json("GET", path, query: query)
18
26
  Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) do |cursor|
@@ -25,6 +25,12 @@ module WithRuntime
25
25
  # room, the create raises +no_capacity+ and nothing is made or charged.
26
26
  # +sbx.info.placement+ is an opaque tag for its server: the same tag means
27
27
  # the same server.
28
+ #
29
+ # +secrets: [name, ...]+ is the egress secrets this sandbox may use, by
30
+ # name: only those of your egress secrets that would otherwise reach it.
31
+ # +[]+ is none; leave it out for every secret that reaches it. Fixed for
32
+ # the sandbox's life; a copy of a snapshot may only narrow its source's
33
+ # list. +sbx.info.secrets+ reads it back, nil for every secret.
28
34
  def create(wait: true, idempotency_key: nil, wait_for_capacity: nil, **fields)
29
35
  room = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
30
36
  info = @t.json("POST", "/v1/sandboxes", body: Fields.body(fields), wait: wait ? 60 : 0,
@@ -208,8 +214,14 @@ module WithRuntime
208
214
  # run. One copy without +count+; an array with it. If a copy fails, the
209
215
  # error's +details["startedSandboxIds"]+ names the copies that did start.
210
216
  # +funding+ is accepted and ignored: copies run on free usage first, then credit.
211
- def fork(count: nil, name: nil, labels: nil, keep_snapshot: nil, funding: nil, idempotency_key: nil)
212
- body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot)
217
+ # +secrets: [name, ...]+ is the egress secrets the copies may use, by name:
218
+ # only those of your egress secrets that would otherwise reach them. +[]+
219
+ # is none; leave it out to keep this sandbox's list. Fixed for the copies'
220
+ # life; it may only narrow this sandbox's list.
221
+ def fork(count: nil, name: nil, labels: nil, keep_snapshot: nil, funding: nil, secrets: nil,
222
+ idempotency_key: nil)
223
+ body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot,
224
+ secrets: secrets&.to_a)
213
225
  reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
214
226
  copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
215
227
  count.nil? ? copies.first : copies
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.5.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/withruntime.rb CHANGED
@@ -28,6 +28,9 @@ require_relative "withruntime/sandbox"
28
28
  require_relative "withruntime/products"
29
29
  require_relative "withruntime/agents"
30
30
  require_relative "withruntime/images"
31
+ require_relative "withruntime/apps"
32
+ require_relative "withruntime/builds"
33
+ require_relative "withruntime/containers"
31
34
  require_relative "withruntime/network_products"
32
35
  require_relative "withruntime/parity_products"
33
36
  require_relative "withruntime/file_watch"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: withruntime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Runtime
@@ -22,7 +22,10 @@ files:
22
22
  - lib/withruntime.rb
23
23
  - lib/withruntime/agents.rb
24
24
  - lib/withruntime/api_defaults.rb
25
+ - lib/withruntime/apps.rb
26
+ - lib/withruntime/builds.rb
25
27
  - lib/withruntime/client.rb
28
+ - lib/withruntime/containers.rb
26
29
  - lib/withruntime/credentials.rb
27
30
  - lib/withruntime/errors.rb
28
31
  - lib/withruntime/fields.rb