pinesandbox 0.3.18 → 0.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,12 +10,15 @@ require_relative "../control_events"
10
10
  require_relative "../sse_frame_parser"
11
11
  require_relative "../exec_stream_parser"
12
12
  require_relative "../response_handler"
13
+ require_relative "../streamed_read"
14
+ require_relative "../version"
13
15
 
14
16
  module PineSandbox
15
17
  module Adapters
16
18
  # @api private — the SDK's HTTP client for the Computer API, reached at the
17
- # runtime's own host `https://<runtime_id>.computer.<zone>`. The subdomain
18
- # selects the runtime and the PATH selects the role (the API at root);
19
+ # Computer sandbox's own host `https://<sandbox_id>.computer.<zone>`. The
20
+ # subdomain selects the Computer sandbox and the PATH selects the role (the
21
+ # API at root);
19
22
  # the host is port-less and header-less — no routing header.
20
23
  #
21
24
  # The bearer goes in `Authorization: Bearer`. The Computer gates every
@@ -34,26 +37,26 @@ module PineSandbox
34
37
 
35
38
  DEFAULT_HTTP_TIMEOUT = 30
36
39
  DEFAULT_OPEN_TIMEOUT = 5
37
- # A non-2xx stream answer keeps this much of its body for the error.
38
- STREAM_ERROR_BODY_CAP = 64 * 1024
39
-
40
- # The runtime is reached at its own host `https://<runtime_id>.computer.<zone>`
41
- # (derived from `zone`), port-less and header-less: the subdomain selects the
42
- # runtime and the PATH selects the role. `zone` is a PineSandbox::Zone.
43
- # `host` is the per-runtime data-plane host `<runtime_id>.computer.<zone>`
40
+ # An event stream silent this long (three missed keepalives) is reopened.
41
+ STREAM_IDLE_TIMEOUT = 60
42
+
43
+ # The Computer sandbox is reached at its own host
44
+ # `https://<sandbox_id>.computer.<zone>` (derived from `zone`), port-less and
45
+ # header-less: the subdomain selects the Computer sandbox and the PATH
46
+ # selects the role. `zone` is a PineSandbox::Zone.
47
+ # `host` is the Computer sandbox's data-plane host `<sandbox_id>.computer.<zone>`
44
48
  # (scheme-less). `scheme` is http for a .localhost zone (local-dev loop),
45
49
  # https otherwise. `clock` paces retries and reconnects.
46
- attr_reader :runtime_id, :host, :scheme, :clock
50
+ attr_reader :sandbox_id, :host, :scheme, :clock
47
51
 
48
52
  # discovery_token is the Computer token that reads the capability
49
53
  # manifest when a request's own token, such as a session's, cannot. An
50
54
  # adopted binding passes it; #bind records the one it issues.
51
- # `sandbox_id:` is the deprecated spelling of `runtime_id:`.
52
- def initialize(zone:, runtime_id: nil, sandbox_id: nil, discovery_token: nil, clock: PineSandbox::SYSTEM_CLOCK)
53
- @runtime_id = runtime_id || sandbox_id
54
- raise ArgumentError, "runtime_id required" if @runtime_id.to_s.empty?
55
+ def initialize(zone:, sandbox_id: nil, discovery_token: nil, clock: PineSandbox::SYSTEM_CLOCK)
56
+ @sandbox_id = sandbox_id
57
+ raise ArgumentError, "sandbox_id required" if @sandbox_id.to_s.empty?
55
58
 
56
- @host = zone.data_host(@runtime_id)
59
+ @host = zone.data_host(@sandbox_id)
57
60
  @scheme = zone.http_scheme # http for a .localhost zone (local-dev loop), https otherwise
58
61
  @discovery_token = discovery_token
59
62
  @clock = clock
@@ -63,13 +66,7 @@ module PineSandbox
63
66
  @zone = zone
64
67
  end
65
68
 
