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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. 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