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.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- 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
|