66
- # Deprecated: use #runtime_id.
67
- def sandbox_id
68
- PineSandbox.deprecated_name("CoordinatorAdapter#sandbox_id", "#runtime_id")
69
- @runtime_id
70
- end
71
-
72
- # The full data-plane origin `<scheme>://<runtime_id>.computer.<zone>` — the
69
+ # The full data-plane origin `<scheme>://<sandbox_id>.computer.<zone>` — the
73
70
  # DelegatedConnection's `computer_host` is a complete URI per the Computer API,
74
71
  # so the web SDK derives the desktop's ws/wss scheme from it (no guessing).
75
72
  def base_url
@@ -77,7 +74,7 @@ module PineSandbox
77
74
  end
78
75
 
79
76
  # Bind handshake. Posts the portal-issued bind_token JWS to
80
- # /v1/coord/bind and returns the runtime's `ct_` from the response.
77
+ # /v1/coord/bind and returns the Computer token (`ct_`) from the response.
81
78
  #
82
79
  # Auth model: bind_token's Ed25519 signature IS the auth — no
83
80
  # bearer token is presented here (the bind endpoint refuses
@@ -85,7 +82,7 @@ module PineSandbox
85
82
  #
86
83
  # @param bind_token [String] compact JWS Ed25519 token minted by the
87
84
  # portal attach-credentials endpoint (AttachCredentialsSource).
88
- # @param pod_uid [String] echoed runtime identity (must match the token).
85
+ # @param pod_uid [String] echoed Computer sandbox identity (must match the token).
89
86
  # @param coord_boot_id [String] echoed process boot id.
90
87
  # @param ciphertext [String] base64url HPKE wire (enc || aead_ct).
91
88
  # @return [Hash] { "computer_token" => ct_, "epoch" => Integer }.
@@ -109,7 +106,7 @@ module PineSandbox
109
106
  end
110
107
 
111
108
  # Fetches the Computer's per-process ephemeral HPKE public key and
112
- # runtime identity. Public route — no token. The SDK seals the bind
109
+ # Computer sandbox identity. Public route — no token. The SDK seals the bind
113
110
  # payload to this key and echoes pod_uid/coord_boot_id back on bind.
114
111
  #
115
112
  # @return [Hash] { "pod_uid", "coord_boot_id", "ephem_pub_x25519"
@@ -192,31 +189,23 @@ module PineSandbox
192
189
  true
193
190
  end
194
191
 
195
- # Orphan downloads: browser downloads no session owns (ct_ only).
196
- def list_orphan_downloads(token:)
197
- get("downloads/orphans", token: token).body
192
+ # POST /v1/artifacts/links — a public link this running Computer serves
193
+ # to one of its artifacts (ct_ only). Returns { "url", "expires_at"? }.
194
+ def create_artifact_link(token:, path:, expires_in_seconds: nil)
195
+ body = { path: path }
196
+ body[:expires_in_seconds] = expires_in_seconds if expires_in_seconds
197
+ post("v1/artifacts/links", token: token, json: body).body
198
198
  end
199
199
 
200
- def claim_orphan_download(token:, guid:, session_name:, filename: nil)
201
- body = { session_name: session_name }
202
- body[:filename] = filename if filename
203
- post("downloads/orphans/#{escape(guid)}/claim", token: token, json: body).body
204
- end
205
-
206
- def discard_orphan_download(token:, guid:)
207
- delete("downloads/orphans/#{escape(guid)}", token: token)
208
- true
209
- end
210
-
211
- def create_session(token:, name: nil, label: nil, browser: false, blind: false)
200
+ def create_session(token:, name: nil, label: nil, artifact_access: nil)
212
201
  # Omit :name when blank so the coordinator mints a friendly name; only
213
- # send :label when the caller set one. Name stays first so the on-wire
214
- # body order is stable.
202
+ # send :label and :artifact_access when the caller set them. Name stays
203
+ # first so the on-wire body order is stable. Every session has a
204
+ # browser window, so the request names none.
215
205
  body = {}
216
206
  body[:name] = name unless name.nil? || name.to_s.empty?
217
- body[:browser] = browser
218
207
  body[:label] = label unless label.nil? || label.to_s.empty?
