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