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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +222 -161
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +90 -114
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +25 -2
- data/lib/pinesandbox/artifacts.rb +776 -0
- data/lib/pinesandbox/attach_credentials_source.rb +58 -16
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +75 -37
- data/lib/pinesandbox/computer.rb +146 -116
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +3 -0
- data/lib/pinesandbox/errors.rb +83 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/models/artifact_file.rb +40 -0
- data/lib/pinesandbox/models/computer_session.rb +7 -3
- data/lib/pinesandbox/operations.rb +2 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +16 -20
- data/lib/pinesandbox/session.rb +39 -4
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/streamed_read.rb +80 -0
- data/lib/pinesandbox/version.rb +3 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +4 -2
- data/pinesandbox.gemspec +5 -3
- metadata +11 -6
|
@@ -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
|
-
#
|
|
18
|
-
# selects the
|
|
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
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# The
|
|
41
|
-
# (derived from `zone`), port-less and
|
|
42
|
-
#
|
|
43
|
-
# `
|
|
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 :
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(@
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
|
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[:
|
|
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 +
|
|
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
|
-
|
|
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?
|
|
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
|
|
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?(
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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(@
|
|
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
|
|
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?(
|
|
1134
|
+
raise if original.is_a?(SandboxGoneError) || !original.problem_type.to_s.empty?
|
|
1155
1135
|
|
|
1156
|
-
# The session list is served by every live
|
|
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
|
|
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 (@
|
|
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!(
|
|
1183
|
+
e.attach_context!(sandbox_id: @sandbox_id, op: op)
|
|
1204
1184
|
if e.problem_type == "/errors/sandbox-not-found"
|
|
1205
|
-
raise PineSandbox::
|
|
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(@
|
|
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"] = @
|
|
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):
|
|
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(
|
|
1259
|
+
def context_suffix(sandbox_id, op, rid)
|
|
1280
1260
|
parts = []
|
|
1281
|
-
parts << "
|
|
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 `<
|
|
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"] =
|
|
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
|
|
1314
|
-
# `
|
|
1315
|
-
#
|
|
1316
|
-
#
|
|
1317
|
-
#
|
|
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
|
|
1322
|
-
#
|
|
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"] =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|