219
- body[:blind] = true if blind
208
+ body[:artifact_access] = artifact_access if artifact_access
220
209
  post("sessions", token: token, json: body)
221
210
  .body
222
211
  .fetch("session")
@@ -300,7 +289,7 @@ module PineSandbox
300
289
  # ----- Artifacts: list / read / zip / upload -----
301
290
 
302
291
  # GET /v1/sessions/{name}/artifacts?turn_id= — the session's artifacts
303
- # (uploads + claimed downloads + every completed turn's durably-captured
292
+ # (uploads + browser downloads + every completed turn's durably-captured
304
293
  # agent outputs).
305
294
  def list_artifacts(token:, name:, turn_id: nil)
306
295
  route = "v1/sessions/#{escape(name)}/artifacts"
@@ -525,7 +514,7 @@ module PineSandbox
525
514
  event.data["exitCode"] || event.data[:exitCode]
526
515
  exit_code = raw unless raw.nil?
527
516
  end
528
- block.call(event) if block
517
+ StreamedRead.pass(block, event) if block
529
518
  end
530
519
  # Clamp the HTTP timeout above timeout_ms so the client waits for
531
520
  # the server's own timeout to fire an execution_complete event
@@ -541,10 +530,16 @@ module PineSandbox
541
530
  req.headers["Accept"] = "text/event-stream"
542
531
  req.body = body.to_json
543
532
  req.options.on_data = proc do |chunk, _received, env|
544
- if env.nil? || env.status.to_i.between?(200, 299)
533
+ if env.nil?
534
+ # faraday-net_http before 3.0 passes no env, so the status is
535
+ # known only at the end: parse the chunk and keep it in case
536
+ # the answer is an error.
537
+ parser.parse(chunk)
538
+ StreamedRead.keep_error_body(error_body, chunk)
539
+ elsif env.status.to_i.between?(200, 299)
545
540
  parser.parse(chunk)
546
541
  else
547
- keep_error_body(error_body, chunk)
542
+ StreamedRead.keep_error_body(error_body, chunk)
548
543
  end
549
544
  end
550
545
  req.options.timeout = http_timeout
@@ -568,6 +563,8 @@ module PineSandbox
568
563
  end
569
564
  ExecResult.new(stdout: stdout, stderr: stderr, exit_code: exit_code,
570
565
  error_message: error_msg)
566
+ rescue BlockError => e
567
+ raise e.error
571
568
  end
572
569
 
573
570
  # Minimal struct surfaced by #exec. Matches the shape tests expect:
@@ -701,7 +698,7 @@ module PineSandbox
701
698
  end
702
699
 
703
700
  # Explicit qualification for a binding. No cache, downgrade or mutation.
704
- # A missing discovery route is an old runtime; the gateway's typed
701
+ # A missing discovery route is an old Computer sandbox; the gateway's typed
705
702
  # sandbox-not-found response remains a lifecycle failure.
706
703
  def require_agent_protocol!(token:)
707
704
  manifest = capabilities(token: token)
@@ -709,7 +706,7 @@ module PineSandbox
709
706
 
710
707
  self
711
708
  rescue NotFoundError => e
712
- raise if e.is_a?(RuntimeGoneError)
709
+ raise if e.is_a?(SandboxGoneError)
713
710
 
714
711
  raise AgentProtocolUnsupported
715
712
  end
@@ -899,10 +896,16 @@ module PineSandbox
899
896
  req.headers["Accept"] = "text/event-stream"
900
897
  req.headers["Last-Event-ID"] = last_event_id.to_s unless last_event_id.to_s.empty?
901
898
  req.options.on_data = proc do |chunk, _bytes, env|
902
- if env.nil? || env.status.to_i.between?(200, 299)
899
+ if env.nil?
900
+ # faraday-net_http before 3.0 passes no env, so the status is
901
+ # known only at the end: parse the chunk and keep it in case the
902
+ # answer is an error.
903
+ parser.feed(chunk) { |frame| yield frame }
904
+ StreamedRead.keep_error_body(error_body, chunk)
905
+ elsif env.status.to_i.between?(200, 299)
903
906
  parser.feed(chunk) { |frame| yield frame }
