pinesandbox 0.0.1 → 0.3.18

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. metadata +155 -10
@@ -0,0 +1,525 @@
1
+ require_relative "error_taxonomy"
2
+
3
+ module PineSandbox
4
+ # Marks a renamed public method: the old name keeps working and, when Ruby's
5
+ # deprecation warnings are on (Warning[:deprecated] = true), warns once per
6
+ # call site with the name to use instead.
7
+ # @api private
8
+ def self.deprecated_name(old, new)
9
+ warn("PineSandbox: #{old} is deprecated; use #{new}", category: :deprecated, uplevel: 2)
10
+ end
11
+
12
+ # Fold a request_id INTO an error message string. The portal/Computer error classes
13
+ # below carry #request_id as an accessor, but integrators overwhelmingly log only
14
+ # `e.message` — so the one identifier that correlates a failure against the server's
15
+ # logs must land in the message too. No-op when there's no id (bind/connection faults
16
+ # that never reached a gateway-fronted response). ApiError folds its context the same way.
17
+ def self.with_request_id(message, request_id)
18
+ return message if request_id.to_s.empty?
19
+
20
+ "#{message} (request_id=#{request_id})"
21
+ end
22
+
23
+ # Fold the RESOURCE-FIRST troubleshooting context INTO an error message: op (WHICH
24
+ # operation — "<METHOD> <path>", carrying the computer/sandbox id where the call is
25
+ # resource-scoped, so it names WHICH resource) then request_id (the secondary precision
26
+ # handle). Each part only when present; no-op when both are absent. Integrators log only
27
+ # `e.message`, so a generic handler must see the operation without extra plumbing.
28
+ def self.with_context(message, op: nil, request_id: nil)
29
+ parts = []
30
+ parts << "op=#{op}" if op && !op.to_s.empty?
31
+ parts << "request_id=#{request_id}" if request_id && !request_id.to_s.empty?
32
+ parts.empty? ? message : "#{message} (#{parts.join(', ')})"
33
+ end
34
+
35
+ # Base for every error this SDK raises.
36
+ class Error < StandardError
37
+ # Set on a run's error when no response arrived (it is then also a
38
+ # RunUnconfirmedError): the run may have started. Send it again with this
39
+ # idempotency_key, which returns the turn it started, or starts it if it
40
+ # never did.
41
+ attr_accessor :idempotency_key
42
+ end
43
+
44
+ # Marks a run whose response never arrived, after the SDK resent it for as
45
+ # long as its budget allows: the run may have started. The error is still
46
+ # the failure that ended the resends (a ConnectionError, TimeoutError or
47
+ # ApiError, so existing rescues keep catching it), extended with this
48
+ # module, so `rescue PineSandbox::RunUnconfirmedError` catches it too. Send
49
+ # the run again with #idempotency_key, which returns the turn it started,
50
+ # or starts it if it never did.
51
+ module RunUnconfirmedError
52
+ def idempotency_key = @idempotency_key
53
+ end
54
+
55
+ # An HTTP error response from the Computer API. #status is the HTTP status;
56
+ # #problem_type and #problem_extensions carry an RFC 9457 problem detail
57
+ # (#field, #reason, #handoff_id, #request_id and #retryable? read its common
58
+ # members). The message is resource-first: the runtime (#runtime_id) and the
59
+ # operation (#op, "<METHOD> <path>") come before #request_id, so a handler
60
+ # that logs only the message can still route a report.
61
+ class ApiError < Error
62
+ attr_reader :status, :error_code, :error_message, :response_body, :problem_type, :problem_extensions
63
+
64
+ def initialize(status:, error_code: nil, error_message: nil, response_body: nil,
65
+ problem_type: nil, problem_extensions: nil)
66
+ @status = status
67
+ @error_code = error_code
68
+ @error_message = redact_credentials(error_message)
69
+ @response_body = redact_credentials(response_body)
70
+ @problem_type = problem_type
71
+ @problem_extensions = problem_extensions || {}
72
+ @built_message = build_message
73
+ super(@built_message)
74
+ end
75
+
76
+ # The runtime that answered, when the request reached one.
77
+ def runtime_id = problem_extensions["sandbox_id"]
78
+
79
+ # Deprecated: use #runtime_id.
80
+ def sandbox_id
81
+ PineSandbox.deprecated_name("ApiError#sandbox_id", "#runtime_id")
82
+ runtime_id
83
+ end
84
+
85
+ # The response's machine `code` ({code, message} bodies), when present.
86
+ def code = error_code
87
+
88
+ def op = problem_extensions["op"]
89
+ def handoff_id = problem_extensions["handoff_id"]
90
+ def field = problem_extensions["field"]
91
+ def reason = problem_extensions["reason"]
92
+ def request_id = problem_extensions["request_id"]
93
+
94
+ # The server's verdict on whether retrying the same operation is sane.
95
+ # When the response doesn't say, the problem type's entry in the shared
96
+ # error taxonomy decides (412 and other 5xx true, 501 and the rest false).
97
+ # A true verdict never by itself makes a mutation safe to send again.
98
+ def retryable?
99
+ wire = problem_extensions["retryable"]
100
+ return wire if wire == true || wire == false
101
+
102
+ ErrorTaxonomy.retryable?(problem_type, status)
103
+ end
104
+
105
+ # The response's Retry-After in seconds, when it set one.
106
+ attr_accessor :retry_after
107
+
108
+ # Folds the runtime and operation onto an error raised before they were
109
+ # known, filling only what the server didn't send, and rebuilds the message.
110
+ # Returns self, for re-raising.
111
+ def attach_context!(runtime_id: nil, op: nil, sandbox_id: nil)
112
+ runtime_id ||= sandbox_id
113
+ problem_extensions["sandbox_id"] ||= runtime_id if runtime_id && !runtime_id.to_s.empty?
114
+ problem_extensions["op"] ||= op if op && !op.to_s.empty?
115
+ @built_message = build_message
116
+ self
117
+ end
118
+
119
+ def message = @built_message
120
+ alias_method :to_s, :message
121
+
122
+ private
123
+
124
+ def build_message
125
+ base =
126
+ if error_message && !error_message.empty?
127
+ error_message
128
+ elsif error_code
129
+ "API request failed with status #{status} (#{error_code})"
130
+ else
131
+ "API request failed with status #{status}"
132
+ end
133
+ parts = []
134
+ parts << "runtime=#{runtime_id}" if runtime_id && !runtime_id.to_s.empty?
135
+ parts << "op=#{op}" if op && !op.to_s.empty?
136
+ parts << "request_id=#{request_id}" if request_id && !request_id.to_s.empty?
137
+ parts.empty? ? base : "#{base} (#{parts.join(', ')})"
138
+ end
139
+
140
+ # A server can echo request headers into an error body, which becomes
141
+ # #response_body and, when it isn't JSON, the message. Callers log both, so a
142
+ # service credential never stays in either.
143
+ def redact_credentials(body)
144
+ return body unless body.is_a?(String)
145
+
146
+ body.gsub(/(X-EXECD-ACCESS-TOKEN["']?\s*[:=]\s*["']?)[^"'\s,}]+/i, '\1[REDACTED]')
147
+ end
148
+ end
149
+
150
+ # The Computer does not serve the public agent protocol. The SDK concludes it
151
+ # from capability discovery, so it has no HTTP status, and the request that
152
+ # led to discovery is not retried against an older protocol. This is not an
153
+ # API conflict and must never trigger conflict recovery or automatic steering.
154
+ class AgentProtocolUnsupported < Error
155
+ def initialize
156
+ super("This Computer does not support the required public agent protocol. Stop this Computer, then Start to update it.")
157
+ end
158
+ end
159
+
160
+ # Status tiers: rescue these whether or not the response named a problem type.
161
+ class BadRequestError < ApiError; end
162
+ class UnauthorizedError < ApiError; end
163
+ class ForbiddenError < ApiError; end
164
+ class NotFoundError < ApiError; end
165
+ class ConflictError < ApiError; end
166
+ class GoneError < ApiError; end
167
+ class PreconditionFailedError < ApiError; end
168
+ class UnprocessableEntityError < ApiError; end
169
+ class PreconditionRequiredError < ApiError; end
170
+ class ServerError < ApiError; end
171
+ class ServiceUnavailableError < ServerError; end
172
+
173
+ # Typed problem details, each under its status tier so tier rescues still
174
+ # catch them.
175
+ class ControlNotHeld < ConflictError; end
176
+ # The Computer's access lapsed and Pine refused to renew it (the project is
177
+ # suspended or out of credits): control is cut, reads stay open. Re-attach,
178
+ # or report the suspension.
179
+ class LeaseExpired < ForbiddenError; end
180
+ # The access lapsed and renewing it failed for now. Retry; don't re-attach.
181
+ class LeaseRefreshUnavailable < ServiceUnavailableError; end
182
+ class AnotherSessionHuman < ConflictError; end
183
+ class ConcurrentTransition < ConflictError; end
184
+ class InvalidTransition < UnprocessableEntityError; end
185
+ class ActorTypeRequired < BadRequestError; end
186
+ class InvalidField < BadRequestError; end
187
+ class EmptyPatch < BadRequestError; end
188
+ class MalformedJSON < BadRequestError; end
189
+ class SessionNotFound < NotFoundError; end
190
+ class DesktopBindFailed < ServiceUnavailableError; end
191
+ class HandoffNotFound < NotFoundError; end
192
+ class HandoffUnavailable < ConflictError; end
193
+ class HandoffNotHuman < NotFoundError; end
194
+ class HandoffExpired < GoneError; end
195
+ class HandoffPersistenceFailed < ServerError; end
196
+
197
+ # Problem `type` → its typed error; anything else falls back to the status tier.
198
+ PROBLEM_TYPE_CLASSES = {
199
+ "/errors/control-not-held" => ControlNotHeld,
200
+ "/errors/lease-expired" => LeaseExpired,
201
+ "/errors/lease-refresh-unavailable" => LeaseRefreshUnavailable,
202
+ "/errors/another-session-human" => AnotherSessionHuman,
203
+ "/errors/concurrent-transition" => ConcurrentTransition,
204
+ "/errors/invalid-transition" => InvalidTransition,
205
+ "/errors/actor-type-required" => ActorTypeRequired,
206
+ "/errors/invalid-field" => InvalidField,
207
+ "/errors/empty-patch" => EmptyPatch,
208
+ "/errors/malformed-json" => MalformedJSON,
209
+ "/errors/session-not-found" => SessionNotFound,
210
+ "/errors/desktop-bind-failed" => DesktopBindFailed,
211
+ "/errors/handoff-not-found" => HandoffNotFound,
212
+ "/errors/handoff-unavailable" => HandoffUnavailable,
213
+ "/errors/handoff-not-human" => HandoffNotHuman,
214
+ "/errors/handoff-expired" => HandoffExpired,
215
+ "/errors/handoff-persistence-failed" => HandoffPersistenceFailed,
216
+ "/errors/precondition-failed" => PreconditionFailedError,
217
+ "/errors/precondition-required" => PreconditionRequiredError,
218
+ "/errors/unauthorized" => UnauthorizedError,
219
+ "/errors/forbidden" => ForbiddenError
220
+ }.freeze
221
+
222
+ def self.problem_class_for(problem_type) = PROBLEM_TYPE_CLASSES[problem_type]
223
+
224
+ # Failures with no HTTP response, or a timeout status (408/504) without a
225
+ # problem body. #retry_after is the response's Retry-After in seconds, when
226
+ # a timeout status set one.
227
+ class TimeoutError < Error
228
+ attr_accessor :retry_after
229
+ end
230
+ class ConnectionError < Error; end
231
+ class ReadyTimeoutError < Error; end
232
+
233
+ # The runtime reported failed, terminated or unavailable before the
234
+ # lifecycle operation finished.
235
+ class RuntimeFailedError < Error
236
+ attr_reader :runtime_id, :state
237
+
238
+ def initialize(message = nil, runtime_id: nil, state: nil)
239
+ @runtime_id = runtime_id
240
+ @state = state
241
+ super(message || "Computer runtime #{runtime_id} reported #{state.inspect} before completing the lifecycle operation")
242
+ end
243
+ end
244
+ # Deprecated: use RuntimeFailedError.
245
+ SandboxFailedError = RuntimeFailedError
246
+ deprecate_constant :SandboxFailedError
247
+
248
+ class StreamError < Error; end
249
+ class StreamingUnsupportedError < Error; end
250
+
251
+ # An event stream could not be re-established: it reconnected
252
+ # RetryPolicy::STREAM_RECONNECT_BUDGET times in a row without delivering an
253
+ # event. #cause is the last failure, when there was one. Reopen it with the
254
+ # last event id you processed. A ConnectionError, so connection rescues
255
+ # keep catching it.
256
+ class StreamLostError < ConnectionError
257
+ attr_reader :op, :request_id
258
+
259
+ def initialize(op: nil, request_id: nil, cause: nil)
260
+ @op = op
261
+ @request_id = request_id
262
+ @stream_cause = cause
263
+ detail = cause ? ": #{cause.message}" : ""
264
+ super(PineSandbox.with_context("event stream lost (reconnect budget exhausted)#{detail}", op: op, request_id: request_id))
265
+ end
266
+
267
+ def cause = @stream_cause || super
268
+ end
269
+
270
+
271
+ # The source binding is retained; no sandbox deletion follows this error.
272
+ class StopSaveError < Error
273
+ attr_reader :save_error
274
+
275
+ def initialize(error, retryable: nil)
276
+ @save_error = error
277
+ @retryable = retryable
278
+ super("Computer stop could not confirm saved state: #{error.message}")
279
+ end
280
+
281
+ def problem_type = save_error.respond_to?(:problem_type) ? save_error.problem_type : "/errors/stop-failed"
282
+ def retryable? = @retryable.nil? ? (save_error.respond_to?(:retryable?) && save_error.retryable?) : @retryable
283
+ end
284
+
285
+ # BindError and its subclasses are the typed outcomes of Computer#attach.
286
+ # `#status` is the HTTP status when one is available; `#cause` is the
287
+ # underlying ApiError, so callers can inspect the raw response if needed.
288
+ class BindError < Error
289
+ attr_reader :status, :cause
290
+
291
+ def initialize(message, status: nil, cause: nil)
292
+ @status = status
293
+ @cause = cause
294
+ super(message)
295
+ end
296
+ end
297
+
298
+ # Start found an existing runtime. No replacement was allocated. Preserve the
299
+ # BindError superclass for integrations already rescuing Start conflicts.
300
+ class RuntimeRetainedError < BindError
301
+ attr_reader :runtime_id, :state
302
+
303
+ def initialize(runtime_id:, state:)
304
+ @runtime_id, @state = runtime_id, state
305
+ super("Computer still has runtime #{runtime_id} (#{state}); Stop it or Resume retained memory before Start")
306
+ end
307
+ end
308
+
309
+ # ControlTokenError is the typed outcome of exchanging a `pk_` client key at
310
+ # the portal's POST /v1/control-token (ControlTokenSource).
311
+ # `#status`/`#body`/`#request_id` carry the portal response for diagnosis;
312
+ # `#op` is "<METHOD> <path>" (WHICH portal call — the primary spine); `#retry_after`
313
+ # is the parsed Retry-After (seconds) on a RateLimited.
314
+ class ControlTokenError < Error
315
+ attr_reader :status, :body, :request_id, :op, :retry_after
316
+
317
+ def initialize(message, status: nil, body: nil, request_id: nil, op: nil, retry_after: nil)
318
+ @status = status
319
+ @body = body
320
+ @request_id = request_id
321
+ @op = op
322
+ @retry_after = retry_after
323
+ super(PineSandbox.with_context(message, op: op, request_id: request_id))
324
+ end
325
+ end
326
+
327
+ # 401 — the pk_ key is missing, malformed, or unknown, raised by the
328
+ # control-token mint. (The attach-credentials flow reports its own 401 as an
329
+ # AttachCredentialsError with #status == 401, so a `rescue AttachCredentialsError`
330
+ # around attach stays complete.)
331
+ class InvalidClientKey < ControlTokenError; end
332
+
333
+ # 403 — the key is valid but its scope may not mint control-plane tokens
334
+ # (needs pk_session or pk_admin).
335
+ class InsufficientScope < ControlTokenError; end
336
+
337
+ # 429 — the portal rate-limited the mint. `#retry_after` is honored on retry.
338
+ class RateLimited < ControlTokenError; end
339
+
340
+ # Base error for the pk_-authenticated Portal machine API. #op is
341
+ # "<METHOD> <path>" and #request_id is the secondary precision handle.
342
+ class PortalError < Error
343
+ attr_reader :status, :body, :request_id, :op, :problem_type, :reason, :retry_after
344
+
345
+ def initialize(message, status: nil, body: nil, request_id: nil, op: nil,
346
+ problem_type: nil, reason: nil, retry_after: nil)
347
+ @status = status
348
+ @body = body
349
+ @request_id = request_id
350
+ @op = op
351
+ @problem_type = problem_type
352
+ @reason = reason
353
+ @retry_after = retry_after
354
+ super(PineSandbox.with_context(message, op: op, request_id: request_id))
355
+ end
356
+ end
357
+
358
+ # AttachCredentialsError + subclasses are the typed outcomes of optional
359
+ # pre-registration and per-attach credential minting.
360
+ class AttachCredentialsError < PortalError; end
361
+
362
+ # A failed project-authenticated read of the available location catalog.
363
+ class LocationDiscoveryError < PortalError; end
364
+
365
+ # 404 — the computer_id is deleted or owned by another project. A genuinely
366
+ # new id is lazily recorded during first attach.
367
+ class UnknownComputerError < AttachCredentialsError; end
368
+
369
+ # 409/422 — registration refused: the computer_id is taken by another
370
+ # project, tombstoned, or malformed.
371
+ class ComputerRegistrationError < AttachCredentialsError; end
372
+
373
+ # 422 — deletion refused: the computer_id is malformed (not a lowercase
374
+ # UUIDv7). Also raised when a custom attach credentials provider cannot
375
+ # delete. Unknown, deleted, and cross-project ids are not errors (204).
376
+ class ComputerDeletionError < AttachCredentialsError; end
377
+
378
+ # 403 — the key/project/computer is validly identified but may not mint attach
379
+ # credentials now (scope, project disabled, key revoked, or computer disabled).
380
+ class ProjectAccessDenied < AttachCredentialsError; end
381
+
382
+ # 412 — another attach advanced the authorization revision. Terminal:
383
+ # reload/adopt the winning integrator database binding; do not allocate a runtime.
384
+ class BindingRevisionConflictError < AttachCredentialsError
385
+ attr_reader :current_binding_revision, :current_runtime_id
386
+
387
+ def initialize(message, current_binding_revision: nil, current_runtime_id: nil, current_sandbox_id: nil, **kwargs)
388
+ @current_binding_revision = current_binding_revision
389
+ @current_runtime_id = current_runtime_id || current_sandbox_id
390
+ super(message, **kwargs)
391
+ end
392
+
393
+ # The winning binding's revision (also #current_binding_revision).
394
+ 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
+ end
402
+
403
+ # Portal committed a new authorization revision, but the later bind
404
+ # failed. The SDK has cleaned up the fresh runtime best-effort. Persist the
405
+ # revision before retrying so the next attach can supersede this attempt.
406
+ # #credentials is present for create_computer, whose generated identity and
407
+ # capture keypair would otherwise be lost because the method has no result.
408
+ class AttachAuthorizationCommittedError < BindError
409
+ attr_reader :binding_revision, :runtime_id, :credentials
410
+
411
+ def initialize(message, binding_revision:, runtime_id: nil, sandbox_id: nil, credentials: nil, **kwargs)
412
+ @binding_revision = binding_revision
413
+ @runtime_id = runtime_id || sandbox_id
414
+ @credentials = credentials
415
+ super(message, **kwargs)
416
+ 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
+ end
424
+
425
+ # The Computer answered with an `X-Computer-Spec-Version` major this SDK
426
+ # doesn't support. Fail fast — the SDK pins a major.
427
+ class SpecVersionMismatch < Error
428
+ attr_reader :supported, :served
429
+
430
+ def initialize(message, supported: nil, served: nil)
431
+ @supported = supported
432
+ @served = served
433
+ super(message)
434
+ end
435
+ end
436
+
437
+ # The Computer rejected the portal-issued bind_token: bad signature, unknown
438
+ # kid, iss/aud mismatch, expired, replayed jti, or issued for another runtime.
439
+ # Terminal — retrying with the same token cannot succeed.
440
+ class BindAuthError < BindError; end
441
+
442
+ # Another runtime holds the epoch lease for this Computer (a live attach
443
+ # exists elsewhere, or you are attaching from two places at once).
444
+ class ComputerAlreadyAttachedError < BindError; end
445
+
446
+ # The allocated runtime can't be bound (it was already bound, or a prior
447
+ # bind failed on it). The SDK deletes it before raising; retry attach for a
448
+ # fresh runtime.
449
+ class RuntimeUnusableError < BindError; end
450
+ # Deprecated: use RuntimeUnusableError.
451
+ PodPoisonedError = RuntimeUnusableError
452
+ deprecate_constant :PodPoisonedError
453
+
454
+ # Saved state was unreachable during the bind; the runtime has been
455
+ # cleaned up. Retry attach later (transient).
456
+ class SavedStateUnavailableError < BindError; end
457
+ # Deprecated: use SavedStateUnavailableError.
458
+ BrokerUnreachableError = SavedStateUnavailableError
459
+ deprecate_constant :BrokerUnreachableError
460
+
461
+ # The bind handshake exhausted its retries without a terminal
462
+ # classification. Usually a runtime-allocation or network issue.
463
+ class BindTimeoutError < BindError; end
464
+
465
+ # The Computer returned 401 for its Computer token (ct_) — a REPORT that
466
+ # 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 live,
468
+ # current runtime this state is binding_auth_lost: surface it and
469
+ # reconcile; only confirmed runtime-gone evidence justifies an attach
470
+ # (a fresh runtime invalidates every ps_ and fences the old runtime's state).
471
+ # A 401 for a session token (ps_) is an ordinary UnauthorizedError.
472
+ class TokenRejectedError < BindError
473
+ MESSAGE = "the Computer rejected the computer token (401). ct_ is binding-lifetime, so on a live " \
474
+ "current runtime this is binding_auth_lost — reconcile; re-attach ONLY on confirmed " \
475
+ "runtime-gone evidence (a fresh runtime invalidates every ps_)".freeze
476
+
477
+ # The rejection of `api_error` (a 401 answer to a Computer token), with
478
+ # its operation and request id folded into the message.
479
+ def self.for_api_error(api_error)
480
+ new(PineSandbox.with_context(MESSAGE, op: api_error.op, request_id: api_error.request_id),
481
+ status: 401, cause: api_error)
482
+ end
483
+ end
484
+
485
+ # The gateway can no longer route this Computer's attached runtime. This is
486
+ # distinct from an /errors/session-not-found response on a live
487
+ # Computer. Inherit the legacy 404 tier so existing NotFoundError rescues
488
+ # remain valid; the original typed ApiError is retained as Exception#cause.
489
+ class RuntimeGoneError < NotFoundError
490
+ def initialize(api_error)
491
+ detail = api_error.error_message.to_s
492
+ message = "the attached runtime is gone"
493
+ message = "#{message}: #{detail}" unless detail.empty?
494
+ super(
495
+ status: api_error.status,
496
+ error_code: api_error.error_code,
497
+ error_message: message,
498
+ response_body: api_error.response_body,
499
+ problem_type: api_error.problem_type,
500
+ problem_extensions: api_error.problem_extensions.dup
501
+ )
502
+ self.retry_after = api_error.retry_after
503
+ end
504
+ end
505
+ # Deprecated: use RuntimeGoneError.
506
+ SandboxGoneError = RuntimeGoneError
507
+ deprecate_constant :SandboxGoneError
508
+
509
+ # 429 COMPUTER_CAPACITY_EXCEEDED on create/attach: the project is at its
510
+ # concurrent Computer limit, so nothing was provisioned. Not retried by the
511
+ # SDK — capacity frees only when another of the project's Computers stops.
512
+ # An ApiError, so existing generic rescues keep catching it.
513
+ class CapacityExceededError < ApiError
514
+ CODE = "COMPUTER_CAPACITY_EXCEEDED".freeze
515
+ end
516
+
517
+ # 429 COMPUTER_CREATE_RATE_LIMITED on create: the project has started as many
518
+ # new Computers in the last minute as it may, so nothing was provisioned.
519
+ # #retry_after is the server's Retry-After in seconds (nil when absent). Not
520
+ # retried by the SDK; the caller decides when to create again. An ApiError,
521
+ # so existing generic rescues keep catching it.
522
+ class CreateRateLimitedError < ApiError
523
+ CODE = "COMPUTER_CREATE_RATE_LIMITED".freeze
524
+ end
525
+ end
@@ -0,0 +1,90 @@
1
+ require "json"
2
+ require_relative "sse_frame_parser"
3
+
4
+ module PineSandbox
5
+ # Decodes a command's output stream (Session#exec) into Event structs. The
6
+ # stream is framed by the shared SseFrameParser: each frame's `data:` is
7
+ # one JSON event whose `type` names it. A frame without data, or whose data
8
+ # is not a JSON object, is skipped; a block cut off at the end of the stream
9
+ # is discarded.
10
+ class ExecStreamParser
11
+ Event = Struct.new(
12
+ :type,
13
+ :id,
14
+ :data,
15
+ :text,
16
+ :execution_count,
17
+ :execution_time,
18
+ :timestamp,
19
+ :results,
20
+ :error,
21
+ :raw,
22
+ keyword_init: true
23
+ )
24
+
25
+ SUPPORTED_EVENT_TYPES = %i[
26
+ init
27
+ stdout
28
+ stderr
29
+ execution_complete
30
+ error
31
+ result
32
+ ping
33
+ ].freeze
34
+
35
+ def initialize(&block)
36
+ @frames = SseFrameParser.new
37
+ @callback = block
38
+ end
39
+
40
+ def parse(chunk, &block)
41
+ callback = block || @callback
42
+ raise ArgumentError, "a block is required to receive exec events" unless callback
43
+
44
+ @frames.feed(chunk) { |frame| dispatch(frame, callback) }
45
+ end
46
+
47
+ # The end of the stream: a trailing block without its blank line is dropped.
48
+ def finish(&_block)
49
+ @frames.finish
50
+ end
51
+
52
+ private
53
+
54
+ def dispatch(frame, callback)
55
+ return if frame.data.empty?
56
+
57
+ parsed = begin
58
+ JSON.parse(frame.data)
59
+ rescue JSON::ParserError
60
+ nil
61
+ end
62
+ return unless parsed.is_a?(Hash)
63
+
64
+ callback.call(
65
+ Event.new(
66
+ type: extract_type(parsed, frame.event == "message" ? nil : frame.event),
67
+ id: frame.id,
68
+ data: parsed,
69
+ text: parsed["text"],
70
+ execution_count: extract_value(parsed, "execution_count", "executionCount"),
71
+ execution_time: extract_value(parsed, "execution_time", "executionTime"),
72
+ timestamp: parsed["timestamp"],
73
+ results: parsed["results"],
74
+ error: parsed["error"],
75
+ raw: frame.data
76
+ )
77
+ )
78
+ end
79
+
80
+ def extract_type(parsed, fallback)
81
+ normalized = (parsed["type"] || fallback).to_s.tr("-", "_")
82
+ normalized.empty? ? nil : normalized.downcase.to_sym
83
+ end
84
+
85
+ def extract_value(parsed, *keys)
86
+ keys.each { |key| return parsed[key] if parsed.key?(key) }
87
+ nil
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,51 @@
1
+ require "time"
2
+
3
+ module PineSandbox
4
+ module Models
5
+ # A session artifact's metadata — a managed session output (a client upload,
6
+ # a claimed browser download, or an agent task output) with a download
7
+ # endpoint. Returned by {PineSandbox::Session#list_artifacts} and
8
+ # {PineSandbox::Session#upload_artifact}. Mirrors the spec `Artifact` schema.
9
+ Artifact = Struct.new(
10
+ :id,
11
+ :session_id,
12
+ :turn_id,
13
+ :root,
14
+ :relative_path,
15
+ :filename,
16
+ :content_type,
17
+ :size,
18
+ :sha256,
19
+ :created_by,
20
+ :created_at,
21
+ :retention,
22
+ keyword_init: true
23
+ ) do
24
+ def self.from_payload(payload)
25
+ # filename is the human name WITH extension (basename of the id-prefixed
26
+ # relative_path) — display/download by this, not `id`. Derive it if an
27
+ # older Computer omits it.
28
+ relative_path = payload["relative_path"]
29
+ new(
30
+ id: payload["id"],
31
+ session_id: payload["session_id"],
32
+ turn_id: payload["turn_id"],
33
+ root: payload["root"],
34
+ relative_path: relative_path,
35
+ filename: payload["filename"] || (relative_path && !relative_path.empty? ? File.basename(relative_path) : nil),
36
+ content_type: payload["content_type"],
37
+ size: payload["size"],
38
+ sha256: payload["sha256"],
39
+ created_by: payload["created_by"],
40
+ created_at: payload["created_at"] && Time.parse(payload["created_at"]),
41
+ retention: payload["retention"]
42
+ )
43
+ end
44
+
45
+ # True for an agent-produced (task output) artifact.
46
+ def agent?
47
+ created_by == "agent"
48
+ end
49
+ end
50
+ end
51
+ end