pinesandbox 0.3.18 → 0.3.20
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/LICENSE +201 -0
- data/README.md +222 -161
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +90 -114
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +25 -2
- data/lib/pinesandbox/artifacts.rb +776 -0
- data/lib/pinesandbox/attach_credentials_source.rb +58 -16
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +75 -37
- data/lib/pinesandbox/computer.rb +146 -116
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +3 -0
- data/lib/pinesandbox/errors.rb +83 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/models/artifact_file.rb +40 -0
- data/lib/pinesandbox/models/computer_session.rb +7 -3
- data/lib/pinesandbox/operations.rb +2 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +16 -20
- data/lib/pinesandbox/session.rb +39 -4
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/streamed_read.rb +80 -0
- data/lib/pinesandbox/version.rb +3 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +4 -2
- data/pinesandbox.gemspec +5 -3
- metadata +11 -6
|
@@ -4,7 +4,7 @@ require "time"
|
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
require_relative "clock"
|
|
6
6
|
require_relative "operations"
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "sandbox_details"
|
|
8
8
|
require_relative "spec_version_middleware"
|
|
9
9
|
|
|
10
10
|
module PineSandbox
|
|
@@ -14,7 +14,7 @@ module PineSandbox
|
|
|
14
14
|
# specs/computer-api.yaml lifecycle routes (root, no /v1).
|
|
15
15
|
# @api private
|
|
16
16
|
class ControlPlane
|
|
17
|
-
|
|
17
|
+
SandboxInfo = Struct.new(:id, :image, :status, :metadata, :entrypoint,
|
|
18
18
|
:expires_at, :created_at, :operation_id,
|
|
19
19
|
:retryable, :reason, :message, :etag, :lifecycle, :runtime_revision, keyword_init: true)
|
|
20
20
|
|
|
@@ -49,7 +49,7 @@ module PineSandbox
|
|
|
49
49
|
# Internal adapter for the public Computer contract. No generic sandbox
|
|
50
50
|
# create, image selection or task dispatch is available through this client.
|
|
51
51
|
def create_computer(body:, request_timeout: nil)
|
|
52
|
-
# A create ID permits identical retries without extending
|
|
52
|
+
# A create ID permits identical retries without extending the Computer's expiry.
|
|
53
53
|
keyed = !!body[:create_id]
|
|
54
54
|
deadline = monotonic_now + request_timeout if request_timeout
|
|
55
55
|
busy_repeats = 0
|
|
@@ -135,7 +135,7 @@ module PineSandbox
|
|
|
135
135
|
true
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# These methods report acceptance.
|
|
138
|
+
# These methods report acceptance. SandboxHandle waits for completion.
|
|
139
139
|
# Keep the boolean result for integrations using older Docker servers.
|
|
140
140
|
# Neither is replay-safe, so a transport fault raises instead of resending.
|
|
141
141
|
def pause(sandbox_id, request_timeout: nil)
|
|
@@ -224,10 +224,10 @@ module PineSandbox
|
|
|
224
224
|
def to_sandbox!(resp, ok:)
|
|
225
225
|
raise_for_status!(resp) unless ok.include?(resp.status)
|
|
226
226
|
payload = parse_json(resp)
|
|
227
|
-
|
|
228
|
-
entrypoint: payload["entrypoint"], status:
|
|
229
|
-
expires_at:
|
|
230
|
-
lifecycle:
|
|
227
|
+
SandboxInfo.new(id: payload["id"], image: payload["image"], metadata: payload["metadata"] || {},
|
|
228
|
+
entrypoint: payload["entrypoint"], status: sandbox_state(payload.dig("status", "state")),
|
|
229
|
+
expires_at: parse_time(payload["expiresAt"]), created_at: parse_time(payload["createdAt"]),
|
|
230
|
+
lifecycle: SandboxLifetime.from_payload(payload["lifecycle"]),
|
|
231
231
|
runtime_revision: payload["runtime_revision"],
|
|
232
232
|
operation_id: payload.dig("status", "operation_id"),
|
|
233
233
|
retryable: payload.dig("status", "retryable"),
|
|
@@ -235,11 +235,11 @@ module PineSandbox
|
|
|
235
235
|
end
|
|
236
236
|
|
|
237
237
|
# "Running" → :running, "PendingResume" → :pending_resume.
|
|
238
|
-
def
|
|
238
|
+
def sandbox_state(state)
|
|
239
239
|
state&.to_s&.gsub(/([a-z\d])([A-Z])/, '\1_\2')&.downcase&.to_sym
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
-
def
|
|
242
|
+
def parse_time(value) = value.nil? || value.is_a?(Time) ? value : Time.iso8601(value)
|
|
243
243
|
|
|
244
244
|
def raise_for_status!(resp)
|
|
245
245
|
body = parse_json(resp)
|
|
@@ -9,6 +9,7 @@ module PineSandbox
|
|
|
9
9
|
"/errors/sandbox-not-ready" => true,
|
|
10
10
|
"/errors/unauthorized" => false,
|
|
11
11
|
"/errors/forbidden" => false,
|
|
12
|
+
"/errors/feature-disabled" => false,
|
|
12
13
|
"/errors/precondition-required" => false,
|
|
13
14
|
"/errors/precondition-failed" => true,
|
|
14
15
|
"/errors/malformed-json" => false,
|
|
@@ -37,6 +38,8 @@ module PineSandbox
|
|
|
37
38
|
"/errors/no-desktop-stream" => false,
|
|
38
39
|
"/errors/desktop-unavailable" => false,
|
|
39
40
|
"/errors/skills-unavailable" => false,
|
|
41
|
+
"/errors/artifacts-unavailable" => true,
|
|
42
|
+
"/errors/artifacts-not-configured" => false,
|
|
40
43
|
"/errors/secret-in-skill" => false,
|
|
41
44
|
"/errors/skill-author-failed" => true,
|
|
42
45
|
"/errors/author-in-flight" => true,
|
data/lib/pinesandbox/errors.rb
CHANGED
|
@@ -55,7 +55,7 @@ module PineSandbox
|
|
|
55
55
|
# An HTTP error response from the Computer API. #status is the HTTP status;
|
|
56
56
|
# #problem_type and #problem_extensions carry an RFC 9457 problem detail
|
|
57
57
|
# (#field, #reason, #handoff_id, #request_id and #retryable? read its common
|
|
58
|
-
# members). The message is resource-first: the
|
|
58
|
+
# members). The message is resource-first: the Computer sandbox (#sandbox_id) and the
|
|
59
59
|
# operation (#op, "<METHOD> <path>") come before #request_id, so a handler
|
|
60
60
|
# that logs only the message can still route a report.
|
|
61
61
|
class ApiError < Error
|
|
@@ -73,14 +73,8 @@ module PineSandbox
|
|
|
73
73
|
super(@built_message)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# The
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
# Deprecated: use #runtime_id.
|
|
80
|
-
def sandbox_id
|
|
81
|
-
PineSandbox.deprecated_name("ApiError#sandbox_id", "#runtime_id")
|
|
82
|
-
runtime_id
|
|
83
|
-
end
|
|
76
|
+
# The id of the Computer sandbox that answered, when the request reached a Computer.
|
|
77
|
+
def sandbox_id = problem_extensions["sandbox_id"]
|
|
84
78
|
|
|
85
79
|
# The response's machine `code` ({code, message} bodies), when present.
|
|
86
80
|
def code = error_code
|
|
@@ -105,12 +99,11 @@ module PineSandbox
|
|
|
105
99
|
# The response's Retry-After in seconds, when it set one.
|
|
106
100
|
attr_accessor :retry_after
|
|
107
101
|
|
|
108
|
-
# Folds the
|
|
102
|
+
# Folds the sandbox id and operation onto an error raised before they were
|
|
109
103
|
# known, filling only what the server didn't send, and rebuilds the message.
|
|
110
104
|
# Returns self, for re-raising.
|
|
111
|
-
def attach_context!(
|
|
112
|
-
|
|
113
|
-
problem_extensions["sandbox_id"] ||= runtime_id if runtime_id && !runtime_id.to_s.empty?
|
|
105
|
+
def attach_context!(sandbox_id: nil, op: nil)
|
|
106
|
+
problem_extensions["sandbox_id"] ||= sandbox_id if sandbox_id && !sandbox_id.to_s.empty?
|
|
114
107
|
problem_extensions["op"] ||= op if op && !op.to_s.empty?
|
|
115
108
|
@built_message = build_message
|
|
116
109
|
self
|
|
@@ -131,7 +124,7 @@ module PineSandbox
|
|
|
131
124
|
"API request failed with status #{status}"
|
|
132
125
|
end
|
|
133
126
|
parts = []
|
|
134
|
-
parts << "
|
|
127
|
+
parts << "sandbox=#{sandbox_id}" if sandbox_id && !sandbox_id.to_s.empty?
|
|
135
128
|
parts << "op=#{op}" if op && !op.to_s.empty?
|
|
136
129
|
parts << "request_id=#{request_id}" if request_id && !request_id.to_s.empty?
|
|
137
130
|
parts.empty? ? base : "#{base} (#{parts.join(', ')})"
|
|
@@ -177,6 +170,10 @@ module PineSandbox
|
|
|
177
170
|
# suspended or out of credits): control is cut, reads stay open. Re-attach,
|
|
178
171
|
# or report the suspension.
|
|
179
172
|
class LeaseExpired < ForbiddenError; end
|
|
173
|
+
# The project, or this Computer's binding, doesn't have a feature the call
|
|
174
|
+
# needs, such as the shell grant for Session#exec or the artifacts feature for
|
|
175
|
+
# the artifact methods. Retrying won't help.
|
|
176
|
+
class FeatureDisabled < ForbiddenError; end
|
|
180
177
|
# The access lapsed and renewing it failed for now. Retry; don't re-attach.
|
|
181
178
|
class LeaseRefreshUnavailable < ServiceUnavailableError; end
|
|
182
179
|
class AnotherSessionHuman < ConflictError; end
|
|
@@ -194,10 +191,23 @@ module PineSandbox
|
|
|
194
191
|
class HandoffExpired < GoneError; end
|
|
195
192
|
class HandoffPersistenceFailed < ServerError; end
|
|
196
193
|
|
|
194
|
+
# A Computer's artifacts have no file at #path (the path you passed,
|
|
195
|
+
# relative to the Computer's artifacts). A NotFoundError, so tier rescues
|
|
196
|
+
# keep catching it.
|
|
197
|
+
class ArtifactNotFoundError < NotFoundError
|
|
198
|
+
attr_reader :path
|
|
199
|
+
|
|
200
|
+
def initialize(path:, **fields)
|
|
201
|
+
@path = path
|
|
202
|
+
super(**fields)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
197
206
|
# Problem `type` → its typed error; anything else falls back to the status tier.
|
|
198
207
|
PROBLEM_TYPE_CLASSES = {
|
|
199
208
|
"/errors/control-not-held" => ControlNotHeld,
|
|
200
209
|
"/errors/lease-expired" => LeaseExpired,
|
|
210
|
+
"/errors/feature-disabled" => FeatureDisabled,
|
|
201
211
|
"/errors/lease-refresh-unavailable" => LeaseRefreshUnavailable,
|
|
202
212
|
"/errors/another-session-human" => AnotherSessionHuman,
|
|
203
213
|
"/errors/concurrent-transition" => ConcurrentTransition,
|
|
@@ -230,20 +240,32 @@ module PineSandbox
|
|
|
230
240
|
class ConnectionError < Error; end
|
|
231
241
|
class ReadyTimeoutError < Error; end
|
|
232
242
|
|
|
233
|
-
# The
|
|
243
|
+
# The Computer reported failed, terminated or unavailable before the
|
|
234
244
|
# lifecycle operation finished.
|
|
235
|
-
class
|
|
236
|
-
attr_reader :
|
|
245
|
+
class SandboxFailedError < Error
|
|
246
|
+
attr_reader :sandbox_id, :state
|
|
237
247
|
|
|
238
|
-
def initialize(message = nil,
|
|
239
|
-
@
|
|
248
|
+
def initialize(message = nil, sandbox_id: nil, state: nil)
|
|
249
|
+
@sandbox_id = sandbox_id
|
|
240
250
|
@state = state
|
|
241
|
-
super(message || "Computer
|
|
251
|
+
super(message || "Computer sandbox #{sandbox_id} reported #{state.inspect} before completing the lifecycle operation")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The Computer's artifacts take up more than its storage limit, so an
|
|
256
|
+
# upload or a copy into them is refused before anything is sent. Delete
|
|
257
|
+
# files to make room. #used_bytes and #limit_bytes are as Pine counted
|
|
258
|
+
# them when it issued the storage token.
|
|
259
|
+
class ArtifactStorageFullError < Error
|
|
260
|
+
attr_reader :used_bytes, :limit_bytes
|
|
261
|
+
|
|
262
|
+
def initialize(used_bytes:, limit_bytes:)
|
|
263
|
+
@used_bytes = used_bytes
|
|
264
|
+
@limit_bytes = limit_bytes
|
|
265
|
+
super("the Computer's artifacts use #{used_bytes} bytes, more than their limit of #{limit_bytes}; " \
|
|
266
|
+
"delete files before writing more")
|
|
242
267
|
end
|
|
243
268
|
end
|
|
244
|
-
# Deprecated: use RuntimeFailedError.
|
|
245
|
-
SandboxFailedError = RuntimeFailedError
|
|
246
|
-
deprecate_constant :SandboxFailedError
|
|
247
269
|
|
|
248
270
|
class StreamError < Error; end
|
|
249
271
|
class StreamingUnsupportedError < Error; end
|
|
@@ -295,14 +317,14 @@ module PineSandbox
|
|
|
295
317
|
end
|
|
296
318
|
end
|
|
297
319
|
|
|
298
|
-
# Start found
|
|
299
|
-
# BindError superclass for integrations already rescuing Start conflicts.
|
|
300
|
-
class
|
|
301
|
-
attr_reader :
|
|
320
|
+
# Start found the Computer still running or paused, and started nothing.
|
|
321
|
+
# Preserve the BindError superclass for integrations already rescuing Start conflicts.
|
|
322
|
+
class SandboxRetainedError < BindError
|
|
323
|
+
attr_reader :sandbox_id, :state
|
|
302
324
|
|
|
303
|
-
def initialize(
|
|
304
|
-
@
|
|
305
|
-
super("Computer
|
|
325
|
+
def initialize(sandbox_id:, state:)
|
|
326
|
+
@sandbox_id, @state = sandbox_id, state
|
|
327
|
+
super("Computer hasn't stopped (Computer sandbox #{sandbox_id}, state #{state}); Stop it or Resume retained memory before Start")
|
|
306
328
|
end
|
|
307
329
|
end
|
|
308
330
|
|
|
@@ -380,46 +402,34 @@ module PineSandbox
|
|
|
380
402
|
class ProjectAccessDenied < AttachCredentialsError; end
|
|
381
403
|
|
|
382
404
|
# 412 — another attach advanced the authorization revision. Terminal:
|
|
383
|
-
# reload/adopt the winning integrator database binding; do not
|
|
405
|
+
# reload/adopt the winning integrator database binding; do not start the Computer again.
|
|
384
406
|
class BindingRevisionConflictError < AttachCredentialsError
|
|
385
|
-
attr_reader :current_binding_revision, :
|
|
407
|
+
attr_reader :current_binding_revision, :current_sandbox_id
|
|
386
408
|
|
|
387
|
-
def initialize(message, current_binding_revision: nil,
|
|
409
|
+
def initialize(message, current_binding_revision: nil, current_sandbox_id: nil, **kwargs)
|
|
388
410
|
@current_binding_revision = current_binding_revision
|
|
389
|
-
@
|
|
411
|
+
@current_sandbox_id = current_sandbox_id
|
|
390
412
|
super(message, **kwargs)
|
|
391
413
|
end
|
|
392
414
|
|
|
393
415
|
# The winning binding's revision (also #current_binding_revision).
|
|
394
416
|
def current_revision = current_binding_revision
|
|
395
|
-
|
|
396
|
-
# Deprecated: use #current_runtime_id.
|
|
397
|
-
def current_sandbox_id
|
|
398
|
-
PineSandbox.deprecated_name("BindingRevisionConflictError#current_sandbox_id", "#current_runtime_id")
|
|
399
|
-
current_runtime_id
|
|
400
|
-
end
|
|
401
417
|
end
|
|
402
418
|
|
|
403
419
|
# Portal committed a new authorization revision, but the later bind
|
|
404
|
-
# failed. The SDK has
|
|
420
|
+
# failed. The SDK has shut the Computer down best-effort. Persist the
|
|
405
421
|
# revision before retrying so the next attach can supersede this attempt.
|
|
406
422
|
# #credentials is present for create_computer, whose generated identity and
|
|
407
423
|
# capture keypair would otherwise be lost because the method has no result.
|
|
408
424
|
class AttachAuthorizationCommittedError < BindError
|
|
409
|
-
attr_reader :binding_revision, :
|
|
425
|
+
attr_reader :binding_revision, :sandbox_id, :credentials
|
|
410
426
|
|
|
411
|
-
def initialize(message, binding_revision:,
|
|
427
|
+
def initialize(message, binding_revision:, sandbox_id: nil, credentials: nil, **kwargs)
|
|
412
428
|
@binding_revision = binding_revision
|
|
413
|
-
@
|
|
429
|
+
@sandbox_id = sandbox_id
|
|
414
430
|
@credentials = credentials
|
|
415
431
|
super(message, **kwargs)
|
|
416
432
|
end
|
|
417
|
-
|
|
418
|
-
# Deprecated: use #runtime_id.
|
|
419
|
-
def sandbox_id
|
|
420
|
-
PineSandbox.deprecated_name("AttachAuthorizationCommittedError#sandbox_id", "#runtime_id")
|
|
421
|
-
runtime_id
|
|
422
|
-
end
|
|
423
433
|
end
|
|
424
434
|
|
|
425
435
|
# The Computer answered with an `X-Computer-Spec-Version` major this SDK
|
|
@@ -435,44 +445,44 @@ module PineSandbox
|
|
|
435
445
|
end
|
|
436
446
|
|
|
437
447
|
# The Computer rejected the portal-issued bind_token: bad signature, unknown
|
|
438
|
-
# kid, iss/aud mismatch, expired, replayed jti, or issued for another
|
|
448
|
+
# kid, iss/aud mismatch, expired, replayed jti, or issued for another start.
|
|
439
449
|
# Terminal — retrying with the same token cannot succeed.
|
|
440
450
|
class BindAuthError < BindError; end
|
|
441
451
|
|
|
442
|
-
#
|
|
443
|
-
#
|
|
452
|
+
# This Computer is already attached elsewhere: another attach holds its epoch
|
|
453
|
+
# lease, or you are attaching from two places at once.
|
|
444
454
|
class ComputerAlreadyAttachedError < BindError; end
|
|
445
455
|
|
|
446
|
-
#
|
|
447
|
-
#
|
|
448
|
-
#
|
|
449
|
-
class
|
|
450
|
-
# Deprecated: use
|
|
451
|
-
PodPoisonedError =
|
|
456
|
+
# This Computer sandbox can't be attached (it was already attached, or a prior attach
|
|
457
|
+
# failed on it). The SDK shuts it down before raising; retry attach to start
|
|
458
|
+
# the Computer again.
|
|
459
|
+
class SandboxUnusableError < BindError; end
|
|
460
|
+
# Deprecated: use SandboxUnusableError.
|
|
461
|
+
PodPoisonedError = SandboxUnusableError
|
|
452
462
|
deprecate_constant :PodPoisonedError
|
|
453
463
|
|
|
454
|
-
# Saved state was unreachable during the bind; the
|
|
455
|
-
#
|
|
464
|
+
# Saved state was unreachable during the bind; the SDK has shut the Computer
|
|
465
|
+
# down. Retry attach later (transient).
|
|
456
466
|
class SavedStateUnavailableError < BindError; end
|
|
457
467
|
# Deprecated: use SavedStateUnavailableError.
|
|
458
468
|
BrokerUnreachableError = SavedStateUnavailableError
|
|
459
469
|
deprecate_constant :BrokerUnreachableError
|
|
460
470
|
|
|
461
471
|
# The bind handshake exhausted its retries without a terminal
|
|
462
|
-
# classification. Usually a
|
|
472
|
+
# classification. Usually a slow start or a network issue.
|
|
463
473
|
class BindTimeoutError < BindError; end
|
|
464
474
|
|
|
465
475
|
# The Computer returned 401 for its Computer token (ct_) — a REPORT that
|
|
466
476
|
# it did not recognize the binding's token, NOT an instruction to
|
|
467
|
-
# re-attach. ct_ is binding-lifetime (no idle expiry), so on a
|
|
468
|
-
#
|
|
469
|
-
#
|
|
470
|
-
#
|
|
477
|
+
# re-attach. ct_ is binding-lifetime (no idle expiry), so on a running
|
|
478
|
+
# Computer this state is binding_auth_lost: surface it and reconcile; only
|
|
479
|
+
# a Computer confirmed stopped justifies an attach (a new start invalidates
|
|
480
|
+
# every ps_ and fences the previous start's state).
|
|
471
481
|
# A 401 for a session token (ps_) is an ordinary UnauthorizedError.
|
|
472
482
|
class TokenRejectedError < BindError
|
|
473
|
-
MESSAGE = "the Computer rejected the computer token (401). ct_ is binding-lifetime, so on a
|
|
474
|
-
"
|
|
475
|
-
"
|
|
483
|
+
MESSAGE = "the Computer rejected the computer token (401). ct_ is binding-lifetime, so on a " \
|
|
484
|
+
"running Computer this is binding_auth_lost — reconcile; re-attach ONLY once the " \
|
|
485
|
+
"Computer is confirmed stopped (a new start invalidates every ps_)".freeze
|
|
476
486
|
|
|
477
487
|
# The rejection of `api_error` (a 401 answer to a Computer token), with
|
|
478
488
|
# its operation and request id folded into the message.
|
|
@@ -482,14 +492,14 @@ module PineSandbox
|
|
|
482
492
|
end
|
|
483
493
|
end
|
|
484
494
|
|
|
485
|
-
# The gateway can no longer route this Computer's
|
|
486
|
-
# distinct from an /errors/session-not-found response on a live
|
|
495
|
+
# The gateway can no longer route to this Computer's host, which is also
|
|
496
|
+
# true while it's paused or recovering. This is distinct from an /errors/session-not-found response on a live
|
|
487
497
|
# Computer. Inherit the legacy 404 tier so existing NotFoundError rescues
|
|
488
498
|
# remain valid; the original typed ApiError is retained as Exception#cause.
|
|
489
|
-
class
|
|
499
|
+
class SandboxGoneError < NotFoundError
|
|
490
500
|
def initialize(api_error)
|
|
491
501
|
detail = api_error.error_message.to_s
|
|
492
|
-
message = "the
|
|
502
|
+
message = "the Computer can't be reached at its host"
|
|
493
503
|
message = "#{message}: #{detail}" unless detail.empty?
|
|
494
504
|
super(
|
|
495
505
|
status: api_error.status,
|
|
@@ -502,9 +512,6 @@ module PineSandbox
|
|
|
502
512
|
self.retry_after = api_error.retry_after
|
|
503
513
|
end
|
|
504
514
|
end
|
|
505
|
-
# Deprecated: use RuntimeGoneError.
|
|
506
|
-
SandboxGoneError = RuntimeGoneError
|
|
507
|
-
deprecate_constant :SandboxGoneError
|
|
508
515
|
|
|
509
516
|
# 429 COMPUTER_CAPACITY_EXCEEDED on create/attach: the project is at its
|
|
510
517
|
# concurrent Computer limit, so nothing was provisioned. Not retried by the
|
|
@@ -3,7 +3,7 @@ require "time"
|
|
|
3
3
|
module PineSandbox
|
|
4
4
|
module Models
|
|
5
5
|
# A session artifact's metadata — a managed session output (a client upload,
|
|
6
|
-
# a
|
|
6
|
+
# a browser download, or an agent task output) with a download
|
|
7
7
|
# endpoint. Returned by {PineSandbox::Session#list_artifacts} and
|
|
8
8
|
# {PineSandbox::Session#upload_artifact}. Mirrors the spec `Artifact` schema.
|
|
9
9
|
Artifact = Struct.new(
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module PineSandbox
|
|
4
|
+
module Models
|
|
5
|
+
# One file in a Computer's artifacts. Returned by
|
|
6
|
+
# {PineSandbox::Artifacts#list}, #upload and #copy. `path` is relative to
|
|
7
|
+
# the Computer's artifacts ("research/q3.csv"); `note` is the note the file
|
|
8
|
+
# was uploaded with, if any.
|
|
9
|
+
ArtifactFile = Struct.new(
|
|
10
|
+
:path,
|
|
11
|
+
:size,
|
|
12
|
+
:updated_at,
|
|
13
|
+
:content_type,
|
|
14
|
+
:note,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
) do
|
|
17
|
+
# From a storage object, whose name is `prefix` followed by the path.
|
|
18
|
+
def self.from_object(object, prefix:)
|
|
19
|
+
new(
|
|
20
|
+
path: object["name"].to_s.delete_prefix(prefix),
|
|
21
|
+
# Storage sends the size as a decimal string.
|
|
22
|
+
size: object["size"].nil? ? nil : Integer(object["size"].to_s, 10),
|
|
23
|
+
updated_at: object["updated"] && Time.parse(object["updated"]),
|
|
24
|
+
content_type: object["contentType"],
|
|
25
|
+
note: object.dig("metadata", "pine-note")
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# One page of {PineSandbox::Artifacts#list}: the files, the folders
|
|
31
|
+
# directly below the listed prefix (each ending in "/"; none when listing
|
|
32
|
+
# recursively), and the token for the next page (nil on the last one).
|
|
33
|
+
ArtifactPage = Struct.new(
|
|
34
|
+
:files,
|
|
35
|
+
:folders,
|
|
36
|
+
:next_page_token,
|
|
37
|
+
keyword_init: true
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -12,12 +12,15 @@ module PineSandbox
|
|
|
12
12
|
:token,
|
|
13
13
|
:session_dir,
|
|
14
14
|
:browser,
|
|
15
|
-
:
|
|
15
|
+
:artifact_access,
|
|
16
16
|
:created_at,
|
|
17
17
|
:last_active_at,
|
|
18
18
|
keyword_init: true
|
|
19
19
|
) do
|
|
20
|
-
|
|
20
|
+
# `artifact_access_known` is false for a session whose artifact access
|
|
21
|
+
# only the Computer knows (Computer#adopt_session): #artifact_access is
|
|
22
|
+
# then nil.
|
|
23
|
+
def self.from_payload(payload, artifact_access_known: true)
|
|
21
24
|
return nil unless payload
|
|
22
25
|
browser_payload = payload["browser"]
|
|
23
26
|
new(
|
|
@@ -26,7 +29,8 @@ module PineSandbox
|
|
|
26
29
|
token: payload["token"],
|
|
27
30
|
session_dir: payload["session_dir"],
|
|
28
31
|
browser: browser_payload && Browser.from_payload(browser_payload),
|
|
29
|
-
|
|
32
|
+
# An older Computer doesn't report it; its sessions have no access.
|
|
33
|
+
artifact_access: artifact_access_known ? payload.fetch("artifact_access", "none") : nil,
|
|
30
34
|
created_at: payload["created_at"] && Time.parse(payload["created_at"]),
|
|
31
35
|
last_active_at: payload["last_active_at"] && Time.parse(payload["last_active_at"])
|
|
32
36
|
)
|
|
@@ -34,9 +34,10 @@ module PineSandbox
|
|
|
34
34
|
["cancelAgentV2", "POST", "/v2/sessions/{name}/agent/cancel", :computer, true, false],
|
|
35
35
|
["cancelAuthorRun", "POST", "/v2/sessions/{name}/skills/author/{author_id}/cancel", :computer, false, false],
|
|
36
36
|
["captureCheckpoint", "POST", "/v1/coord/capture", :computer, false, false],
|
|
37
|
-
["claimDownloadOrphan", "POST", "/downloads/orphans/{guid}/claim", :computer, false, false],
|
|
38
37
|
["computerUse", "POST", "/v1/sessions/{name}/computer-use", :computer, false, false],
|
|
38
|
+
["createArtifactLink", "POST", "/v1/artifacts/links", :computer, false, false],
|
|
39
39
|
["createComputer", "POST", "/computer-sandboxes", :control, true, false],
|
|
40
|
+
["createComputerArtifactToken", "POST", "/v1/computers/{computer_id}/artifacts/token", :control, false, false],
|
|
40
41
|
["createDiagnostic", "POST", "/v1/diagnostics", :control, true, true],
|
|
41
42
|
["createSession", "POST", "/sessions", :computer, false, false],
|
|
42
43
|
["deactivateSkill", "POST", "/v1/skills/{name}/deactivate", :computer, false, false],
|
|
@@ -49,7 +50,6 @@ module PineSandbox
|
|
|
49
50
|
["deleteSession", "DELETE", "/sessions/{name}", :computer, true, false],
|
|
50
51
|
["deleteSkillVersion", "DELETE", "/v1/skills/{name}/versions/{version}", :computer, true, false],
|
|
51
52
|
["destroyComputer", "DELETE", "/sandboxes/{sandboxId}", :control, true, false],
|
|
52
|
-
["discardDownloadOrphan", "DELETE", "/downloads/orphans/{guid}", :computer, true, false],
|
|
53
53
|
["getAgentEventsPageV2", "GET", "/v2/sessions/{name}/agent/events/page", :computer, true, false],
|
|
54
54
|
["getAgentEventsV2", "GET", "/v2/sessions/{name}/agent/events", :computer, true, false],
|
|
55
55
|
["getAgentTurnV2", "GET", "/v2/sessions/{name}/agent/turns/{turn_id}", :computer, true, false],
|
|
@@ -74,7 +74,6 @@ module PineSandbox
|
|
|
74
74
|
["listAllSkillVersions", "GET", "/v1/skills/versions", :computer, true, false],
|
|
75
75
|
["listArtifacts", "GET", "/v1/sessions/{name}/artifacts", :computer, true, false],
|
|
76
76
|
["listAvailableLocations", "GET", "/v1/locations", :control, true, false],
|
|
77
|
-
["listDownloadOrphans", "GET", "/downloads/orphans", :computer, true, false],
|
|
78
77
|
["listHandoffs", "GET", "/v1/sessions/{name}/handoffs", :computer, true, false],
|
|
79
78
|
["listOrphans", "GET", "/orphans", :computer, true, false],
|
|
80
79
|
["listPasskeyCeremonies", "GET", "/v1/passkeys/ceremonies", :computer, true, false],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module PineSandbox
|
|
2
|
-
# An observation of
|
|
3
|
-
|
|
2
|
+
# An observation of the started Computer, not its identity or a cached health verdict.
|
|
3
|
+
SandboxLifetime = Data.define(:idle_timeout_seconds, :min_idle_timeout_seconds,
|
|
4
4
|
:max_idle_timeout_seconds, :max_runtime_age_seconds,
|
|
5
5
|
:stop_at, :deadline) do
|
|
6
6
|
def self.from_payload(value)
|
|
@@ -14,9 +14,9 @@ module PineSandbox
|
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
SandboxDetails = Data.define(:id, :state, :reason, :message, :retryable,
|
|
18
18
|
:operation_id, :created_at, :expires_at, :lifecycle, :runtime_revision) do
|
|
19
|
-
def self.
|
|
19
|
+
def self.from_info(info)
|
|
20
20
|
new(id: info.id, state: info.status, reason: info.reason, message: info.message,
|
|
21
21
|
retryable: info.retryable, operation_id: info.operation_id,
|
|
22
22
|
created_at: info.created_at, expires_at: info.expires_at, lifecycle: info.lifecycle, runtime_revision: info.runtime_revision)
|
|
@@ -3,26 +3,25 @@ require_relative "errors"
|
|
|
3
3
|
require_relative "clock"
|
|
4
4
|
|
|
5
5
|
module PineSandbox
|
|
6
|
-
# Lifecycle handle for a Computer
|
|
6
|
+
# Lifecycle handle for a Computer sandbox, backed by the project-scoped
|
|
7
7
|
# ControlPlane. A powered-on Computer holds one of these;
|
|
8
8
|
# Computer#stop/#kill/#alive? delegate here. It carries NO data-plane knowledge —
|
|
9
9
|
# the coordinator host is derived separately from the Zone.
|
|
10
10
|
# @api private
|
|
11
|
-
class
|
|
11
|
+
class SandboxHandle
|
|
12
12
|
READY = :running
|
|
13
13
|
WAIT_FAILURE_STATES = %i[failed terminated unavailable].freeze
|
|
14
14
|
LIVE = %i[running pending stopping].freeze
|
|
15
15
|
DEFAULT_POLL_INTERVAL = 2
|
|
16
16
|
|
|
17
17
|
attr_reader :id
|
|
18
|
-
alias_method :
|
|
18
|
+
alias_method :sandbox_id, :id
|
|
19
19
|
|
|
20
20
|
# `clock` returns monotonic seconds and `sleeper` waits; both default to
|
|
21
|
-
# the control plane's clock.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
raise ArgumentError, "runtime_id required" if id.to_s.empty?
|
|
21
|
+
# the control plane's clock.
|
|
22
|
+
def initialize(control_plane:, sandbox_id: nil, status: nil, clock: nil, sleeper: nil)
|
|
23
|
+
id = sandbox_id
|
|
24
|
+
raise ArgumentError, "sandbox_id required" if id.to_s.empty?
|
|
26
25
|
|
|
27
26
|
source = control_plane.respond_to?(:clock) ? control_plane.clock : PineSandbox::SYSTEM_CLOCK
|
|
28
27
|
@control_plane = control_plane
|
|
@@ -53,13 +52,13 @@ module PineSandbox
|
|
|
53
52
|
end
|
|
54
53
|
|
|
55
54
|
def set_idle_timeout(seconds:)
|
|
56
|
-
observed =
|
|
57
|
-
raise BindError, "
|
|
55
|
+
observed = sandbox_details
|
|
56
|
+
raise BindError, "Computer was started without a lifetime policy; choose the idle timeout on the next Start" unless observed.lifecycle
|
|
58
57
|
|
|
59
58
|
@control_plane.set_idle_timeout(@id, seconds: seconds, etag: observed.etag)
|
|
60
59
|
end
|
|
61
60
|
|
|
62
|
-
def
|
|
61
|
+
def sandbox_details
|
|
63
62
|
@control_plane.get(@id)
|
|
64
63
|
end
|
|
65
64
|
|
|
@@ -101,11 +100,12 @@ module PineSandbox
|
|
|
101
100
|
|
|
102
101
|
# Explicit saved-state recovery discards retained memory. It is allowed
|
|
103
102
|
# only after a recorded failure and is fenced by the observed HTTP ETag.
|
|
104
|
-
def
|
|
103
|
+
def release_failed_sandbox(info, wait_timeout: 90, interval: DEFAULT_POLL_INTERVAL)
|
|
105
104
|
deadline = @clock.call + wait_timeout
|
|
106
105
|
unless info.status == :stopping
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
# Accepts CAPTURE_TIMEOUT as well as CaptureTimeout to unify on SNAKE_CASE while staying backward compatible.
|
|
107
|
+
unless %i[save_failed unavailable failed terminated].include?(info.status) && !%w[CAPTURE_TIMEOUT CaptureTimeout].include?(info.reason)
|
|
108
|
+
raise PineSandbox::ConflictError.new(status: 409, error_message: "The Computer has no recorded failure; use Stop or Resume")
|
|
109
109
|
end
|
|
110
110
|
raise PineSandbox::Error, "Server did not supply a conditional release version" if info.etag.to_s.empty?
|
|
111
111
|
@control_plane.destroy(@id, if_match: info.etag, request_timeout: remaining(deadline))
|
|
@@ -229,8 +229,8 @@ module PineSandbox
|
|
|
229
229
|
end
|
|
230
230
|
return self if state == target
|
|
231
231
|
if WAIT_FAILURE_STATES.include?(state)
|
|
232
|
-
raise PineSandbox::
|
|
233
|
-
"Computer
|
|
232
|
+
raise PineSandbox::SandboxFailedError.new(
|
|
233
|
+
"Computer sandbox #{@id} reported #{state.inspect} before reaching #{target}", sandbox_id: @id, state: state
|
|
234
234
|
)
|
|
235
235
|
end
|
|
236
236
|
end
|
|
@@ -245,8 +245,4 @@ module PineSandbox
|
|
|
245
245
|
end
|
|
246
246
|
|
|
247
247
|
end
|
|
248
|
-
|
|
249
|
-
# Deprecated: use RuntimeHandle.
|
|
250
|
-
SandboxHandle = RuntimeHandle
|
|
251
|
-
deprecate_constant :SandboxHandle
|
|
252
248
|
end
|