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
data/README.md
CHANGED
|
@@ -1,6 +1,670 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PineSandbox Computer SDK (Ruby)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
`pinesandbox` is the Ruby server SDK for Pine Computers. Create and reconnect
|
|
4
|
+
Computers, run agent tasks, manage files, and authorize desktop access from
|
|
5
|
+
your backend. Stop/Start restores saved cookies, learned skills, and localStorage.
|
|
6
|
+
Pause/Resume retains live tabs, sessions, and memory when enabled for your project.
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
See the [integration guide](https://pinesandbox.io/docs/integration) for setup,
|
|
9
|
+
authentication, and examples. This README covers the Ruby API.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Admitted users download the versioned Ruby SDK bundle from
|
|
14
|
+
<https://pinesandbox.io/sdks/ruby>. Extract it under `vendor/`, then reference
|
|
15
|
+
the bundle from your `Gemfile`:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "pinesandbox", path: "vendor/pinesandbox-ruby-sdk-<version>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use the directory name of the bundle you extracted; the download page shows the
|
|
22
|
+
exact line for the latest release.
|
|
23
|
+
|
|
24
|
+
No GitHub account or package token is required. See the
|
|
25
|
+
[integration guide §Install](https://pinesandbox.io/docs/integration#1-install)
|
|
26
|
+
for the download and extraction commands.
|
|
27
|
+
|
|
28
|
+
The current beta SDK is on the `0.3` compatibility line.
|
|
29
|
+
|
|
30
|
+
## Create a Computer
|
|
31
|
+
|
|
32
|
+
A **Computer** is the identity and saved browser state you keep. A **runtime** is
|
|
33
|
+
one running allocation of it. A **session** is work inside that runtime.
|
|
34
|
+
|
|
35
|
+
The examples use `store` for your application's encrypted database; it is not an
|
|
36
|
+
SDK object. Save credentials **before** Create, then save the returned binding.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "pinesandbox"
|
|
40
|
+
|
|
41
|
+
client = PineSandbox::Client.new(
|
|
42
|
+
api_key: ENV.fetch("PINE_SANDBOX_API_KEY"),
|
|
43
|
+
endpoint: ENV.fetch("PINE_SANDBOX_ENDPOINT")
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
credentials = PineSandbox::Computer.generate_credentials
|
|
47
|
+
store.save_credentials(credentials.id, credentials.to_json)
|
|
48
|
+
|
|
49
|
+
computer = client.create_computer(credentials)
|
|
50
|
+
store.save_binding(computer.id, computer.binding.to_json)
|
|
51
|
+
|
|
52
|
+
session = computer.create_session(name: "main", browser: true)
|
|
53
|
+
session.create_tab(url: "https://example.com")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The two records have different lifetimes:
|
|
57
|
+
|
|
58
|
+
| Save | When | Used for |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `credentials.to_json` | Before Create; again if you change keys | Starting this Computer again and decrypting its saved state |
|
|
61
|
+
| `computer.binding.to_json` | After each successful Create or Start | Reconnecting to that exact runtime from any backend |
|
|
62
|
+
|
|
63
|
+
Both contain secrets. Keep them encrypted on your backend, never in a browser.
|
|
64
|
+
For a one-off script, `client.with_computer { |computer| … }` creates and stops the
|
|
65
|
+
Computer for you, raising if Stop cannot be confirmed.
|
|
66
|
+
|
|
67
|
+
## Reuse it from another backend
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
credentials = PineSandbox::Computer::Credentials.from_json(store.credentials(computer_id))
|
|
71
|
+
binding = PineSandbox::Computer::Binding.from_json(store.binding(computer_id))
|
|
72
|
+
computer = client.reconnect_computer(credentials, binding: binding)
|
|
73
|
+
|
|
74
|
+
state = computer.status # e.g. :running, :paused, :resuming
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Reconnect reconstructs a handle locally. It makes no request, creates nothing,
|
|
78
|
+
and does not replay work. Its next call targets the saved runtime, even if another
|
|
79
|
+
backend has since replaced it. Store the binding revision alongside its JSON in
|
|
80
|
+
your existing Computer record. Save a replacement only if its revision is newer
|
|
81
|
+
(or it is the identical binding); a delayed reply must not overwrite a newer one.
|
|
82
|
+
After confirmed Stop, clear the binding only if the stored revision and runtime
|
|
83
|
+
still match the one stopped. Keep that revision and the durable credentials.
|
|
84
|
+
Session records belong to a binding revision and cannot be reused after Start.
|
|
85
|
+
|
|
86
|
+
To continue a session, also save its name and token when you create it, then use
|
|
87
|
+
`computer.adopt_session(saved_name, saved_token)`. Session lookup does not return
|
|
88
|
+
the token. Save agent turn IDs and event cursors alongside that session so another
|
|
89
|
+
worker can wait for existing work rather than submit it again. See
|
|
90
|
+
[Activity](#activity) for reconnecting event streams.
|
|
91
|
+
|
|
92
|
+
## Stop, Start, Pause or Resume
|
|
93
|
+
|
|
94
|
+
| Intent | Call | Result |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| Finish using compute | `computer.stop` | Saves supported state, releases the runtime and ends its sessions |
|
|
97
|
+
| Start a stopped Computer | `client.start_computer(credentials)` | Restores saved state into a new runtime; create new sessions |
|
|
98
|
+
| Keep live memory for later | `computer.pause` | Retains the runtime and sessions; requires project opt-in |
|
|
99
|
+
| Continue paused work | `computer.resume` | Restores the retained runtime and sessions |
|
|
100
|
+
| Observe after a connection failure | `computer.status` | Reads state without changing or renewing it |
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
raise PineSandbox::TimeoutError, "Stop is unconfirmed" unless computer.stop
|
|
104
|
+
|
|
105
|
+
# Later, at a safe boundary chosen by your application:
|
|
106
|
+
computer = client.start_computer(credentials)
|
|
107
|
+
store.save_binding(computer.id, computer.binding.to_json)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Stop/Start is also how an application replaces an old runtime. An SDK upgrade or
|
|
111
|
+
Pine image rollout does not replace a running Computer. Start can receive a
|
|
112
|
+
compatible older warm revision during rollout; it does not guarantee a build.
|
|
113
|
+
|
|
114
|
+
`attach_computer` remains an alias for Start. **Use Reconnect for an existing
|
|
115
|
+
runtime.** A failed Resume does not fall back to Start. Stop/Start restores saved
|
|
116
|
+
browser state, not live tabs, session files or page memory. Export files first.
|
|
117
|
+
[Persistence and teardown](#persistence--teardown) covers completion, recovery and
|
|
118
|
+
permanent deletion.
|
|
119
|
+
|
|
120
|
+
## Idle timeout and maximum age
|
|
121
|
+
|
|
122
|
+
`timeout:` is the inactivity window (default five minutes), not an HTTP request
|
|
123
|
+
timeout. Work renews it; reading status, watching events and other observation do
|
|
124
|
+
not. It starts at allocation, so a one-minute window can expire during slow setup.
|
|
125
|
+
|
|
126
|
+
Projects can opt into a lifetime policy. The standard allowance is one minute to
|
|
127
|
+
24 hours of idle time and seven days of maximum runtime age; approved project
|
|
128
|
+
limits can be higher. Existing allocations retain their admitted terms.
|
|
129
|
+
Enrollment applies to new allocations from Ruby 0.3.13 / Go 0.3.15 and later,
|
|
130
|
+
including integrations that do not use the new policy methods. A requested
|
|
131
|
+
timeout above the allowance, including the SDK default, is rejected. Before
|
|
132
|
+
enrolling a project, check its integrations' requested timeouts and handling of
|
|
133
|
+
maximum age. Deployment alone does not enroll projects or alter existing runtimes.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
details = computer.runtime_details
|
|
137
|
+
if details.lifecycle
|
|
138
|
+
updated = computer.set_idle_timeout(seconds: 3600)
|
|
139
|
+
puts updated.lifecycle.idle_timeout_seconds
|
|
140
|
+
puts updated.expires_at # effective expiry; maximum age is unchanged
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For an enrolled runtime, changing idle timeout takes effect now while running,
|
|
145
|
+
or on the next Resume while paused. It cannot extend maximum age or snapshot
|
|
146
|
+
retention. Legacy runtimes keep their previous expiry clock while paused; enrolled
|
|
147
|
+
runtimes use snapshot retention and maximum age. An identical value does nothing.
|
|
148
|
+
After a conflict or lost response,
|
|
149
|
+
read `runtime_details` before deciding whether to edit again.
|
|
150
|
+
|
|
151
|
+
Maximum age includes paused time. Graceful Stop begins twelve minutes before the
|
|
152
|
+
hard deadline; expiry is not proof that a final save succeeded. Plan Stop/Start
|
|
153
|
+
at a safe point before that deadline when you need uninterrupted work.
|
|
154
|
+
|
|
155
|
+
`runtime_details` describes the current allocation, including optional lifecycle
|
|
156
|
+
terms and its opaque `runtime_revision`. Missing lifecycle means the runtime
|
|
157
|
+
has no admitted project lifetime policy. `client.project_policy` optionally reads
|
|
158
|
+
allowances for future allocations; ordinary operations do not require that call.
|
|
159
|
+
|
|
160
|
+
`ready_timeout:` bounds allocation/readiness and `bind_ready_timeout:` bounds the
|
|
161
|
+
following handshake. They do not change runtime expiry. For example,
|
|
162
|
+
`timeout: 28_800, ready_timeout: 120` requests eight hours of idle time with a
|
|
163
|
+
two-minute readiness wait. These waits are separate from transport retries and
|
|
164
|
+
cleanup; see the [integration guide](https://pinesandbox.io/docs/integration).
|
|
165
|
+
|
|
166
|
+
## Handle failures without replacing live work
|
|
167
|
+
|
|
168
|
+
- A network error or `alive? == false` does not prove the runtime is gone.
|
|
169
|
+
Read `status`; its `NotFoundError` confirms runtime-record absence. A
|
|
170
|
+
data-plane `RuntimeGoneError` alone does not: paused/restoring Computers can
|
|
171
|
+
temporarily have no route.
|
|
172
|
+
- `RuntimeRetainedError` (a `BindError`) means Start found an existing runtime.
|
|
173
|
+
Match its `runtime_id` to your saved binding, or reload the latest binding from
|
|
174
|
+
your database. Reconnect, Resume or finish Stop as appropriate; an ID alone
|
|
175
|
+
cannot reconstruct a missing token.
|
|
176
|
+
- A Stop timeout means completion is unconfirmed, not canceled. Keep the binding
|
|
177
|
+
and observe or retry Stop. Older servers report `StopSaveError` for a failed or
|
|
178
|
+
unconfirmed save; current servers still release the runtime.
|
|
179
|
+
- Serialize Create/Start per Computer in your application. On
|
|
180
|
+
`BindingRevisionConflictError`, reload the winner's saved binding instead of
|
|
181
|
+
retrying with a higher revision.
|
|
182
|
+
- If a registered Computer's Start reply is lost before saving its binding,
|
|
183
|
+
serialize recovery, call `client.stop_computer(id)`, then Start separately with
|
|
184
|
+
the saved credentials. This releases the runtime whose token was lost.
|
|
185
|
+
- An interrupted first Create can fail before registering the Computer. Stop-by-ID
|
|
186
|
+
then returns true, but Start raises `UnknownComputerError`. For that initial
|
|
187
|
+
creation, retry Create with your saved credentials after confirming the project
|
|
188
|
+
and ID are correct and the identity was not deleted. Do not treat every unknown
|
|
189
|
+
ID as permission to create. An allocation abandoned before registration may
|
|
190
|
+
remain until expiry; Stop-by-ID cannot discover it.
|
|
191
|
+
- On `AttachAuthorizationCommittedError`, registration has succeeded. Retain its
|
|
192
|
+
non-nil credentials and reconcile the registered Computer before Start. Private
|
|
193
|
+
keys cannot be recovered from an ID.
|
|
194
|
+
|
|
195
|
+
Allocation retries within one call reuse its create ID. A fresh call is a new
|
|
196
|
+
attempt. Failed attach attempts clean up known allocations best-effort; process
|
|
197
|
+
crashes rely on expiry. Longer requested timeouts can leave abandoned compute
|
|
198
|
+
running longer. See [Errors](#errors) for the error types.
|
|
199
|
+
|
|
200
|
+
## Stored credentials and older integrations
|
|
201
|
+
|
|
202
|
+
The project API key stays on your backend. Computer credentials contain the state
|
|
203
|
+
key, current and retained capture keys, prior state keys and ephemeral mode.
|
|
204
|
+
Bindings contain the runtime ID, Computer token and binding revision. The SDK
|
|
205
|
+
handles short-lived project and attach authorization internally.
|
|
206
|
+
|
|
207
|
+
`computer.credentials` returns an independent copy. Credentials and bindings use
|
|
208
|
+
versioned JSON shared with the Go SDK. Normal `inspect`/`to_s` output redacts
|
|
209
|
+
secrets; their explicit JSON is for encrypted storage and **does contain secrets**.
|
|
210
|
+
|
|
211
|
+
For integrations using older storage or field-based methods:
|
|
212
|
+
|
|
213
|
+
- Use `to_json` / `from_json` for durable records. `to_h` contains raw Ruby values.
|
|
214
|
+
If upgrading from a credential struct with fewer members, convert native
|
|
215
|
+
`Marshal` dumps with that older SDK before upgrading.
|
|
216
|
+
- `adopt_existing` remains supported, but its field-based handle lacks capture
|
|
217
|
+
keypairs. Retain the original complete credentials; prefer `reconnect_computer`
|
|
218
|
+
when both saved records are available.
|
|
219
|
+
- Starting from a credentials value requires custom credential providers to
|
|
220
|
+
implement `current_binding` and `credentials`. Bare callables still support
|
|
221
|
+
Create and the field-based Attach form with an explicit binding revision.
|
|
222
|
+
- `control_plane` and sandbox handles are SDK internals. Integrations use the
|
|
223
|
+
Computer methods above.
|
|
224
|
+
|
|
225
|
+
## Sessions
|
|
226
|
+
|
|
227
|
+
A session is the unit of work — a browser context + bash terminal. Create one, then
|
|
228
|
+
drive it (next two sections). A Computer runs up to 3 sessions at once by default;
|
|
229
|
+
`computer.capabilities` reports the limit (`sessions.concurrent`).
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
session = computer.create_session(name: "researcher-1", browser: true)
|
|
233
|
+
computer.session("researcher-1") # metadata by name; token is redacted
|
|
234
|
+
computer.sessions # list all on this Computer
|
|
235
|
+
computer.destroy_session("researcher-1") # tear down (keeps on-disk dir; clean: true to GC)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Drive — Pine's agent (hand off the loop)
|
|
239
|
+
|
|
240
|
+
For an attached Computer, `computer.require_agent_protocol!` checks public agent
|
|
241
|
+
support before you enable agent UI. It returns the Computer or raises
|
|
242
|
+
`PineSandbox::AgentProtocolUnsupported`; older Computers need Stop, then Start
|
|
243
|
+
to update. The check never restarts or rebinds a Computer. Any agent call that
|
|
244
|
+
reaches a Computer without public agent support raises the same local error,
|
|
245
|
+
which has no HTTP status. Authorization, connection and other HTTP failures
|
|
246
|
+
keep their own classes, status and problem type.
|
|
247
|
+
|
|
248
|
+
`session.agent` delegates a **goal**; Pine's agent perceives the page and acts.
|
|
249
|
+
Work is addressed by **turn**: a turn starts when a run or an answer is accepted
|
|
250
|
+
and ends when the agent finishes, fails, is canceled or asks a question. Every
|
|
251
|
+
ending is the turn's `result`, with its `usage`; a question is the result's
|
|
252
|
+
summary (`turn.question`), and the answer starts the next turn, which continues
|
|
253
|
+
the same goal. A session has
|
|
254
|
+
**one persistent agent**, whose memory persists across runs; `reset` starts
|
|
255
|
+
fresh. Mutations (`run`/`steer`/`answer`/`cancel`/`reset`) use `ct_`; reads
|
|
256
|
+
(`wait`/`status`/`turns`/`events`) use `ps_` — the SDK picks the right one.
|
|
257
|
+
|
|
258
|
+
Keep the turn ID with the record it belongs to and wait for that turn. The SDK
|
|
259
|
+
does not require the submitting process to stay alive. Any process with the saved
|
|
260
|
+
session and turn ID can continue:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
agent = session.agent
|
|
264
|
+
# saved_message_id is your durable request identity. A run with the same key
|
|
265
|
+
# returns the turn it admitted, so a lost response never starts a second run.
|
|
266
|
+
turn = agent.run("Find the cheapest direct SFO→NRT flight next month and report the price",
|
|
267
|
+
idempotency_key: saved_message_id)
|
|
268
|
+
record.update!(turn_id: turn.turn_id)
|
|
269
|
+
|
|
270
|
+
# Any worker, later:
|
|
271
|
+
turn = agent.wait(turn_id: record.turn_id, timeout: 30)
|
|
272
|
+
case turn.state
|
|
273
|
+
when "running", "canceling" then schedule_another_wait(record)
|
|
274
|
+
when "needs_input" then present_question(record, turn.turn_id, turn.question)
|
|
275
|
+
when "answered" then record.update!(turn_id: turn.next_turn_id) # another worker answered
|
|
276
|
+
else save_outcome(record, turn.state, turn.result) # completed, failed or canceled; result carries usage
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The process that receives the reply answers the turn that asked:
|
|
280
|
+
turn = agent.answer(turn_id: reply.turn_id, answer: "Use the corporate Amex ending 4242")
|
|
281
|
+
record.update!(turn_id: turn.turn_id)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`wait` holds while the turn runs and returns it when it ends or the timeout
|
|
285
|
+
passes (still `running`). It never cancels the turn and never follows
|
|
286
|
+
`next_turn_id` for you. A read whose response was lost, or whose problem is
|
|
287
|
+
marked retryable, is read again within the timeout. Only the session's latest
|
|
288
|
+
turn can be answered, so starting new work leaves an open question unanswered.
|
|
289
|
+
Sending the same answer again returns the same new turn without delivering its
|
|
290
|
+
secrets again; a different answer to an answered turn raises a `409`
|
|
291
|
+
(`/errors/stale-turn`).
|
|
292
|
+
`run`, `answer` and `cancel` resend a lost response themselves for up to about
|
|
293
|
+
30 seconds, honoring `Retry-After` (`run` uses a key of its own when you pass
|
|
294
|
+
none). A lost response is a network fault, a `408`, or a gateway `502`, `503` or
|
|
295
|
+
`504`, which cannot say whether the Computer acted. If none arrives, `run`'s
|
|
296
|
+
error carries `#idempotency_key`: save it and send the run again with it. The
|
|
297
|
+
error is also a `PineSandbox::RunUnconfirmedError`, so you can rescue that name
|
|
298
|
+
or the error's own class. Pass a
|
|
299
|
+
key of your own, such as your message ID, so a process that restarts can do the
|
|
300
|
+
same.
|
|
301
|
+
|
|
302
|
+
- `agent.steer(turn_id:, text:)` — add guidance while that turn runs (best-effort;
|
|
303
|
+
never replay it automatically).
|
|
304
|
+
- `agent.cancel(turn_id:)` — cancel that turn while it runs, or close its
|
|
305
|
+
waiting question (the turn ends `canceled`); any other ended turn is returned
|
|
306
|
+
unchanged, so retrying is safe. An answered turn raises a `409`: cancel the
|
|
307
|
+
turn its answer opened.
|
|
308
|
+
- `agent.reset(expected_task_id: agent.status.task_id)` — forget the thread so the
|
|
309
|
+
next `run` is fresh memory (idle-only).
|
|
310
|
+
- `agent.status` — the `AgentTask` and its latest `turn`, for discovery;
|
|
311
|
+
`agent.turns` — the retained turns, oldest first.
|
|
312
|
+
|
|
313
|
+
`run` raises `409` if a turn is already running, or a person or your own agent
|
|
314
|
+
holds the drive slot. Routes: `/v2/sessions/{name}/agent/*` in the
|
|
315
|
+
[Computer API reference](https://pinesandbox.io/spec/computer).
|
|
316
|
+
|
|
317
|
+
### Activity
|
|
318
|
+
|
|
319
|
+
`events` streams what the agent does as typed, Hash-compatible `AgentEvent`
|
|
320
|
+
values; each carries the `turn_id` it belongs to. Use it to show progress, not
|
|
321
|
+
to detect how a turn ended: `wait` does that. The stream is continuous: a
|
|
322
|
+
dropped connection reopens from the last event it delivered, and after five
|
|
323
|
+
reconnects in a row that delivered nothing it raises `StreamLostError` (a
|
|
324
|
+
`ConnectionError`). Break out of the block to stop reading.
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
task_id = agent.status.task_id
|
|
328
|
+
# A saved event cursor is valid only for the Task it was stored with.
|
|
329
|
+
cursor = session_progress.last_agent_event_id if session_progress.agent_task_id == task_id
|
|
330
|
+
agent.events(last_event_id: cursor, expected_task_id: task_id) do |e|
|
|
331
|
+
apply_event_and_commit_cursor!(e)
|
|
332
|
+
if (change = e.control_change)
|
|
333
|
+
update_driver_banner(change.controller, change.mode_epoch)
|
|
334
|
+
elsif (progress = e.progress) # the agent explains what it is doing
|
|
335
|
+
narrate(progress.text)
|
|
336
|
+
elsif (step = e.activity) # one row per step, upserted by step.id
|
|
337
|
+
# step.title is the Computer's own description, such as "Click “Log in”";
|
|
338
|
+
# step.kind and step.facts add detail. Every step ends with one outcome,
|
|
339
|
+
# "interrupted" included.
|
|
340
|
+
upsert_step(step.id, step.state, step.title)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
A captured screen carries its matching step when known: `frame.activity`
|
|
346
|
+
is a typed activity snapshot, so a frame read from partial history still has
|
|
347
|
+
its action title and outcome. Attach it by activity ID without counting another
|
|
348
|
+
outcome or changing completion time. An absent activity means an independent
|
|
349
|
+
observation; do not guess from nearby events. A text-only update leaves the last
|
|
350
|
+
captured screen and its original attribution visible. Keep historical selection
|
|
351
|
+
separate from following new frames; the event ID identifies each image for replay.
|
|
352
|
+
|
|
353
|
+
This SDK requires a Computer runtime with the public V2 agent contract. There is
|
|
354
|
+
no fallback to native trace. Activity includes progress, questions and results;
|
|
355
|
+
native commands, reasoning and provider metadata are internal-only. Upgrade
|
|
356
|
+
agent consumers before moving them to the new runtime: its legacy agent routes
|
|
357
|
+
reject ordinary bindings.
|
|
358
|
+
|
|
359
|
+
Paged history uses the same typed events as streaming. Scope every saved cursor
|
|
360
|
+
to its Task; after a session replacement, discover the new Task before replaying
|
|
361
|
+
history. Reset clears agent memory while preserving the Task and its history.
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
page = agent.events_page(after: saved_cursor, expected_task_id: saved_task_id)
|
|
365
|
+
page.fetch("events").each { |event| apply_event!(event) }
|
|
366
|
+
# Persist saved_task_id with page.fetch("next_after") only after applying.
|
|
367
|
+
# A stale-task refusal requires rediscovery with a fresh cursor, never a retry
|
|
368
|
+
# of saved_cursor against the replacement Task.
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Drive — your own agent (you drive)
|
|
372
|
+
|
|
373
|
+
`session.drive` is the "bring your own agent" surface: you perceive and act. The
|
|
374
|
+
vision-friendly primitive is the **observe → act loop**:
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
drive = session.drive
|
|
378
|
+
loop do
|
|
379
|
+
snap = drive.observe # { screenshot, viewport, device_scale_factor, scroll, ... }
|
|
380
|
+
action = my_policy.next(snap) # your model/policy decides
|
|
381
|
+
break if action.done?
|
|
382
|
+
drive.computer_use(action.kind, **action.fields) # e.g. :left_click, coordinate: [x, y]; :type, text: "hi"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
drive.upload_file("input[type=file]", "resume.pdf") # set a file input (deposit the file first)
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`observe` is read-only (allowed even while Pine's agent runs — you can watch);
|
|
389
|
+
`computer_use` / `upload_file` take the drive slot (`409` if Pine's agent or a person
|
|
390
|
+
holds it). Pine's resident agent can use private DOM/browser tools through
|
|
391
|
+
`session.delegate`; direct customer shell execution runs public programs and
|
|
392
|
+
works with session files. It cannot invoke Pine's private browser implementation.
|
|
393
|
+
Raw CDP is an image-owned runtime transport and is intentionally absent from
|
|
394
|
+
the SDK surface. Tab helpers
|
|
395
|
+
(`create_tab`/`tabs`/`patch_tab`/`close_tab`), `focus!`, and `epoch` round out the manual
|
|
396
|
+
surface. Public routes are defined by the
|
|
397
|
+
[Computer API reference](https://pinesandbox.io/spec/computer).
|
|
398
|
+
|
|
399
|
+
## Files & artifacts
|
|
400
|
+
|
|
401
|
+
A session has a **workdir** (where `exec` runs and the agent works), an inbound
|
|
402
|
+
**attachments** root (files you hand the agent), and an **artifact registry** (the
|
|
403
|
+
session's managed outputs).
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
# Read the workdir (what the agent produced / your shell wrote):
|
|
407
|
+
session.list_files(pattern: "*.csv") # => [FileEntry, ...]
|
|
408
|
+
session.read_file("out/report.csv") # => bytes (binary encoding)
|
|
409
|
+
|
|
410
|
+
# Deposit a material the agent should use. It lands in attachments/ and surfaces
|
|
411
|
+
# to the agent via the materials preamble — a sibling of the workdir, so it is NOT
|
|
412
|
+
# read_file-able (read_file serves the workdir).
|
|
413
|
+
session.write_file("brief.md", File.read("brief.md"))
|
|
414
|
+
|
|
415
|
+
# Artifacts — the session's managed outputs, each with metadata + a download.
|
|
416
|
+
art = session.upload_artifact("logo.png", File.binread("logo.png")) # => Models::Artifact
|
|
417
|
+
session.list_artifacts # all, newest-first
|
|
418
|
+
session.list_artifacts(turn_id: "turn_…") # just one agent turn's outputs
|
|
419
|
+
session.read_artifact(art.id) # => bytes
|
|
420
|
+
session.zip_artifacts # => a .zip of all artifacts (bytes)
|
|
421
|
+
session.open_artifact(art.id) { |chunk| io.write(chunk) } # stream it without buffering
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`created_by` names the producer: `upload` (you), `download` (a browser download,
|
|
425
|
+
auto-indexed), `agent` (a turn's outputs, durably captured at the turn's result). Only
|
|
426
|
+
`agent` artifacts carry a `turn_id`, so the `turn_id` filter scopes to one turn's
|
|
427
|
+
outputs; uploads/downloads always appear unfiltered. Uploads are immutable +
|
|
428
|
+
id-addressed (a repeated filename yields a distinct artifact). Routes: `/files`,
|
|
429
|
+
`/artifacts[/{zip,artifact_id}]` in the
|
|
430
|
+
[Computer API reference](https://pinesandbox.io/spec/computer).
|
|
431
|
+
|
|
432
|
+
## Skills — `learn` / `teach` (and when each blocks)
|
|
433
|
+
|
|
434
|
+
`session.learn` and `session.teach` author a learned-skill **draft** from
|
|
435
|
+
either the agent's last finished turn (`#learn`) or the demonstration a person
|
|
436
|
+
just finished and released (`#teach`). The draft lands pending in the
|
|
437
|
+
Computer's `skills.drafts` queue, and you activate it before any future agent
|
|
438
|
+
sees it. Both start a separate authoring model run that typically takes
|
|
439
|
+
**30–180 s** of wall-clock.
|
|
440
|
+
|
|
441
|
+
There are **two flavors** of each, and choosing the right one matters under
|
|
442
|
+
realistic networking:
|
|
443
|
+
|
|
444
|
+
| Method | Returns when | Use when |
|
|
445
|
+
|---|---|---|
|
|
446
|
+
| `session.learn(scaffold:)` / `session.teach(goal:)` | The authoring run ends (blocks on its event stream) | CLI tools, backend workers / Sidekiq jobs, tests, smoke scripts — anything that owns its own request budget |
|
|
447
|
+
| `session.start_learn(scaffold:)` / `session.start_teach(goal:)` | Immediately, with the raw 202 envelope `{author_session, task_id[, handoff_id]}` | A web handler behind a reverse proxy (Cloudflare, ALB, GFE, Heroku) where the request timeout is 30–100 s — the proxy will sever the sync call before the terminal event |
|
|
448
|
+
|
|
449
|
+
The blocking variant wraps the non-blocking one: it starts the run, follows
|
|
450
|
+
its events to the terminal event, and translates that into a `LearnResult` /
|
|
451
|
+
`TeachResult`. The only difference is whether **you** follow the events with
|
|
452
|
+
`session.author_events` or the SDK does on your behalf.
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
# Blocking — fine from a Sidekiq job
|
|
456
|
+
result = session.learn(scaffold: "---\nname: book-united\ndescription: …\n---\n")
|
|
457
|
+
result.learned? # => true / false
|
|
458
|
+
result.draft # => { "name" => "book-united", "version" => 1, … }
|
|
459
|
+
|
|
460
|
+
# Non-blocking — what you want from a Rails controller behind CF
|
|
461
|
+
accepted = session.start_learn(scaffold: scaffold)
|
|
462
|
+
# accepted["author_session"] => an opaque ID; pass it back unchanged
|
|
463
|
+
# Follow the run on your own time (pass last_event_id: to resume):
|
|
464
|
+
session.author_events(author_id: accepted["author_session"]) do |evt|
|
|
465
|
+
break if evt.result # then process evt.result["terminal_reason"]
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`session.cancel_author(author_id:)` cancels a run that is still in flight.
|
|
470
|
+
|
|
471
|
+
Activation, drafts review, and the rest of the lifecycle live on the
|
|
472
|
+
**Computer**, not the session — `computer.skills.{list, drafts, get(name),
|
|
473
|
+
activate(name, version:), deactivate(name)}`. Draft review is the safety
|
|
474
|
+
gate: nothing learned reaches another agent until you activate it.
|
|
475
|
+
|
|
476
|
+
## Persistence & teardown
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
computer.checkpoint # synchronous state save (content-hash dedup) — before a risky step
|
|
480
|
+
computer.stop # save cookies, learned skills and localStorage, then delete the sandbox
|
|
481
|
+
computer.kill # delete without an acknowledged save
|
|
482
|
+
client.delete_computer(computer) # PERMANENT: kill if attached, then delete id + saved state
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
`stop` and `kill` keep the Computer's identity and saved state; `delete_computer`
|
|
486
|
+
(a `Computer` or its id) ends both. The id is refused for every later attach, saved
|
|
487
|
+
state is purged after 24 hours, and the id is never reusable. Deleting an unknown,
|
|
488
|
+
already-deleted, or other project's id also returns `true`. There is no undelete.
|
|
489
|
+
|
|
490
|
+
`stop` submits graceful Stop to the runtime controller, then waits up to 390s
|
|
491
|
+
(`wait_timeout:`) for compute release. The controller tries to save supported state
|
|
492
|
+
before deletion and continues if the SDK process disappears. Saving is best effort:
|
|
493
|
+
a save that fails or is not confirmed by the deadline still releases the runtime,
|
|
494
|
+
and `stop` returns true; the next Start restores the last saved state. Reconstruct
|
|
495
|
+
the saved handle with `reconnect_computer` to observe or join the same operation from
|
|
496
|
+
another backend. Only an older server keeps the source and raises
|
|
497
|
+
`PineSandbox::StopSaveError`; its `retryable` flag distinguishes a retryable save
|
|
498
|
+
failure from an unconfirmed outcome after the Computer's services restart.
|
|
499
|
+
A `false` return means completion is unconfirmed. Keep the saved binding for retry.
|
|
500
|
+
After Stop returns true, clear the stored binding only if its revision and
|
|
501
|
+
runtime still match the handle stopped; a concurrent Start may have replaced it.
|
|
502
|
+
Keep the revision fence, identity, state key and capture keypairs for future Start. Ephemeral
|
|
503
|
+
bindings skip persistence; the runtime's binding record decides this.
|
|
504
|
+
|
|
505
|
+
If a Start response was lost before saving the runtime token, use
|
|
506
|
+
`client.stop_computer(id)` to stop the currently bound runtime with project
|
|
507
|
+
authorization alone, then call Start separately. This lookup selects one binding;
|
|
508
|
+
it never follows a concurrent newer binding. Private keys remain necessary for
|
|
509
|
+
Start and cannot be recovered from the ID.
|
|
510
|
+
|
|
511
|
+
A true result proves compute release or authoritative runtime absence. It does
|
|
512
|
+
not prove a final save by a runtime that disappeared, and provider snapshot cleanup
|
|
513
|
+
can still be running. Start restores only the latest committed state. Cookies,
|
|
514
|
+
learned skills and compatible localStorage survive supported saved-state recovery;
|
|
515
|
+
tabs, session files, page memory and IndexedDB do not.
|
|
516
|
+
|
|
517
|
+
`start_computer` reads the current binding revision once, checks that the old
|
|
518
|
+
runtime is gone or released, waiting for an already accepted deletion if needed,
|
|
519
|
+
and creates a new binding. A conflict stays a conflict;
|
|
520
|
+
it never adopts a newer revision and displaces another worker's winner. For a
|
|
521
|
+
recorded failed runtime, explicitly call
|
|
522
|
+
`client.recover_computer(id, key: key, capture_keypair: keypair)`.
|
|
523
|
+
Recovery accepts possible unsaved-state loss, releases the observed failed runtime
|
|
524
|
+
with a version precondition, waits up to 90s for compute release, then Starts. It
|
|
525
|
+
refuses healthy running/paused runtimes and an unresolved capture timeout. If
|
|
526
|
+
release times out, retry later; no replacement has been allocated. `kill` remains
|
|
527
|
+
an unconditional destructive operation.
|
|
528
|
+
|
|
529
|
+
This SDK's retryable allocation and server-owned Stop require the updated backend
|
|
530
|
+
and a compatible Computer runtime; neither requires Pause opt-in. Existing
|
|
531
|
+
Computer create, capture/Finish and delete calls remain supported for applications
|
|
532
|
+
keeping their prior SDK. Complete the backend rollout before upgrading, including
|
|
533
|
+
the conditional-release support used by recovery. A pool image update does not
|
|
534
|
+
upgrade an already-running Computer: stop old-image bindings with the prior SDK
|
|
535
|
+
before upgrading their caller. Integrations using the removed generic
|
|
536
|
+
public sandbox operations must migrate to the Computer contract; the private Ruby
|
|
537
|
+
`ControlPlane#create` adapter is removed as well.
|
|
538
|
+
|
|
539
|
+
For projects opted in to Pause/Resume on snapshot-capable runtimes:
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
computer.pause(timeout: 360) # waits for capture and confirmed source stop
|
|
543
|
+
computer.resume(timeout: 360) # waits for restored routing and runtime recovery
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Pause is disabled by default. Pine enables it for a project on request;
|
|
547
|
+
refresh the project token after it is enabled. Disabling the
|
|
548
|
+
feature prevents new pauses but preserves Resume and Stop for retained memory.
|
|
549
|
+
Ordinary Stop/Start remains available without the opt-in.
|
|
550
|
+
|
|
551
|
+
The same sandbox handle, session capabilities and retained process memory remain.
|
|
552
|
+
A pool image update does not change the captured runtime. Calls include acceptance
|
|
553
|
+
and completion in one bounded timeout. A timeout leaves the outcome unconfirmed;
|
|
554
|
+
retry against the retained handle. The controller restores the same paused
|
|
555
|
+
runtime before Stop can save it. Snapshot retention and admitted maximum age still apply; legacy expiry is unchanged.
|
|
556
|
+
|
|
557
|
+
Do not rescue a failed `resume` by automatically calling `start_computer`.
|
|
558
|
+
Retrying Resume keeps the existing memory-recovery intent; Start explicitly
|
|
559
|
+
accepts recovery from the last saved state with new sessions and possible loss
|
|
560
|
+
of unsaved work. An unavailable status or timeout alone does not prove that
|
|
561
|
+
memory is lost. The [Computer API reference](https://pinesandbox.io/spec/computer)
|
|
562
|
+
defines the observed states and recovery choices: retry Resume, refresh the
|
|
563
|
+
status, or Start from saved state.
|
|
564
|
+
|
|
565
|
+
## Errors
|
|
566
|
+
|
|
567
|
+
Three typed families, one per credential exchange (see `lib/pinesandbox/errors.rb`):
|
|
568
|
+
|
|
569
|
+
Data-plane runtime loss raises `PineSandbox::RuntimeGoneError`; it preserves the
|
|
570
|
+
wire diagnostics and original `ApiError` cause. Errors about a resource on a
|
|
571
|
+
live Computer remain distinct, including `PineSandbox::SessionNotFound` and
|
|
572
|
+
`PineSandbox::ControlNotHeld`.
|
|
573
|
+
|
|
574
|
+
- **`BindError`** — `Computer#attach` outcomes: `BindAuthError`,
|
|
575
|
+
`ComputerAlreadyAttachedError`, `RuntimeUnusableError`, `SavedStateUnavailableError`,
|
|
576
|
+
`BindTimeoutError`; plus `TokenRejectedError` on a post-bind 401 for the
|
|
577
|
+
Computer token (`ct_` not recognized — on a live runtime it is
|
|
578
|
+
`binding_auth_lost`; re-attach only when the runtime is confirmed gone). A 401
|
|
579
|
+
for a session token is an ordinary `UnauthorizedError`. If `create_computer` raises
|
|
580
|
+
`AttachAuthorizationCommittedError`, retain its non-nil `#credentials` and inspect `#runtime_id` before deciding
|
|
581
|
+
whether to stop or recover the failed runtime. Credential inspection redacts private material.
|
|
582
|
+
- **`AttachCredentialsError`** — the portal attach-credentials flow:
|
|
583
|
+
`UnknownComputerError` (404 — deleted or cross-project),
|
|
584
|
+
`ProjectAccessDenied` (403), and `BindingRevisionConflictError` (412 — another start changed the binding; `#current_binding_revision` /
|
|
585
|
+
`#current_runtime_id` identify the winner. Do not retry automatically). A bad `pk_` (401),
|
|
586
|
+
rate limit (429), and server errors are the base `AttachCredentialsError`.
|
|
587
|
+
Inspect the stable problem `#problem_type` and optional safe `#reason`; raw
|
|
588
|
+
internal exceptions are never part of the API contract.
|
|
589
|
+
- **`ControlTokenError`** — the `pk_` → project-token mint: `InvalidClientKey`
|
|
590
|
+
(401), `InsufficientScope` (403), `RateLimited` (429, carries `#retry_after`).
|
|
591
|
+
A `Retry-After` longer than the mint's 10-second backoff cap ends the mint at
|
|
592
|
+
once with that error instead of retrying early.
|
|
593
|
+
|
|
594
|
+
The old names `SandboxGoneError`, `SandboxFailedError`, `PodPoisonedError`,
|
|
595
|
+
`BrokerUnreachableError`, `SandboxHandle` and the `sandbox_id` readers keep
|
|
596
|
+
working as deprecated aliases; they warn when Ruby's deprecation warnings are on.
|
|
597
|
+
|
|
598
|
+
## Troubleshooting — reporting an issue
|
|
599
|
+
|
|
600
|
+
The SDK supports saved Computer diagnostics. They require the diagnostics backend; older deployments do not serve them. The request needs an existing Computer ID, not its private keys or a live handle:
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
# Persist one key per logical request if you need to retry after a lost response.
|
|
604
|
+
report = client.create_diagnostic(computer_id: saved_id, session: "main",
|
|
605
|
+
idempotency_key: diagnostic_request_key)
|
|
606
|
+
support_reference = report.fetch("id")
|
|
607
|
+
# Read later. Only poll queued/running reports, with a bounded deadline.
|
|
608
|
+
report = client.get_diagnostic(support_reference)
|
|
609
|
+
puts report.dig("assessment", "message") if report["assessment"]
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Supply a session name to investigate its recorded agent work; omit it for a lifecycle check. The investigation does not operate the application or repeat its work. It produces a private report for Pine support and a separate customer-safe assessment. Optional descriptions provide private context. An unavailable diagnostic does not justify repeating customer work. Requesting a report does not open a human support ticket; share its reference separately when asking Pine for help.
|
|
613
|
+
|
|
614
|
+
Clients declaring `diagnostic_response_metadata?` expose `report.retry_after` and
|
|
615
|
+
`PortalError#retry_after` as seconds, including HTTP-date hints. Honor the hint
|
|
616
|
+
before polling or retrying, even when it exceeds your normal backoff cap. Stop
|
|
617
|
+
at your application deadline instead of retrying early. The response remains
|
|
618
|
+
Hash-compatible, and the metadata is not added to its JSON payload. Earlier
|
|
619
|
+
packages do not expose these hints; see the changelog for release availability.
|
|
620
|
+
|
|
621
|
+
Errors are **self-describing at the resource level**: every error the SDK raises off a
|
|
622
|
+
gateway-fronted response folds the failing Computer + operation into `e.message` — resource
|
|
623
|
+
first — so a generic handler that logs only `e.message` shows WHICH Computer and WHICH call
|
|
624
|
+
failed with no extra plumbing.
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
begin
|
|
628
|
+
session.agent.run("book the 9am slot")
|
|
629
|
+
rescue PineSandbox::ApiError => e
|
|
630
|
+
logger.error(e.message)
|
|
631
|
+
# => "… (runtime=sbx_abc123, op=POST /v2/sessions/main/agent/run, request_id=req_abc123)"
|
|
632
|
+
Report.open(computer_id: computer.id, session: session.name,
|
|
633
|
+
at: Time.now.utc, request_id: e.request_id, op: e.op)
|
|
634
|
+
end
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Each error class exposes exactly its own typed context (the message always carries the same
|
|
638
|
+
data): Computer API **`ApiError`** → `#runtime_id` / `#op` / `#request_id`; control-plane errors
|
|
639
|
+
→ `#op` / `#request_id`; portal **`ControlTokenError`** / **`AttachCredentialsError`** → `#op`
|
|
640
|
+
/ `#request_id`. The suffix grammar is uniform `key=value` across the Go and Ruby SDKs; Ruby
|
|
641
|
+
Computer API errors use **`runtime=`** because the Ruby SDK holds the runtime id, where the Go SDK
|
|
642
|
+
uses **`host=`** because its client holds the Computer host (same Computer, different handle
|
|
643
|
+
each SDK already has).
|
|
644
|
+
|
|
645
|
+
When you file a report, **lead with the resource spine** — the durable key Pine support uses
|
|
646
|
+
to find the failure:
|
|
647
|
+
|
|
648
|
+
1. **`computer_id`** (`computer.id`) — WHICH Computer. Add the **session name** (`session.name`)
|
|
649
|
+
and, for an agent-lane issue, the **`task_id`**.
|
|
650
|
+
2. A **UTC time window**.
|
|
651
|
+
3. **`request_id`** *when you have it* — the precision handle for a single failed call
|
|
652
|
+
(`e.request_id`, also in the message). A read or stream that fails *without* a response —
|
|
653
|
+
a `PineSandbox::TimeoutError` / `ConnectionError`, which carry no id — is still actionable
|
|
654
|
+
off the resource spine alone (the op is still in their message).
|
|
655
|
+
|
|
656
|
+
## Known limitations
|
|
657
|
+
|
|
658
|
+
- Pause/Resume requires a qualified snapshot-capable Computer runtime.
|
|
659
|
+
Other runtimes reject it as unsupported. Snapshot loss, expiry or
|
|
660
|
+
failed recovery surfaces an error; it never silently falls back to a fresh
|
|
661
|
+
runtime. Enrolled runtimes use snapshot retention and maximum age while paused; legacy expiry is unchanged.
|
|
662
|
+
|
|
663
|
+
## Develop
|
|
664
|
+
|
|
665
|
+
```bash
|
|
666
|
+
make install # bundle install
|
|
667
|
+
make lint # ruby -c syntax over lib + spec
|
|
668
|
+
make test # rspec (unit; .rspec scopes to spec/pinesandbox)
|
|
669
|
+
make build # gem build pinesandbox.gemspec
|
|
670
|
+
```
|