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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/LICENSE +201 -0
  4. data/README.md +670 -4
  5. data/Rakefile +5 -0
  6. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1399 -0
  7. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  8. data/lib/pinesandbox/agent_events.rb +213 -0
  9. data/lib/pinesandbox/agent_mode.rb +252 -0
  10. data/lib/pinesandbox/agent_turn.rb +97 -0
  11. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  12. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  13. data/lib/pinesandbox/bind_hpke.rb +54 -0
  14. data/lib/pinesandbox/capture_keypair.rb +43 -0
  15. data/lib/pinesandbox/client.rb +469 -0
  16. data/lib/pinesandbox/clock.rb +51 -0
  17. data/lib/pinesandbox/computer.rb +1075 -0
  18. data/lib/pinesandbox/computer_binding.rb +51 -0
  19. data/lib/pinesandbox/computer_credentials.rb +175 -0
  20. data/lib/pinesandbox/connection.rb +26 -0
  21. data/lib/pinesandbox/control_events.rb +156 -0
  22. data/lib/pinesandbox/control_plane.rb +341 -0
  23. data/lib/pinesandbox/control_state.rb +31 -0
  24. data/lib/pinesandbox/control_token_source.rb +232 -0
  25. data/lib/pinesandbox/delegated_connection.rb +46 -0
  26. data/lib/pinesandbox/diagnostics.rb +83 -0
  27. data/lib/pinesandbox/drive_mode.rb +67 -0
  28. data/lib/pinesandbox/error_taxonomy.rb +105 -0
  29. data/lib/pinesandbox/errors.rb +505 -0
  30. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  31. data/lib/pinesandbox/models/artifact.rb +51 -0
  32. data/lib/pinesandbox/models/computer_session.rb +57 -0
  33. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  34. data/lib/pinesandbox/models/file_entry.rb +38 -0
  35. data/lib/pinesandbox/operations.rb +131 -0
  36. data/lib/pinesandbox/response_handler.rb +88 -0
  37. data/lib/pinesandbox/sandbox_details.rb +25 -0
  38. data/lib/pinesandbox/sandbox_handle.rb +247 -0
  39. data/lib/pinesandbox/session.rb +647 -0
  40. data/lib/pinesandbox/skills.rb +142 -0
  41. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  42. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  43. data/lib/pinesandbox/state_hpke.rb +57 -0
  44. data/lib/pinesandbox/version.rb +3 -0
  45. data/lib/pinesandbox/zone.rb +173 -0
  46. data/lib/pinesandbox.rb +40 -2
  47. data/pinesandbox.gemspec +49 -0
  48. metadata +157 -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,131 @@
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
+ ["computerUse", "POST", "/v1/sessions/{name}/computer-use", :computer, false, false],
38
+ ["createComputer", "POST", "/computer-sandboxes", :control, true, false],
39
+ ["createDiagnostic", "POST", "/v1/diagnostics", :control, true, true],
40
+ ["createSession", "POST", "/sessions", :computer, false, false],
41
+ ["deactivateSkill", "POST", "/v1/skills/{name}/deactivate", :computer, false, false],
42
+ ["decidePasskeyCeremony", "POST", "/v1/passkeys/ceremonies/{ceremony_id}", :computer, false, false],
43
+ ["decideSessionPasskeyCeremony", "POST", "/v1/sessions/{name}/passkeys/ceremonies/{ceremony_id}", :computer, false, false],
44
+ ["declareSessionPasskeyIntent", "POST", "/v1/sessions/{name}/passkeys/intents", :computer, false, false],
45
+ ["deleteComputer", "DELETE", "/v1/computers/{computer_id}", :control, true, false],
46
+ ["deleteOrphan", "DELETE", "/orphans/{target_id}", :computer, true, false],
47
+ ["deletePasskey", "DELETE", "/v1/passkeys/{credential_id}", :computer, true, false],
48
+ ["deleteSession", "DELETE", "/sessions/{name}", :computer, true, false],
49
+ ["deleteSkillVersion", "DELETE", "/v1/skills/{name}/versions/{version}", :computer, true, false],
50
+ ["destroyComputer", "DELETE", "/sandboxes/{sandboxId}", :control, true, false],
51
+ ["getAgentEventsPageV2", "GET", "/v2/sessions/{name}/agent/events/page", :computer, true, false],
52
+ ["getAgentEventsV2", "GET", "/v2/sessions/{name}/agent/events", :computer, true, false],
53
+ ["getAgentTurnV2", "GET", "/v2/sessions/{name}/agent/turns/{turn_id}", :computer, true, false],
54
+ ["getAgentTurnsV2", "GET", "/v2/sessions/{name}/agent/turns", :computer, true, false],
55
+ ["getAgentV2", "GET", "/v2/sessions/{name}/agent", :computer, true, false],
56
+ ["getArtifact", "GET", "/v1/sessions/{name}/artifacts/{artifact_id}", :computer, true, false],
57
+ ["getBindPubkey", "GET", "/v1/coord/bind-pubkey", :computer, true, false],
58
+ ["getCapabilities", "GET", "/v1/capabilities", :computer, true, false],
59
+ ["getComputer", "GET", "/sandboxes/{sandboxId}", :control, true, false],
60
+ ["getComputerBinding", "GET", "/v1/computers/{computer_id}", :control, true, false],
61
+ ["getControl", "GET", "/v1/sessions/{name}/control", :computer, true, false],
62
+ ["getControlEvents", "GET", "/v1/sessions/{name}/control/events", :computer, true, false],
63
+ ["getDiagnostic", "GET", "/v1/diagnostics/{id}", :control, true, false],
64
+ ["getHandoff", "GET", "/v1/sessions/{name}/handoffs/{handoff_id}", :computer, true, false],
65
+ ["getProjectPolicy", "GET", "/v1/project-policy", :control, true, false],
66
+ ["getSession", "GET", "/sessions/{name}", :computer, true, false],
67
+ ["getSessionFile", "GET", "/v1/sessions/{name}/files", :computer, true, false],
68
+ ["getSkill", "GET", "/v1/skills/{name}", :computer, true, false],
69
+ ["getSkillVersion", "GET", "/v1/skills/{name}/versions/{version}", :computer, true, false],
70
+ ["getState", "GET", "/state", :computer, true, false],
71
+ ["learnSkill", "POST", "/v2/sessions/{name}/learn", :computer, false, false],
72
+ ["listAllSkillVersions", "GET", "/v1/skills/versions", :computer, true, false],
73
+ ["listArtifacts", "GET", "/v1/sessions/{name}/artifacts", :computer, true, false],
74
+ ["listAvailableLocations", "GET", "/v1/locations", :control, true, false],
75
+ ["listHandoffs", "GET", "/v1/sessions/{name}/handoffs", :computer, true, false],
76
+ ["listOrphans", "GET", "/orphans", :computer, true, false],
77
+ ["listPasskeyCeremonies", "GET", "/v1/passkeys/ceremonies", :computer, true, false],
78
+ ["listPasskeys", "GET", "/v1/passkeys", :computer, true, false],
79
+ ["listSessionFiles", "GET", "/v1/sessions/{name}/files/list", :computer, true, false],
80
+ ["listSessionPasskeyCeremonies", "GET", "/v1/sessions/{name}/passkeys/ceremonies", :computer, true, false],
81
+ ["listSessions", "GET", "/sessions", :computer, true, false],
82
+ ["listSkillDrafts", "GET", "/v1/skills/drafts", :computer, true, false],
83
+ ["listSkillVersions", "GET", "/v1/skills/{name}/versions", :computer, true, false],
84
+ ["listSkills", "GET", "/v1/skills", :computer, true, false],
85
+ ["mintAttachCredentials", "POST", "/v1/computers/{computer_id}/attach-credentials", :control, true, true],
86
+ ["mintControlToken", "POST", "/v1/control-token", :control, true, false],
87
+ ["mintDesktopToken", "POST", "/v1/sessions/{name}/desktop-token", :computer, false, false],
88
+ ["observeSession", "POST", "/v1/sessions/{name}/observe", :computer, false, false],
89
+ ["patchControl", "PATCH", "/v1/sessions/{name}/control", :computer, false, true],
90
+ ["pauseComputer", "POST", "/sandboxes/{sandboxId}/pause", :control, false, false],
91
+ ["postControlNotify", "POST", "/v1/sessions/{name}/control/notify", :computer, false, false],
92
+ ["putSessionAttachment", "PUT", "/v1/sessions/{name}/files", :computer, false, false],
93
+ ["refineSkill", "POST", "/v2/sessions/{name}/refine", :computer, false, false],
94
+ ["registerComputer", "POST", "/v1/computers", :control, true, false],
95
+ ["resetAgentThreadV2", "POST", "/v2/sessions/{name}/agent/reset", :computer, false, false],
96
+ ["resumeComputer", "POST", "/sandboxes/{sandboxId}/resume", :control, false, false],
97
+ ["runAgentV2", "POST", "/v2/sessions/{name}/agent/run", :computer, true, true],
98
+ ["sessionEpoch", "GET", "/sessions/{name}/epoch", :computer, true, false],
99
+ ["sessionExec", "POST", "/sessions/{name}/exec", :computer, false, false],
100
+ ["sessionFocus", "POST", "/sessions/{name}/focus", :computer, false, false],
101
+ ["sessionTerminalRecreate", "POST", "/sessions/{name}/terminal/recreate", :computer, false, false],
102
+ ["setComputerIdleTimeout", "PUT", "/sandboxes/{sandboxId}/idle-timeout", :control, false, false],
103
+ ["setSkillEnabled", "PUT", "/v1/skills/{name}/enabled", :computer, false, false],
104
+ ["steerAgentV2", "POST", "/v2/sessions/{name}/agent/steer", :computer, false, false],
105
+ ["stopComputerRuntime", "POST", "/sandboxes/{sandboxId}/stop", :control, true, false],
106
+ ["streamAuthorEventsV2", "GET", "/v2/sessions/{name}/skills/author/{author_id}/events", :computer, true, false],
107
+ ["tabsCreate", "POST", "/sessions/{name}/tabs", :computer, false, false],
108
+ ["tabsDelete", "DELETE", "/sessions/{name}/tabs/{target_id}", :computer, true, false],
109
+ ["tabsList", "GET", "/sessions/{name}/tabs", :computer, true, false],
110
+ ["tabsPatch", "PATCH", "/sessions/{name}/tabs/{target_id}", :computer, false, false],
111
+ ["teachSkill", "POST", "/v2/sessions/{name}/teach", :computer, false, false],
112
+ ["uploadArtifact", "POST", "/v1/sessions/{name}/artifacts", :computer, false, false],
113
+ ["uploadFile", "POST", "/v1/sessions/{name}/upload_file", :computer, false, false],
114
+ ["zipArtifacts", "GET", "/v1/sessions/{name}/artifacts/zip", :computer, true, false],
115
+ ].map { |row| build(*row) }.freeze
116
+
117
+ BY_ID = TABLE.to_h { |operation| [operation.id, operation] }.freeze
118
+
119
+ module_function
120
+
121
+ def fetch(id) = BY_ID.fetch(id)
122
+
123
+ # The operation a request to `path` (no query) on a `host` role matches, or
124
+ # nil for a route outside the table.
125
+ def match(host, method, path)
126
+ method = method.to_s.upcase
127
+ TABLE.select { |op| op.host == host && op.method == method && op.pattern.match?(path) }
128
+ .max_by(&:specificity)
129
+ end
130
+ end
131
+ 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 the started Computer, not its identity or a cached health verdict.
3
+ SandboxLifetime = 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
+ SandboxDetails = Data.define(:id, :state, :reason, :message, :retryable,
18
+ :operation_id, :created_at, :expires_at, :lifecycle, :runtime_revision) do
19
+ def self.from_info(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,247 @@
1
+ require "securerandom"
2
+ require_relative "errors"
3
+ require_relative "clock"
4
+
5
+ module PineSandbox
6
+ # Lifecycle handle for a Computer sandbox, 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 SandboxHandle
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 :sandbox_id, :id
19
+
20
+ # `clock` returns monotonic seconds and `sleeper` waits; both default to
21
+ # the control plane's clock.
22
+ def initialize(control_plane:, sandbox_id: nil, status: nil, clock: nil, sleeper: nil)
23
+ id = sandbox_id
24
+ raise ArgumentError, "sandbox_id required" if id.to_s.empty?
25
+
26
+ source = control_plane.respond_to?(:clock) ? control_plane.clock : PineSandbox::SYSTEM_CLOCK
27
+ @control_plane = control_plane
28
+ @id = id.to_s
29
+ @status = status
30
+ @clock = clock || -> { source.monotonic }
31
+ @sleeper = sleeper || ->(seconds) { source.sleep(seconds) }
32
+ end
33
+
34
+ # Allocation may reuse Running from its create response. Memory resume must
35
+ # discard that observation and wait for the controller's new completion.
36
+ def wait_until_running(timeout:, interval: DEFAULT_POLL_INTERVAL)
37
+ return self if @status == READY
38
+
39
+ wait_until_state(:running, timeout: timeout, interval: interval)
40
+ end
41
+
42
+ def pause(timeout: 360, interval: DEFAULT_POLL_INTERVAL)
43
+ transition_memory(:pause, :paused, timeout: timeout, interval: interval)
44
+ end
45
+
46
+ def resume(timeout: 360, interval: DEFAULT_POLL_INTERVAL)
47
+ transition_memory(:resume, :running, timeout: timeout, interval: interval)
48
+ end
49
+
50
+ def status
51
+ refresh_status
52
+ end
53
+
54
+ def set_idle_timeout(seconds:)
55
+ observed = sandbox_details
56
+ raise BindError, "Computer was started without a lifetime policy; choose the idle timeout on the next Start" unless observed.lifecycle
57
+
58
+ @control_plane.set_idle_timeout(@id, seconds: seconds, etag: observed.etag)
59
+ end
60
+
61
+ def sandbox_details
62
+ @control_plane.get(@id)
63
+ end
64
+
65
+ # Observe existing intent before admitting a new operation. No SDK memory
66
+ # or saved operation cursor is needed to continue Stop from another backend.
67
+ def stop(wait_timeout: 390, interval: DEFAULT_POLL_INTERVAL)
68
+ return false if wait_timeout <= 0
69
+
70
+ deadline = @clock.call + wait_timeout
71
+ info = @control_plane.get(@id, request_timeout: wait_timeout)
72
+ return true if released?(info)
73
+ if info.status == :save_failed && !info.retryable
74
+ raise StopSaveError.new(Error.new(info.message), retryable: false)
75
+ end
76
+ unless %i[saving stopping].include?(info.status)
77
+ begin
78
+ @control_plane.stop(@id, operation_id: SecureRandom.uuid, request_timeout: remaining(deadline))
79
+ rescue PineSandbox::ConflictError
80
+ # A concurrent caller may have admitted Stop first. Join only that
81
+ # actual operation; a memory-transition conflict is still an error.
82
+ info = @control_plane.get(@id, request_timeout: remaining(deadline))
83
+ raise unless %i[saving stopping].include?(info.status)
84
+ end
85
+ end
86
+ loop do
87
+ info = next_observation(info, deadline, interval)
88
+ return true if released?(info)
89
+ if info.status == :save_failed
90
+ raise StopSaveError.new(Error.new(info.message), retryable: info.retryable == true)
91
+ end
92
+ end
93
+ rescue PineSandbox::NotFoundError
94
+ true
95
+ rescue PineSandbox::TimeoutError
96
+ raise if @clock.call < deadline
97
+
98
+ false
99
+ end
100
+
101
+ # Explicit saved-state recovery discards retained memory. It is allowed
102
+ # only after a recorded failure and is fenced by the observed HTTP ETag.
103
+ def release_failed_sandbox(info, wait_timeout: 90, interval: DEFAULT_POLL_INTERVAL)
104
+ deadline = @clock.call + wait_timeout
105
+ unless info.status == :stopping
106
+ unless %i[save_failed unavailable failed terminated].include?(info.status) && info.reason != "CaptureTimeout"
107
+ raise PineSandbox::ConflictError.new(status: 409, error_message: "The Computer has no recorded failure; use Stop or Resume")
108
+ end
109
+ raise PineSandbox::Error, "Server did not supply a conditional release version" if info.etag.to_s.empty?
110
+ @control_plane.destroy(@id, if_match: info.etag, request_timeout: remaining(deadline))
111
+ end
112
+ wait_until_released(info, deadline, interval)
113
+ rescue PineSandbox::TimeoutError
114
+ raise if @clock.call < deadline
115
+ false
116
+ end
117
+
118
+ # Delete, then wait for the sandbox record to disappear. This confirms
119
+ # compute release only. Computer#stop uses the controller's graceful Stop.
120
+ # false retains the caller's handle when deletion remains unconfirmed.
121
+ def terminate(wait_timeout: 90, interval: DEFAULT_POLL_INTERVAL)
122
+ deadline = @clock.call + wait_timeout
123
+ return false if wait_timeout <= 0
124
+
125
+ @control_plane.destroy(@id, request_timeout: wait_timeout)
126
+ wait_until_released(nil, deadline, interval)
127
+ rescue PineSandbox::TimeoutError
128
+ raise if @clock.call < deadline
129
+
130
+ false
131
+ end
132
+
133
+ # Ungraceful force-recover: best-effort DELETE, no wait. Never raises.
134
+ def kill
135
+ @control_plane.destroy(@id)
136
+ true
137
+ rescue StandardError
138
+ false
139
+ end
140
+
141
+ def alive?
142
+ LIVE.include?(refresh_status)
143
+ rescue StandardError
144
+ false
145
+ end
146
+
147
+ # Absence or explicit controller compute release permits dropping the handle.
148
+ # Generic terminal state, timeout, and errors are insufficient.
149
+ def gone?(request_timeout: 5)
150
+ released?(@control_plane.get(@id, request_timeout: request_timeout))
151
+ rescue PineSandbox::NotFoundError
152
+ true
153
+ rescue StandardError
154
+ false
155
+ end
156
+
157
+ private
158
+
159
+ def wait_until_released(info, deadline, interval)
160
+ loop do
161
+ return false if @clock.call >= deadline
162
+ begin
163
+ info = next_observation(info, deadline, interval)
164
+ return true if released?(info)
165
+ rescue PineSandbox::NotFoundError
166
+ return true
167
+ rescue StandardError
168
+ # A failed observation is not proof of deletion. Preserve the
169
+ # existing bounded retry without spinning after a transport error.
170
+ @sleeper.call([interval, remaining(deadline)].min)
171
+ end
172
+ end
173
+ rescue PineSandbox::TimeoutError
174
+ false
175
+ end
176
+
177
+ def next_observation(previous, deadline, interval)
178
+ timeout = remaining(deadline)
179
+ if previous
180
+ @control_plane.wait_for_change(@id, previous: previous, request_timeout: timeout, interval: interval)
181
+ else
182
+ @control_plane.get(@id, request_timeout: timeout)
183
+ end
184
+ end
185
+
186
+ def remaining(deadline)
187
+ timeout = deadline - @clock.call
188
+ raise PineSandbox::TimeoutError, "Computer #{@id} Stop wait ended; the server continues cleanup" if timeout <= 0
189
+ timeout
190
+ end
191
+
192
+ def released?(info)
193
+ info.status == :terminated && info.reason == "COMPUTE_RELEASED"
194
+ end
195
+
196
+ def transition_memory(operation, target, timeout:, interval:)
197
+ raise ArgumentError, "timeout must be positive" unless timeout.positive?
198
+
199
+ deadline = @clock.call + timeout
200
+ accepted = @control_plane.public_send(operation, @id, request_timeout: timeout)
201
+ @status = nil
202
+ unless accepted
203
+ # Older Docker servers answer 409 for an already completed transition.
204
+ remaining = deadline - @clock.call
205
+ raise PineSandbox::ReadyTimeoutError, "Computer #{@id} #{operation} exceeded its deadline" if remaining <= 0
206
+ return self if refresh_status(request_timeout: remaining) == target
207
+
208
+ raise PineSandbox::ConflictError.new(status: 409, error_message: "Computer #{@id} cannot #{operation} in its current state")
209
+ end
210
+ wait_until_state(target, timeout: deadline - @clock.call, interval: interval)
211
+ end
212
+
213
+ def wait_until_state(target, timeout:, interval:)
214
+ deadline = @clock.call + timeout
215
+ info = nil
216
+ loop do
217
+ remaining = deadline - @clock.call
218
+ if remaining <= 0
219
+ raise PineSandbox::ReadyTimeoutError, "Computer #{@id} did not reach #{target} before the deadline (last state #{@status.inspect})"
220
+ end
221
+ begin
222
+ info = next_observation(info, deadline, interval)
223
+ state = @status = info.status
224
+ rescue PineSandbox::TimeoutError => error
225
+ raise error if @clock.call < deadline
226
+
227
+ raise PineSandbox::ReadyTimeoutError, "Computer #{@id} did not reach #{target} before the deadline"
228
+ end
229
+ return self if state == target
230
+ if WAIT_FAILURE_STATES.include?(state)
231
+ raise PineSandbox::SandboxFailedError.new(
232
+ "Computer sandbox #{@id} reported #{state.inspect} before reaching #{target}", sandbox_id: @id, state: state
233
+ )
234
+ end
235
+ end
236
+ end
237
+
238
+ def refresh_status(request_timeout: nil)
239
+ @status = if request_timeout
240
+ @control_plane.get(@id, request_timeout: request_timeout).status
241
+ else
242
+ @control_plane.get(@id).status
243
+ end
244
+ end
245
+
246
+ end
247
+ end