904
907
  else
905
- keep_error_body(error_body, chunk)
908
+ StreamedRead.keep_error_body(error_body, chunk)
906
909
  end
907
910
  end
908
911
  end
@@ -914,17 +917,7 @@ module PineSandbox
914
917
  response
915
918
  end
916
919
 
917
- # Raised through Faraday's read loop in place of an exception from the
918
- # caller's block, so the block's own error is never mistaken for a
919
- # dropped connection.
920
- class BlockError < StandardError
921
- attr_reader :error
922
-
923
- def initialize(error)
924
- @error = error
925
- super(error.message)
926
- end
927
- end
920
+ BlockError = StreamedRead::BlockError
928
921
  private_constant :BlockError
929
922
 
930
923
  # The resuming event-stream engine. Each connection opens from the last
@@ -936,7 +929,7 @@ module PineSandbox
936
929
  # dead one ends with StreamLostError. An error answer raises at once.
937
930
  # Returns the cursor when `terminal` or `stop_when` matches an event, or
938
931
  # when `max_reconnects` (a caller cap on total reconnects) is used up.
939
- def follow_events(op:, cursor:, open:, decode:, terminal: nil, stop_when: nil, max_reconnects: nil)
932
+ def follow_events(op:, cursor:, open:, decode:, terminal: nil, stop_when: nil, max_reconnects: nil, &block)
940
933
  cursor = cursor.to_s
941
934
  failures = 0
942
935
  reconnects = 0
@@ -954,11 +947,7 @@ module PineSandbox
954
947
  next if event.nil?
955
948
 
956
949
  delivered = true
957
- begin
958
- yield event
959
- rescue StandardError => e
960
- raise BlockError.new(e)
961
- end
950
+ StreamedRead.pass(block, event)
962
951
  throw :pine_stream_stop if terminal&.call(event) || stop_when&.call(event)
963
952
  end
964
953
  request_id = request_id_header(response) || request_id
@@ -997,35 +986,26 @@ module PineSandbox
997
986
  ::PineSandbox::ControlEvents.decode_event(frame.event, id, frame.data)
998
987
  end
999
988
 
1000
- def keep_error_body(buffer, chunk)
1001
- remaining = STREAM_ERROR_BODY_CAP - buffer.bytesize
1002
- buffer << chunk.byteslice(0, remaining) if remaining.positive?
1003
- end
1004
-
1005
989
  # Passes a binary GET's body to the block in chunks as it arrives instead
1006
990
  # of buffering it. Only a 2xx body reaches the block: an error answer is
1007
991
  # kept (bounded) and raised as the same typed error the buffered read
1008
992
  # raises, before any chunk. A transport fault after the first chunk
1009
993
  # raises too, so the caller knows the body is incomplete. The default
1010
994
  # connection's read timeout bounds each wait for the next chunk.
1011
- def stream_bytes(path, token:, accept:)
1012
- error_body = +""
995
+ def stream_bytes(path, token:, accept:, &block)
1013
996
  with_transport_norm("GET", path) do
1014
- response = connection.get(route_path(path)) do |req|
1015
- apply_token(req, token)
1016
- req.headers["Accept"] = accept
1017
- req.options.on_data = proc do |chunk, _received, env|
1018
- if env.status.to_i.between?(200, 299)
1019
- yield chunk.b unless chunk.empty?
1020
- else
1021
- keep_error_body(error_body, chunk)
1022
- end
997
+ response = StreamedRead.read(->(chunk, _answer) { StreamedRead.pass(block, chunk) }) do |on_data|
998
+ connection.get(route_path(path)) do |req|
999
+ apply_token(req, token)
1000
+ req.headers["Accept"] = accept
1001
+ req.options.on_data = on_data
1023
1002
  end
1024
1003
  end
1025
- response.env.body = error_body if !response.success? && response.body.to_s.empty?
1026
1004
  ensure_success!(response, token: token) unless response.success?
1027
1005
  end
