pinesandbox 0.0.1 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- metadata +155 -10
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
module Models
|
|
3
|
+
# Computer session — the per-window slice of a Pine CUA computer.
|
|
4
|
+
#
|
|
5
|
+
# Returned by {PineSandbox::Computer#create_session}. Holds the
|
|
6
|
+
# session token (ps_*) used to drive that session's tabs and exec surface.
|
|
7
|
+
# The computer-level token (ct_*) is intentionally NOT held here —
|
|
8
|
+
# ct_ scope is the {ComputerSandbox}'s, not the session's.
|
|
9
|
+
Session = Struct.new(
|
|
10
|
+
:name,
|
|
11
|
+
:label,
|
|
12
|
+
:token,
|
|
13
|
+
:session_dir,
|
|
14
|
+
:browser,
|
|
15
|
+
:blind,
|
|
16
|
+
:created_at,
|
|
17
|
+
:last_active_at,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
) do
|
|
20
|
+
def self.from_payload(payload)
|
|
21
|
+
return nil unless payload
|
|
22
|
+
browser_payload = payload["browser"]
|
|
23
|
+
new(
|
|
24
|
+
name: payload["name"],
|
|
25
|
+
label: payload["label"],
|
|
26
|
+
token: payload["token"],
|
|
27
|
+
session_dir: payload["session_dir"],
|
|
28
|
+
browser: browser_payload && Browser.from_payload(browser_payload),
|
|
29
|
+
blind: payload.fetch("blind", false),
|
|
30
|
+
created_at: payload["created_at"] && Time.parse(payload["created_at"]),
|
|
31
|
+
last_active_at: payload["last_active_at"] && Time.parse(payload["last_active_at"])
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def browser?
|
|
36
|
+
!browser.nil?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Browser = Struct.new(
|
|
41
|
+
:primary_tab_id,
|
|
42
|
+
:owned_tab_ids,
|
|
43
|
+
:window_id,
|
|
44
|
+
:started_at,
|
|
45
|
+
keyword_init: true
|
|
46
|
+
) do
|
|
47
|
+
def self.from_payload(payload)
|
|
48
|
+
new(
|
|
49
|
+
primary_tab_id: payload["primary_tab_id"],
|
|
50
|
+
owned_tab_ids: payload.fetch("owned_tab_ids", []),
|
|
51
|
+
window_id: payload["window_id"],
|
|
52
|
+
started_at: payload["started_at"] && Time.parse(payload["started_at"])
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
module Models
|
|
3
|
+
# Tab inside a computer session. Returned by {PineSandbox::Session#tabs} and
|
|
4
|
+
# the result of {PineSandbox::Session#create_tab}.
|
|
5
|
+
Tab = Struct.new(
|
|
6
|
+
:target_id,
|
|
7
|
+
:url,
|
|
8
|
+
:title,
|
|
9
|
+
:label,
|
|
10
|
+
:active,
|
|
11
|
+
:window_id,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
) do
|
|
14
|
+
def self.from_payload(payload)
|
|
15
|
+
new(
|
|
16
|
+
target_id: payload["target_id"],
|
|
17
|
+
url: payload["url"],
|
|
18
|
+
title: payload["title"],
|
|
19
|
+
label: payload["label"],
|
|
20
|
+
active: payload.fetch("active", false),
|
|
21
|
+
window_id: payload["window_id"]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active?
|
|
26
|
+
active
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module PineSandbox
|
|
4
|
+
module Models
|
|
5
|
+
# File or directory entry in a session workdir listing.
|
|
6
|
+
#
|
|
7
|
+
# Returned by {PineSandbox::Session#list_files}. Paths are relative to the
|
|
8
|
+
# session workdir root; absolute container paths are never exposed.
|
|
9
|
+
FileEntry = Struct.new(
|
|
10
|
+
:relative_path,
|
|
11
|
+
:name,
|
|
12
|
+
:is_dir,
|
|
13
|
+
:size,
|
|
14
|
+
:mode,
|
|
15
|
+
:modified_at,
|
|
16
|
+
keyword_init: true
|
|
17
|
+
) do
|
|
18
|
+
def self.from_payload(payload)
|
|
19
|
+
new(
|
|
20
|
+
relative_path: payload["relative_path"],
|
|
21
|
+
name: payload["name"],
|
|
22
|
+
is_dir: !!payload["is_dir"],
|
|
23
|
+
size: payload["size"],
|
|
24
|
+
mode: payload["mode"],
|
|
25
|
+
modified_at: payload["modified_at"] && Time.parse(payload["modified_at"])
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dir?
|
|
30
|
+
is_dir
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def is_dir?
|
|
34
|
+
is_dir
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# @api private — the public operations this SDK sends: operationId, method,
|
|
3
|
+
# path template, host role, whether a request may be sent again after a
|
|
4
|
+
# transport fault, and whether that rests on an Idempotency-Key. A copy of the server-SDK slice of
|
|
5
|
+
# sdks/pine-computer/contract/operations.json, which the test suite checks
|
|
6
|
+
# this table against; the gem ships without the contract files.
|
|
7
|
+
module Operations
|
|
8
|
+
# `keyed`: the operation's replay safety rests on an Idempotency-Key
|
|
9
|
+
# header, so a request without one is sent once.
|
|
10
|
+
Operation = Struct.new(:id, :method, :path, :host, :replay_safe, :keyed, :pattern, :specificity, keyword_init: true) do
|
|
11
|
+
def replay_safe? = replay_safe
|
|
12
|
+
|
|
13
|
+
# Whether this request may be sent again after a transport fault.
|
|
14
|
+
def resendable?(idempotency_key)
|
|
15
|
+
replay_safe && (!keyed || !idempotency_key.to_s.empty?)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.build(id, method, path, host, replay_safe, keyed)
|
|
20
|
+
pattern = Regexp.new("\\A#{path.split(/(\{[^}]+\})/).map { |part| part.start_with?("{") ? "[^/]+" : Regexp.escape(part) }.join}\\z")
|
|
21
|
+
# Literal path segments outweigh parameters, so /v1/skills/drafts wins
|
|
22
|
+
# over /v1/skills/{name}.
|
|
23
|
+
specificity = path.split("/").count { |segment| !segment.start_with?("{") }
|
|
24
|
+
Operation.new(id: id, method: method, path: path, host: host, replay_safe: replay_safe, keyed: keyed,
|
|
25
|
+
pattern: pattern, specificity: specificity).freeze
|
|
26
|
+
end
|
|
27
|
+
private_class_method :build
|
|
28
|
+
|
|
29
|
+
TABLE = [
|
|
30
|
+
["activateSkill", "POST", "/v1/skills/{name}/activate", :computer, false, false],
|
|
31
|
+
["answerAgentV2", "POST", "/v2/sessions/{name}/agent/answer", :computer, true, false],
|
|
32
|
+
["authorSkillDraft", "POST", "/v1/sessions/{name}/skills", :computer, false, false],
|
|
33
|
+
["bindComputer", "POST", "/v1/coord/bind", :computer, false, false],
|
|
34
|
+
["cancelAgentV2", "POST", "/v2/sessions/{name}/agent/cancel", :computer, true, false],
|
|
35
|
+
["cancelAuthorRun", "POST", "/v2/sessions/{name}/skills/author/{author_id}/cancel", :computer, false, false],
|
|
36
|
+
["captureCheckpoint", "POST", "/v1/coord/capture", :computer, false, false],
|
|
37
|
+
["claimDownloadOrphan", "POST", "/downloads/orphans/{guid}/claim", :computer, false, false],
|
|
38
|
+
["computerUse", "POST", "/v1/sessions/{name}/computer-use", :computer, false, false],
|
|
39
|
+
["createComputer", "POST", "/computer-sandboxes", :control, true, false],
|
|
40
|
+
["createDiagnostic", "POST", "/v1/diagnostics", :control, true, true],
|
|
41
|
+
["createSession", "POST", "/sessions", :computer, false, false],
|
|
42
|
+
["deactivateSkill", "POST", "/v1/skills/{name}/deactivate", :computer, false, false],
|
|
43
|
+
["decidePasskeyCeremony", "POST", "/v1/passkeys/ceremonies/{ceremony_id}", :computer, false, false],
|
|
44
|
+
["decideSessionPasskeyCeremony", "POST", "/v1/sessions/{name}/passkeys/ceremonies/{ceremony_id}", :computer, false, false],
|
|
45
|
+
["declareSessionPasskeyIntent", "POST", "/v1/sessions/{name}/passkeys/intents", :computer, false, false],
|
|
46
|
+
["deleteComputer", "DELETE", "/v1/computers/{computer_id}", :control, true, false],
|
|
47
|
+
["deleteOrphan", "DELETE", "/orphans/{target_id}", :computer, true, false],
|
|
48
|
+
["deletePasskey", "DELETE", "/v1/passkeys/{credential_id}", :computer, true, false],
|
|
49
|
+
["deleteSession", "DELETE", "/sessions/{name}", :computer, true, false],
|
|
50
|
+
["deleteSkillVersion", "DELETE", "/v1/skills/{name}/versions/{version}", :computer, true, false],
|
|
51
|
+
["destroyComputer", "DELETE", "/sandboxes/{sandboxId}", :control, true, false],
|
|
52
|
+
["discardDownloadOrphan", "DELETE", "/downloads/orphans/{guid}", :computer, true, false],
|
|
53
|
+
["getAgentEventsPageV2", "GET", "/v2/sessions/{name}/agent/events/page", :computer, true, false],
|
|
54
|
+
["getAgentEventsV2", "GET", "/v2/sessions/{name}/agent/events", :computer, true, false],
|
|
55
|
+
["getAgentTurnV2", "GET", "/v2/sessions/{name}/agent/turns/{turn_id}", :computer, true, false],
|
|
56
|
+
["getAgentTurnsV2", "GET", "/v2/sessions/{name}/agent/turns", :computer, true, false],
|
|
57
|
+
["getAgentV2", "GET", "/v2/sessions/{name}/agent", :computer, true, false],
|
|
58
|
+
["getArtifact", "GET", "/v1/sessions/{name}/artifacts/{artifact_id}", :computer, true, false],
|
|
59
|
+
["getBindPubkey", "GET", "/v1/coord/bind-pubkey", :computer, true, false],
|
|
60
|
+
["getCapabilities", "GET", "/v1/capabilities", :computer, true, false],
|
|
61
|
+
["getComputer", "GET", "/sandboxes/{sandboxId}", :control, true, false],
|
|
62
|
+
["getComputerBinding", "GET", "/v1/computers/{computer_id}", :control, true, false],
|
|
63
|
+
["getControl", "GET", "/v1/sessions/{name}/control", :computer, true, false],
|
|
64
|
+
["getControlEvents", "GET", "/v1/sessions/{name}/control/events", :computer, true, false],
|
|
65
|
+
["getDiagnostic", "GET", "/v1/diagnostics/{id}", :control, true, false],
|
|
66
|
+
["getHandoff", "GET", "/v1/sessions/{name}/handoffs/{handoff_id}", :computer, true, false],
|
|
67
|
+
["getProjectPolicy", "GET", "/v1/project-policy", :control, true, false],
|
|
68
|
+
["getSession", "GET", "/sessions/{name}", :computer, true, false],
|
|
69
|
+
["getSessionFile", "GET", "/v1/sessions/{name}/files", :computer, true, false],
|
|
70
|
+
["getSkill", "GET", "/v1/skills/{name}", :computer, true, false],
|
|
71
|
+
["getSkillVersion", "GET", "/v1/skills/{name}/versions/{version}", :computer, true, false],
|
|
72
|
+
["getState", "GET", "/state", :computer, true, false],
|
|
73
|
+
["learnSkill", "POST", "/v2/sessions/{name}/learn", :computer, false, false],
|
|
74
|
+
["listAllSkillVersions", "GET", "/v1/skills/versions", :computer, true, false],
|
|
75
|
+
["listArtifacts", "GET", "/v1/sessions/{name}/artifacts", :computer, true, false],
|
|
76
|
+
["listAvailableLocations", "GET", "/v1/locations", :control, true, false],
|
|
77
|
+
["listDownloadOrphans", "GET", "/downloads/orphans", :computer, true, false],
|
|
78
|
+
["listHandoffs", "GET", "/v1/sessions/{name}/handoffs", :computer, true, false],
|
|
79
|
+
["listOrphans", "GET", "/orphans", :computer, true, false],
|
|
80
|
+
["listPasskeyCeremonies", "GET", "/v1/passkeys/ceremonies", :computer, true, false],
|
|
81
|
+
["listPasskeys", "GET", "/v1/passkeys", :computer, true, false],
|
|
82
|
+
["listSessionFiles", "GET", "/v1/sessions/{name}/files/list", :computer, true, false],
|
|
83
|
+
["listSessionPasskeyCeremonies", "GET", "/v1/sessions/{name}/passkeys/ceremonies", :computer, true, false],
|
|
84
|
+
["listSessions", "GET", "/sessions", :computer, true, false],
|
|
85
|
+
["listSkillDrafts", "GET", "/v1/skills/drafts", :computer, true, false],
|
|
86
|
+
["listSkillVersions", "GET", "/v1/skills/{name}/versions", :computer, true, false],
|
|
87
|
+
["listSkills", "GET", "/v1/skills", :computer, true, false],
|
|
88
|
+
["mintAttachCredentials", "POST", "/v1/computers/{computer_id}/attach-credentials", :control, true, true],
|
|
89
|
+
["mintControlToken", "POST", "/v1/control-token", :control, true, false],
|
|
90
|
+
["mintDesktopToken", "POST", "/v1/sessions/{name}/desktop-token", :computer, false, false],
|
|
91
|
+
["observeSession", "POST", "/v1/sessions/{name}/observe", :computer, false, false],
|
|
92
|
+
["patchControl", "PATCH", "/v1/sessions/{name}/control", :computer, false, true],
|
|
93
|
+
["pauseComputer", "POST", "/sandboxes/{sandboxId}/pause", :control, false, false],
|
|
94
|
+
["postControlNotify", "POST", "/v1/sessions/{name}/control/notify", :computer, false, false],
|
|
95
|
+
["putSessionAttachment", "PUT", "/v1/sessions/{name}/files", :computer, false, false],
|
|
96
|
+
["refineSkill", "POST", "/v2/sessions/{name}/refine", :computer, false, false],
|
|
97
|
+
["registerComputer", "POST", "/v1/computers", :control, true, false],
|
|
98
|
+
["resetAgentThreadV2", "POST", "/v2/sessions/{name}/agent/reset", :computer, false, false],
|
|
99
|
+
["resumeComputer", "POST", "/sandboxes/{sandboxId}/resume", :control, false, false],
|
|
100
|
+
["runAgentV2", "POST", "/v2/sessions/{name}/agent/run", :computer, true, true],
|
|
101
|
+
["sessionEpoch", "GET", "/sessions/{name}/epoch", :computer, true, false],
|
|
102
|
+
["sessionExec", "POST", "/sessions/{name}/exec", :computer, false, false],
|
|
103
|
+
["sessionFocus", "POST", "/sessions/{name}/focus", :computer, false, false],
|
|
104
|
+
["sessionTerminalRecreate", "POST", "/sessions/{name}/terminal/recreate", :computer, false, false],
|
|
105
|
+
["setComputerIdleTimeout", "PUT", "/sandboxes/{sandboxId}/idle-timeout", :control, false, false],
|
|
106
|
+
["setSkillEnabled", "PUT", "/v1/skills/{name}/enabled", :computer, false, false],
|
|
107
|
+
["steerAgentV2", "POST", "/v2/sessions/{name}/agent/steer", :computer, false, false],
|
|
108
|
+
["stopComputerRuntime", "POST", "/sandboxes/{sandboxId}/stop", :control, true, false],
|
|
109
|
+
["streamAuthorEventsV2", "GET", "/v2/sessions/{name}/skills/author/{author_id}/events", :computer, true, false],
|
|
110
|
+
["tabsCreate", "POST", "/sessions/{name}/tabs", :computer, false, false],
|
|
111
|
+
["tabsDelete", "DELETE", "/sessions/{name}/tabs/{target_id}", :computer, true, false],
|
|
112
|
+
["tabsList", "GET", "/sessions/{name}/tabs", :computer, true, false],
|
|
113
|
+
["tabsPatch", "PATCH", "/sessions/{name}/tabs/{target_id}", :computer, false, false],
|
|
114
|
+
["teachSkill", "POST", "/v2/sessions/{name}/teach", :computer, false, false],
|
|
115
|
+
["uploadArtifact", "POST", "/v1/sessions/{name}/artifacts", :computer, false, false],
|
|
116
|
+
["uploadFile", "POST", "/v1/sessions/{name}/upload_file", :computer, false, false],
|
|
117
|
+
["zipArtifacts", "GET", "/v1/sessions/{name}/artifacts/zip", :computer, true, false],
|
|
118
|
+
].map { |row| build(*row) }.freeze
|
|
119
|
+
|
|
120
|
+
BY_ID = TABLE.to_h { |operation| [operation.id, operation] }.freeze
|
|
121
|
+
|
|
122
|
+
module_function
|
|
123
|
+
|
|
124
|
+
def fetch(id) = BY_ID.fetch(id)
|
|
125
|
+
|
|
126
|
+
# The operation a request to `path` (no query) on a `host` role matches, or
|
|
127
|
+
# nil for a route outside the table.
|
|
128
|
+
def match(host, method, path)
|
|
129
|
+
method = method.to_s.upcase
|
|
130
|
+
TABLE.select { |op| op.host == host && op.method == method && op.pattern.match?(path) }
|
|
131
|
+
.max_by(&:specificity)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# Turns an HTTP error response into the matching typed error: the problem
|
|
3
|
+
# type's class when the body is an RFC 9457 problem detail Pine knows,
|
|
4
|
+
# otherwise the status tier.
|
|
5
|
+
module ResponseHandler
|
|
6
|
+
# Standard RFC 9457 problem-detail fields. Anything outside this
|
|
7
|
+
# set is treated as a "problem extension" and surfaced via
|
|
8
|
+
# ApiError#problem_extensions so callers can read e.g.
|
|
9
|
+
# `err.handoff_id` or `err.field`.
|
|
10
|
+
PROBLEM_DETAIL_STANDARD_KEYS = %w[type title status detail instance].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def raise_for_status!(response_or_status, body = nil)
|
|
15
|
+
if response_or_status.respond_to?(:status)
|
|
16
|
+
status = response_or_status.status
|
|
17
|
+
parsed_body = parse_error_body(response_or_status.body)
|
|
18
|
+
raw_body = response_or_status.body.to_s
|
|
19
|
+
else
|
|
20
|
+
status = response_or_status
|
|
21
|
+
parsed_body = parse_error_body(body)
|
|
22
|
+
raw_body = body.is_a?(String) ? body : JSON.generate(body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Prefer the typed problem-detail class when the response body
|
|
26
|
+
# carries an RFC 9457 `type` slug we know about; fall back to
|
|
27
|
+
# the status-tier class otherwise. Existing rescues against the
|
|
28
|
+
# status-tier classes keep working — typed classes inherit from
|
|
29
|
+
# them (see PineSandbox::ApiError).
|
|
30
|
+
problem_type = parsed_body["type"] if parsed_body.is_a?(Hash)
|
|
31
|
+
error_class = PineSandbox.problem_class_for(problem_type) || status_tier_class_for(status)
|
|
32
|
+
|
|
33
|
+
problem_ext =
|
|
34
|
+
if parsed_body.is_a?(Hash) && problem_type
|
|
35
|
+
parsed_body.reject { |k, _| PROBLEM_DETAIL_STANDARD_KEYS.include?(k.to_s) }
|
|
36
|
+
else
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# X-Request-Id is set on EVERY response by the server's outermost
|
|
41
|
+
# middleware (0C error contract). A conformant ProblemDetail body echoes
|
|
42
|
+
# it as `request_id`, but raw / non-typed error bodies carry it only in
|
|
43
|
+
# the header — capture it so ApiError#request_id is populated for any
|
|
44
|
+
# error, not just typed problem details. The body value (if present)
|
|
45
|
+
# wins; both are the same id by contract.
|
|
46
|
+
if response_or_status.respond_to?(:headers)
|
|
47
|
+
rid = response_or_status.headers["x-request-id"] || response_or_status.headers["X-Request-Id"]
|
|
48
|
+
problem_ext["request_id"] ||= rid if rid && !rid.to_s.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise error_class.new(
|
|
52
|
+
status: status,
|
|
53
|
+
error_code: parsed_body["code"] || parsed_body["errorCode"] || parsed_body["error"],
|
|
54
|
+
error_message: parsed_body["message"] || parsed_body["errorMessage"] ||
|
|
55
|
+
(problem_type ? parsed_body["detail"] : nil),
|
|
56
|
+
response_body: raw_body,
|
|
57
|
+
problem_type: problem_type,
|
|
58
|
+
problem_extensions: problem_ext
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def status_tier_class_for(status)
|
|
63
|
+
case status
|
|
64
|
+
when 400 then BadRequestError
|
|
65
|
+
when 401 then UnauthorizedError
|
|
66
|
+
when 403 then ForbiddenError
|
|
67
|
+
when 404 then NotFoundError
|
|
68
|
+
when 409 then ConflictError
|
|
69
|
+
when 410 then GoneError
|
|
70
|
+
when 412 then PreconditionFailedError
|
|
71
|
+
when 422 then UnprocessableEntityError
|
|
72
|
+
when 428 then PreconditionRequiredError
|
|
73
|
+
when 503 then ServiceUnavailableError
|
|
74
|
+
when 500..599 then ServerError
|
|
75
|
+
else ApiError
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_error_body(body)
|
|
80
|
+
return body if body.is_a?(Hash)
|
|
81
|
+
return {} if body.nil? || body == ""
|
|
82
|
+
|
|
83
|
+
JSON.parse(body)
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
{ "message" => body.to_s }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module PineSandbox
|
|
2
|
+
# An observation of one runtime, not the identity or a cached health verdict.
|
|
3
|
+
RuntimeLifetime = Data.define(:idle_timeout_seconds, :min_idle_timeout_seconds,
|
|
4
|
+
:max_idle_timeout_seconds, :max_runtime_age_seconds,
|
|
5
|
+
:stop_at, :deadline) do
|
|
6
|
+
def self.from_payload(value)
|
|
7
|
+
return unless value
|
|
8
|
+
|
|
9
|
+
new(idle_timeout_seconds: value.fetch("idle_timeout_seconds"),
|
|
10
|
+
min_idle_timeout_seconds: value.fetch("min_idle_timeout_seconds"),
|
|
11
|
+
max_idle_timeout_seconds: value.fetch("max_idle_timeout_seconds"),
|
|
12
|
+
max_runtime_age_seconds: value.fetch("max_runtime_age_seconds"),
|
|
13
|
+
stop_at: Time.iso8601(value.fetch("stop_at")), deadline: Time.iso8601(value.fetch("deadline")))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RuntimeDetails = Data.define(:id, :state, :reason, :message, :retryable,
|
|
18
|
+
:operation_id, :created_at, :expires_at, :lifecycle, :runtime_revision) do
|
|
19
|
+
def self.from_runtime(info)
|
|
20
|
+
new(id: info.id, state: info.status, reason: info.reason, message: info.message,
|
|
21
|
+
retryable: info.retryable, operation_id: info.operation_id,
|
|
22
|
+
created_at: info.created_at, expires_at: info.expires_at, lifecycle: info.lifecycle, runtime_revision: info.runtime_revision)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require_relative "errors"
|
|
3
|
+
require_relative "clock"
|
|
4
|
+
|
|
5
|
+
module PineSandbox
|
|
6
|
+
# Lifecycle handle for a Computer's runtime, backed by the project-scoped
|
|
7
|
+
# ControlPlane. A powered-on Computer holds one of these;
|
|
8
|
+
# Computer#stop/#kill/#alive? delegate here. It carries NO data-plane knowledge —
|
|
9
|
+
# the coordinator host is derived separately from the Zone.
|
|
10
|
+
# @api private
|
|
11
|
+
class RuntimeHandle
|
|
12
|
+
READY = :running
|
|
13
|
+
WAIT_FAILURE_STATES = %i[failed terminated unavailable].freeze
|
|
14
|
+
LIVE = %i[running pending stopping].freeze
|
|
15
|
+
DEFAULT_POLL_INTERVAL = 2
|
|
16
|
+
|
|
17
|
+
attr_reader :id
|
|
18
|
+
alias_method :runtime_id, :id
|
|
19
|
+
|
|
20
|
+
# `clock` returns monotonic seconds and `sleeper` waits; both default to
|
|
21
|
+
# the control plane's clock. `sandbox_id:` is the deprecated spelling of
|
|
22
|
+
# `runtime_id:`.
|
|
23
|
+
def initialize(control_plane:, runtime_id: nil, sandbox_id: nil, status: nil, clock: nil, sleeper: nil)
|
|
24
|
+
id = runtime_id || sandbox_id
|
|
25
|
+
raise ArgumentError, "runtime_id required" if id.to_s.empty?
|
|
26
|
+
|
|
27
|
+
source = control_plane.respond_to?(:clock) ? control_plane.clock : PineSandbox::SYSTEM_CLOCK
|
|
28
|
+
@control_plane = control_plane
|
|
29
|
+
@id = id.to_s
|
|
30
|
+
@status = status
|
|
31
|
+
@clock = clock || -> { source.monotonic }
|
|
32
|
+
@sleeper = sleeper || ->(seconds) { source.sleep(seconds) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Allocation may reuse Running from its create response. Memory resume must
|
|
36
|
+
# discard that observation and wait for the controller's new completion.
|
|
37
|
+
def wait_until_running(timeout:, interval: DEFAULT_POLL_INTERVAL)
|
|
38
|
+
return self if @status == READY
|
|
39
|
+
|
|
40
|
+
wait_until_state(:running, timeout: timeout, interval: interval)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pause(timeout: 360, interval: DEFAULT_POLL_INTERVAL)
|
|
44
|
+
transition_memory(:pause, :paused, timeout: timeout, interval: interval)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resume(timeout: 360, interval: DEFAULT_POLL_INTERVAL)
|
|
48
|
+
transition_memory(:resume, :running, timeout: timeout, interval: interval)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def status
|
|
52
|
+
refresh_status
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def set_idle_timeout(seconds:)
|
|
56
|
+
observed = runtime_details
|
|
57
|
+
raise BindError, "Runtime has no admitted lifetime policy; choose idle timeout on the next Start" unless observed.lifecycle
|
|
58
|
+
|
|
59
|
+
@control_plane.set_idle_timeout(@id, seconds: seconds, etag: observed.etag)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def runtime_details
|
|
63
|
+
@control_plane.get(@id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Observe existing intent before admitting a new operation. No SDK memory
|
|
67
|
+
# or saved operation cursor is needed to continue Stop from another backend.
|
|
68
|
+
def stop(wait_timeout: 390, interval: DEFAULT_POLL_INTERVAL)
|
|
69
|
+
return false if wait_timeout <= 0
|
|
70
|
+
|
|
71
|
+
deadline = @clock.call + wait_timeout
|
|
72
|
+
info = @control_plane.get(@id, request_timeout: wait_timeout)
|
|
73
|
+
return true if released?(info)
|
|
74
|
+
if info.status == :save_failed && !info.retryable
|
|
75
|
+
raise StopSaveError.new(Error.new(info.message), retryable: false)
|
|
76
|
+
end
|
|
77
|
+
unless %i[saving stopping].include?(info.status)
|
|
78
|
+
begin
|
|
79
|
+
@control_plane.stop(@id, operation_id: SecureRandom.uuid, request_timeout: remaining(deadline))
|
|
80
|
+
rescue PineSandbox::ConflictError
|
|
81
|
+
# A concurrent caller may have admitted Stop first. Join only that
|
|
82
|
+
# actual operation; a memory-transition conflict is still an error.
|
|
83
|
+
info = @control_plane.get(@id, request_timeout: remaining(deadline))
|
|
84
|
+
raise unless %i[saving stopping].include?(info.status)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
loop do
|
|
88
|
+
info = next_observation(info, deadline, interval)
|
|
89
|
+
return true if released?(info)
|
|
90
|
+
if info.status == :save_failed
|
|
91
|
+
raise StopSaveError.new(Error.new(info.message), retryable: info.retryable == true)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
rescue PineSandbox::NotFoundError
|
|
95
|
+
true
|
|
96
|
+
rescue PineSandbox::TimeoutError
|
|
97
|
+
raise if @clock.call < deadline
|
|
98
|
+
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Explicit saved-state recovery discards retained memory. It is allowed
|
|
103
|
+
# only after a recorded failure and is fenced by the observed HTTP ETag.
|
|
104
|
+
def release_failed_runtime(info, wait_timeout: 90, interval: DEFAULT_POLL_INTERVAL)
|
|
105
|
+
deadline = @clock.call + wait_timeout
|
|
106
|
+
unless info.status == :stopping
|
|
107
|
+
unless %i[save_failed unavailable failed terminated].include?(info.status) && info.reason != "CaptureTimeout"
|
|
108
|
+
raise PineSandbox::ConflictError.new(status: 409, error_message: "Runtime has no recorded failure; use Stop or Resume")
|
|
109
|
+
end
|
|
110
|
+
raise PineSandbox::Error, "Server did not supply a conditional release version" if info.etag.to_s.empty?
|
|
111
|
+
@control_plane.destroy(@id, if_match: info.etag, request_timeout: remaining(deadline))
|
|
112
|
+
end
|
|
113
|
+
wait_until_released(info, deadline, interval)
|
|
114
|
+
rescue PineSandbox::TimeoutError
|
|
115
|
+
raise if @clock.call < deadline
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Delete, then wait for the sandbox record to disappear. This confirms
|
|
120
|
+
# compute release only. Computer#stop uses the controller's graceful Stop.
|
|
121
|
+
# false retains the caller's handle when deletion remains unconfirmed.
|
|
122
|
+
def terminate(wait_timeout: 90, interval: DEFAULT_POLL_INTERVAL)
|
|
123
|
+
deadline = @clock.call + wait_timeout
|
|
124
|
+
return false if wait_timeout <= 0
|
|
125
|
+
|
|
126
|
+
@control_plane.destroy(@id, request_timeout: wait_timeout)
|
|
127
|
+
wait_until_released(nil, deadline, interval)
|
|
128
|
+
rescue PineSandbox::TimeoutError
|
|
129
|
+
raise if @clock.call < deadline
|
|
130
|
+
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Ungraceful force-recover: best-effort DELETE, no wait. Never raises.
|
|
135
|
+
def kill
|
|
136
|
+
@control_plane.destroy(@id)
|
|
137
|
+
true
|
|
138
|
+
rescue StandardError
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def alive?
|
|
143
|
+
LIVE.include?(refresh_status)
|
|
144
|
+
rescue StandardError
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Absence or explicit controller compute release permits dropping the handle.
|
|
149
|
+
# Generic terminal state, timeout, and errors are insufficient.
|
|
150
|
+
def gone?(request_timeout: 5)
|
|
151
|
+
released?(@control_plane.get(@id, request_timeout: request_timeout))
|
|
152
|
+
rescue PineSandbox::NotFoundError
|
|
153
|
+
true
|
|
154
|
+
rescue StandardError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def wait_until_released(info, deadline, interval)
|
|
161
|
+
loop do
|
|
162
|
+
return false if @clock.call >= deadline
|
|
163
|
+
begin
|
|
164
|
+
info = next_observation(info, deadline, interval)
|
|
165
|
+
return true if released?(info)
|
|
166
|
+
rescue PineSandbox::NotFoundError
|
|
167
|
+
return true
|
|
168
|
+
rescue StandardError
|
|
169
|
+
# A failed observation is not proof of deletion. Preserve the
|
|
170
|
+
# existing bounded retry without spinning after a transport error.
|
|
171
|
+
@sleeper.call([interval, remaining(deadline)].min)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
rescue PineSandbox::TimeoutError
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def next_observation(previous, deadline, interval)
|
|
179
|
+
timeout = remaining(deadline)
|
|
180
|
+
if previous
|
|
181
|
+
@control_plane.wait_for_change(@id, previous: previous, request_timeout: timeout, interval: interval)
|
|
182
|
+
else
|
|
183
|
+
@control_plane.get(@id, request_timeout: timeout)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def remaining(deadline)
|
|
188
|
+
timeout = deadline - @clock.call
|
|
189
|
+
raise PineSandbox::TimeoutError, "Computer #{@id} Stop wait ended; the server continues cleanup" if timeout <= 0
|
|
190
|
+
timeout
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def released?(info)
|
|
194
|
+
info.status == :terminated && info.reason == "COMPUTE_RELEASED"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def transition_memory(operation, target, timeout:, interval:)
|
|
198
|
+
raise ArgumentError, "timeout must be positive" unless timeout.positive?
|
|
199
|
+
|
|
200
|
+
deadline = @clock.call + timeout
|
|
201
|
+
accepted = @control_plane.public_send(operation, @id, request_timeout: timeout)
|
|
202
|
+
@status = nil
|
|
203
|
+
unless accepted
|
|
204
|
+
# Older Docker servers answer 409 for an already completed transition.
|
|
205
|
+
remaining = deadline - @clock.call
|
|
206
|
+
raise PineSandbox::ReadyTimeoutError, "Computer #{@id} #{operation} exceeded its deadline" if remaining <= 0
|
|
207
|
+
return self if refresh_status(request_timeout: remaining) == target
|
|
208
|
+
|
|
209
|
+
raise PineSandbox::ConflictError.new(status: 409, error_message: "Computer #{@id} cannot #{operation} in its current state")
|
|
210
|
+
end
|
|
211
|
+
wait_until_state(target, timeout: deadline - @clock.call, interval: interval)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def wait_until_state(target, timeout:, interval:)
|
|
215
|
+
deadline = @clock.call + timeout
|
|
216
|
+
info = nil
|
|
217
|
+
loop do
|
|
218
|
+
remaining = deadline - @clock.call
|
|
219
|
+
if remaining <= 0
|
|
220
|
+
raise PineSandbox::ReadyTimeoutError, "Computer #{@id} did not reach #{target} before the deadline (last state #{@status.inspect})"
|
|
221
|
+
end
|
|
222
|
+
begin
|
|
223
|
+
info = next_observation(info, deadline, interval)
|
|
224
|
+
state = @status = info.status
|
|
225
|
+
rescue PineSandbox::TimeoutError => error
|
|
226
|
+
raise error if @clock.call < deadline
|
|
227
|
+
|
|
228
|
+
raise PineSandbox::ReadyTimeoutError, "Computer #{@id} did not reach #{target} before the deadline"
|
|
229
|
+
end
|
|
230
|
+
return self if state == target
|
|
231
|
+
if WAIT_FAILURE_STATES.include?(state)
|
|
232
|
+
raise PineSandbox::RuntimeFailedError.new(
|
|
233
|
+
"Computer runtime #{@id} reported #{state.inspect} before reaching #{target}", runtime_id: @id, state: state
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def refresh_status(request_timeout: nil)
|
|
240
|
+
@status = if request_timeout
|
|
241
|
+
@control_plane.get(@id, request_timeout: request_timeout).status
|
|
242
|
+
else
|
|
243
|
+
@control_plane.get(@id).status
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Deprecated: use RuntimeHandle.
|
|
250
|
+
SandboxHandle = RuntimeHandle
|
|
251
|
+
deprecate_constant :SandboxHandle
|
|
252
|
+
end
|