1028
1006
  nil
1007
+ rescue BlockError => e
1008
+ raise e.error
1029
1009
  end
1030
1010
 
1031
1011
  def get(path, token:, accept: "application/json", headers: nil)
@@ -1105,7 +1085,7 @@ module PineSandbox
1105
1085
  end
1106
1086
 
1107
1087
  def normalize_transport_fault(error, method, path)
1108
- suffix = context_suffix(@runtime_id, op_from_route(method, path), nil)
1088
+ suffix = context_suffix(@sandbox_id, op_from_route(method, path), nil)
1109
1089
  if error.is_a?(Faraday::TimeoutError)
1110
1090
  PineSandbox::TimeoutError.new("Computer request timed out: #{error.message}#{suffix}")
1111
1091
  else
@@ -1123,7 +1103,7 @@ module PineSandbox
1123
1103
 
1124
1104
  # The coord route path on the gateway host. The gateway routes by subdomain
1125
1105
  # and selects the role by PATH, so the path is just the coord route — no
1126
- # proxy prefix, no runtime id, no port.
1106
+ # proxy prefix, no sandbox id, no port.
1127
1107
  def route_path(suffix)
1128
1108
  "/#{suffix}"
1129
1109
  end
@@ -1151,12 +1131,12 @@ module PineSandbox
1151
1131
  def classify_failure!(response, token)
1152
1132
  check_success!(response)
1153
1133
  rescue NotFoundError => original
1154
- raise if original.is_a?(RuntimeGoneError) || !original.problem_type.to_s.empty?
1134
+ raise if original.is_a?(SandboxGoneError) || !original.problem_type.to_s.empty?
1155
1135
 
1156
- # The session list is served by every live runtime, so an untyped 404
1136
+ # The session list is served by every live Computer sandbox, so an untyped 404
1157
1137
  # there means the gateway no longer routes to it.
1158
1138
  operation = Operations.match(:computer, response.env.method.to_s.upcase, response.env.url.path)
1159
- raise RuntimeGoneError.new(original), cause: original if operation&.id == "listSessions"
1139
+ raise SandboxGoneError.new(original), cause: original if operation&.id == "listSessions"
1160
1140
 
1161
1141
  # Healthy V2 calls need no discovery round trip. Qualify only an
1162
1142
  # ambiguous 404, without retrying the original operation.
@@ -1185,7 +1165,7 @@ module PineSandbox
1185
1165
  # edge / the ingress), fall back to the older shape so
1186
1166
  # existing callers that rescue ApiError / UnauthorizedError /
1187
1167
  # NotFoundError keep working unchanged.
1188
- # The RESOURCE spine for every error this adapter raises: WHICH Computer (@runtime_id,
1168
+ # The RESOURCE spine for every error this adapter raises: WHICH Computer (@sandbox_id,
1189
1169
  # held by the adapter) and WHICH operation ("<METHOD> <path>", off the Faraday env). We
1190
1170
  # attach it to EVERY raised path so an integrator's generic handler sees the resource +
1191
1171
  # op with no extra plumbing. request_id is the secondary precision handle.
@@ -1200,9 +1180,9 @@ module PineSandbox
1200
1180
  begin
1201
1181
  raise_for_status!(response)
1202
1182
  rescue PineSandbox::ApiError => e
1203
- e.attach_context!(runtime_id: @runtime_id, op: op)
1183
+ e.attach_context!(sandbox_id: @sandbox_id, op: op)
1204
1184
  if e.problem_type == "/errors/sandbox-not-found"
1205
- raise PineSandbox::RuntimeGoneError.new(e), cause: e
1185
+ raise PineSandbox::SandboxGoneError.new(e), cause: e
1206
1186
  end
1207
1187
  raise
1208
1188
  end
@@ -1222,7 +1202,7 @@ module PineSandbox
1222
1202
  if response.status == 408 || response.status == 504
1223
1203
  message = parse_legacy_error_message(response.body)
1224
1204
  timeout = PineSandbox::TimeoutError.new(
1225
- "Computer request failed: status=#{response.status} message=#{message}#{context_suffix(@runtime_id, op, rid)}"
1205
+ "Computer request failed: status=#{response.status} message=#{message}#{context_suffix(@sandbox_id, op, rid)}"
1226
1206
  )
1227
1207
  timeout.retry_after = retry_after_seconds(response)
1228
1208
  raise timeout
@@ -1240,7 +1220,7 @@ module PineSandbox
1240
1220
  else PineSandbox::ApiError
1241
1221
  end
1242
1222
  ext = {}
1243
- ext["sandbox_id"] = @runtime_id if @runtime_id && !@runtime_id.to_s.empty?
1223
+ ext["sandbox_id"] = @sandbox_id if @sandbox_id && !@sandbox_id.to_s.empty?
1244
1224
  ext["op"] = op if op && !op.to_s.empty?
1245
1225
  ext["request_id"] = rid if rid
1246
1226
  raise klass.new(
@@ -1274,11 +1254,11 @@ module PineSandbox
1274
1254
  end
1275
1255
 
1276
1256
  # Resource-first troubleshooting suffix for the message-only error paths (TimeoutError
1277
- # has no problem_extensions): runtime (WHICH Computer runtime) → op (WHICH operation) →
1257
+ # has no problem_extensions): sandbox (WHICH Computer sandbox) → op (WHICH operation) →
1278
1258
  # request_id. Each part only when present; "" when all absent.
1279
- def context_suffix(runtime_id, op, rid)
1259
+ def context_suffix(sandbox_id, op, rid)
1280
1260
  parts = []
1281
- parts << "runtime=#{runtime_id}" if runtime_id && !runtime_id.to_s.empty?
1261
+ parts << "sandbox=#{sandbox_id}" if sandbox_id && !sandbox_id.to_s.empty?
1282
1262
  parts << "op=#{op}" if op && !op.to_s.empty?
1283
1263
  parts << "request_id=#{rid}" if rid && !rid.to_s.empty?
1284
1264
  parts.empty? ? "" : " (#{parts.join(', ')})"
@@ -1297,39 +1277,35 @@ module PineSandbox
1297
1277
  end
1298
1278
 
1299
1279
  # Faraday connection to the Computer's gateway host. The base URL sets the
1300
- # Host (and TLS SNI) to `<runtime_id>.computer.<zone>` — covered by the
1280
+ # Host (and TLS SNI) to `<sandbox_id>.computer.<zone>` — covered by the
1301
1281
  # gateway wildcard cert `*.computer.<zone>`. Port-less and header-less: the
1302
1282
  # gateway routes the subdomain to the pod and the PATH selects the role; no
1303
1283
  # API key (coord gates every route on the bearer).
1304
1284
  def connection
1305
1285
  @connection ||= @zone.faraday(@host) do |conn|
1306
- conn.headers["User-Agent"] = "pinesandbox-ruby/#{PineSandbox::VERSION}"
1286
+ conn.headers["User-Agent"] = PineSandbox::USER_AGENT
1307
1287
  conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
1308
1288
  conn.options.timeout = DEFAULT_HTTP_TIMEOUT
1309
1289
  conn.adapter Faraday.default_adapter
1310
1290
  end
1311
1291
  end
1312
1292
 
1313
- # Long-lived stream connection — disables the read timeout. The default
1314
- # `connection` above ships a 30s `options.timeout`; for SSE that's a
1315
- # papercut (a model-thinking turn or sparse control-event stream
1316
- # legitimately exceeds 30s of silence). Coord ships periodic `: ka`
1317
- # keepalive comments (every 20s) which reset Net::HTTP's read timer,
1318
- # so even with the default this rarely fires in practice — but the
1319
- # principled fix is to make the streaming socket actually long-lived.
1293
+ # Long-lived stream connection. Its read timeout is an idle watchdog: coord
1294
+ # sends a `: ka` keepalive comment every 20s, and each read resets the
1295
+ # timer, so a quiet but healthy stream never trips it. A connection that
1296
+ # sends nothing for STREAM_IDLE_TIMEOUT is half-open; the timeout raises
1297
+ # Faraday::TimeoutError and follow_events reconnects from the cursor.
1320
1298
  #
1321
- # Faraday 2.x trap: `req.options.timeout = nil` does NOT mean "no
1322
- # timeout" on the Net::HTTP adapter. faraday-net_http 3.x only sets
1323
- # `http.read_timeout` when the option is truthy; nil falls through to
1324
- # Ruby's 60s default. The fix is to set it explicitly via the adapter
1299
+ # Faraday 2.x trap: `req.options.timeout` is not honored the way it reads
1300
+ # on the Net::HTTP adapter, so the read timeout is set in the adapter
1325
1301
  # block, which the adapter calls AFTER applying request_timeout.
1326
1302
  def streaming_connection
1327
1303
  @streaming_connection ||= @zone.faraday(@host) do |conn|
1328
- conn.headers["User-Agent"] = "pinesandbox-ruby/#{PineSandbox::VERSION}"
1304
+ conn.headers["User-Agent"] = PineSandbox::USER_AGENT
1329
1305
  conn.options.open_timeout = DEFAULT_OPEN_TIMEOUT
1330
1306
  # DELIBERATELY no conn.options.timeout — adapter block is authoritative.
1331
1307
  conn.adapter Faraday.default_adapter do |http|
1332
- http.read_timeout = nil
1308
+ http.read_timeout = STREAM_IDLE_TIMEOUT
1333
1309
  end
1334
1310
  end
1335
1311
  end
@@ -165,11 +165,11 @@ module PineSandbox
165
165
  end
166
166
 
167
167
  # How the turn ended, including with a question (a `result` frame): the
168
- # same Hash the turn's "result" holds, or nil for another event.
168
+ # same AgentResult the turn's #result returns, or nil for another event.
169
169
  def result
170
170
  return nil unless type == "result" && payload.is_a?(Hash)
171
171
 
172
- payload
172
+ AgentResult.wrap(payload)
173
173
  end
174
174
 
175
175
  private
@@ -1,6 +1,29 @@
1
1
  require "time"
2
2
 
3
3
  module PineSandbox
4
+ # How a turn ended (spec PublicTurnResult), from AgentTurn#result and
5
+ # AgentEvent#result: the result's own string-keyed Hash, extended with these
6
+ # readers. It stays a plain Hash, so a change to it is kept and YAML and JSON
7
+ # store it as they did before the readers existed. A frozen result can't be
8
+ # extended, so it reads as a frozen copy.
9
+ module AgentResult
10
+ def self.wrap(value)
11
+ return value unless value.is_a?(Hash)
12
+ return value if value.is_a?(self)
13
+ return value.dup.extend(self).freeze if value.frozen?
14
+
15
+ value.extend(self)
16
+ end
17
+
18
+ def terminal_reason = self["terminal_reason"]
19
+ # The agent's closing message; the question for "needs_input".
20
+ def summary = self["summary"]
21
+ def artifacts = self["artifacts"]
22
+ # Saved paths: client.artifacts(ref["computer_id"]).download(ref["path"], destination).
23
+ def author = self["author"]
24
+ def usage = self["usage"]
25
+ end
26
+
4
27
  # One unit of agent work (spec PublicTurn), returned by AgentMode#run,
5
28
  # #wait, #answer, #steer and #cancel. A turn starts when a run or an answer is
6
29
  # accepted and ends when the agent finishes, fails, is canceled or asks a
@@ -25,8 +48,8 @@ module PineSandbox
25
48
  def state = self["state"]
26
49
  # The turn this turn's answer opened; present once it is "answered".
27
50
  def next_turn_id = self["next_turn_id"]
28
- # How the turn ended, with "usage"; nil while it runs.
29
- def result = self["result"]
51
+ # How the turn ended, an AgentResult with its usage; nil while it runs.
52
+ def result = AgentResult.wrap(self["result"])
30
53
  def first_event_id = self["first_event_id"]
31
54
  def last_event_id = self["last_event_id"]
32
55