sai-sdk 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b354a76d6581874f8fb42a95a2c08e493e35caeff8719badd6ea1b3bcc2833f7
4
+ data.tar.gz: '079cf8b51f0604c50904643c772085e8a63873afa437a56286140d27273a1000'
5
+ SHA512:
6
+ metadata.gz: 1b7a1c2cb4783e1b4ef6a9add4d445e6dda1b6d37794c2cb23f7b95f57026deebcbf689a0dbb12772cdda9ea3427a73bf444edf1dab60b42d22c020a22859b14
7
+ data.tar.gz: f568d3063e62375cbc35f6f0eb2dabdcf5a173f77ab70b1f291326e39a95d640a67d8464a7a12a131e2ebc706bb7c71a8fc08366549e2752d1abc767f89effe2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simular, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # sai-sdk (Ruby)
2
+
3
+ Ruby SDK for the [Sai API](https://platform.simular.ai/documentation). Hand desktop work to Sai on a
4
+ computer (a Sai cloud computer, or your own PC with the Sai app) and get the answer back, or drive a
5
+ cloud computer yourself.
6
+
7
+ Status: **alpha (0.1), unreleased**. The surface may still change before 1.0. It matches the
8
+ [Python SDK](../python/README.md) and the [TypeScript SDK](../typescript/README.md) method for method.
9
+
10
+ | Layer | What it is | Status |
11
+ | -------------- | --------------------------------------------- | ---------------- |
12
+ | `sai.sessions` | A conversation with Sai on one computer | available |
13
+ | `sai.control` | Drive a cloud computer directly with Simulang | research preview |
14
+ | `sai.turns` | A turn's record: typed output, evidence | available |
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ gem install sai-sdk # or: gem "sai-sdk" in your Gemfile
20
+ export SAI_API_KEY=sapi_... # create one at https://platform.simular.ai
21
+ ```
22
+
23
+ Ruby 3.1 or later. No runtime dependencies beyond the standard library (`net/http`, `json`).
24
+
25
+ ## Give Sai a task
26
+
27
+ ```ruby
28
+ require "sai"
29
+
30
+ sai = Sai::Client.new
31
+ pc = sai.machines.list.first
32
+
33
+ session = sai.sessions.create(machine: pc)
34
+ result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")
35
+
36
+ puts result.status # idle | needs_approval | error
37
+ puts result.text # Sai's answer
38
+ p result.usage # tokens and cost for this task
39
+ ```
40
+
41
+ `machine:` can be left out when the account has one computer. More `session.run(...)` calls continue
42
+ the same conversation; `sai.sessions.create` starts a new one with no memory of the old.
43
+ `sai.sessions.list(machine: pc)` lists your sessions on the computer (desktop, CLI and API), most
44
+ recent first, and `sai.auth.check` says who the key acts as.
45
+
46
+ ### Watch it work
47
+
48
+ ```ruby
49
+ turn = session.send("Find last month's invoices in Downloads and total them")
50
+ turn.events do |ev|
51
+ case ev.kind
52
+ when "step" then puts " #{ev.text}"
53
+ when "approval" then turn.respond(ev.approval, "yes")
54
+ end
55
+ end
56
+ result = turn.wait
57
+ ```
58
+
59
+ `ev.kind` is one of `step`, `message`, `approval`, `tool_error`, `error`, `finished`.
60
+ `turn.events(raw: true)` adds the wire frames (reasoning, tool calls) as `other`. Without a block,
61
+ `events` returns an `Enumerator`. `Session#send` is the API call, as in the other SDKs; Ruby's own
62
+ `send` is still there as `__send__` and `public_send`.
63
+
64
+ ### Approvals
65
+
66
+ `run` / `wait` return with `status == "needs_approval"` unless you pass a handler, as a block or as
67
+ `on_approval:` (anything that responds to `call`):
68
+
69
+ ```ruby
70
+ result = session.run("Clean up the temp folder") do |approval|
71
+ if approval.link_only?
72
+ puts "Approve in a browser: #{approval.url}"
73
+ next nil # a person answers it; keep waiting
74
+ end
75
+ next ["yes", approval.questions.map { |q| [q["options"][0]["value"]] }] if approval.type == "choice"
76
+
77
+ "yes"
78
+ end
79
+ ```
80
+
81
+ The handler returns `"yes"`, `"no"`, `"task"`, a `[response, selections]` pair for a choice, or
82
+ `nil` to leave the approval pending. For an unattended run, `autonomous: true` runs the turn without
83
+ approval cards, as if `"task"` had been answered up front; link-only approvals still stop it.
84
+
85
+ ### Files, models, stopping
86
+
87
+ ```ruby
88
+ report = sai.files.upload("report.pdf") # or data: bytes, name: "report.pdf"
89
+ session.run("Summarise the attached report", attachments: [report])
90
+
91
+ allowed = sai.models.list.select(&:allowed).map(&:id)
92
+ session.run("...", model: "anthropic/claude-opus-5-5") # sticks to the session
93
+
94
+ turn = session.send("...")
95
+ turn.abort
96
+ ```
97
+
98
+ ### Let a person take over
99
+
100
+ ```ruby
101
+ view = sai.machines.live(pc)
102
+ puts "Sign in here, then tell me: #{view.live_url}" # valid until view.expires_at (ms)
103
+ # ... once they are done
104
+ session.run("I signed in. Carry on.")
105
+ sai.machines.revoke_live(pc) # every open link stops working
106
+ ```
107
+
108
+ ## Get typed results
109
+
110
+ ```ruby
111
+ result = session.run(
112
+ "Save \"Invoice {{invoice_number}}: {{amount}} USD\" on the Desktop as {{file_name}}. Return its path.",
113
+ inputs: { "invoice_number" => "INV-1001", "amount" => "42.50", "file_name" => "inv-1001.txt" },
114
+ output_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
115
+ success_criteria: [
116
+ Sai::Check.new(id: "saved", check: "The file is saved and contains the invoice line", return_evidence: true)
117
+ ],
118
+ idempotency_key: "inv-1001", # a retry returns this turn and sends nothing
119
+ record_trajectory: true
120
+ )
121
+ if result.ok?
122
+ puts result.record.output["path"]
123
+ else
124
+ puts result.record.reason_code # e.g. output_invalid, with result.record.raw_answer
125
+ end
126
+ ```
127
+
128
+ `inputs`, `output_schema` or `success_criteria` make the message _structured_; it is rejected with
129
+ `Sai::ConflictError` (`code == "session_busy"`) while the session is working. `thinking_level:`
130
+ (`off`, `low`, `medium`, `high`, `xhigh`) is refused the same way.
131
+
132
+ **Reading a turn later.** Every message gets a turn id, `turn.id`:
133
+
134
+ ```ruby
135
+ record = sai.turns.get(turn) # never waits
136
+ record = sai.turns.wait(turn, timeout: 600) # until succeeded, failed or needs_input
137
+ sai.turns.trajectory(turn).each do |step| # needs record_trajectory: true
138
+ File.binwrite("step#{step.index}.jpg", sai.turns.screenshot(turn, step.index)) if step.has_screenshot
139
+ end
140
+ ```
141
+
142
+ ## Drive a computer yourself
143
+
144
+ Direct control runs [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
145
+ blocks on a cloud computer. With a block, `open` closes the lease when the block ends; while it is
146
+ open, Sai's own tasks on that computer wait.
147
+
148
+ ```ruby
149
+ sai.control.open(machine: pc) do |lease|
150
+ puts lease.shell("hostname").stdout
151
+
152
+ r = lease.exec(<<~JS)
153
+ var page = await machine.browser.newtab('https://example.com')
154
+ var snap = await page.snapshot()
155
+ snap.title
156
+ JS
157
+ p r.result # Example Domain
158
+ File.binwrite("page.#{r.screenshot_mime_type.split('/')[1]}", r.screenshot)
159
+ end
160
+ ```
161
+
162
+ A block that threw still returns an `ExecResult`, so check `r.error` (`exception`, `timeout`,
163
+ `interrupted`). Bytes (screenshots) are binary `String`s.
164
+
165
+ ## Learn code from turns (research preview)
166
+
167
+ ```ruby
168
+ learned = sai.learn.create([turn]) # turns sent with record_trajectory: true
169
+ r = sai.control.open(machine: pc) { |lease| lease.run(learned, { "site" => "https://lobste.rs" }) }
170
+
171
+ fixed = sai.learn.create(
172
+ initial_code: learned.code,
173
+ evaluations: [Sai::Evaluation.new(inputs: { "site" => "https://lobste.rs" }, result: r, passed: false)]
174
+ )
175
+ judgement = sai.judge.create([turn], [Sai::Check.new(id: "title", check: "The agent reported a story title")])
176
+
177
+ job = sai.learn.create(task_template: "Open {{site}} and read the title of the top story",
178
+ inputs: { "site" => "https://news.ycombinator.com" }, machine: pc, max_iterations: 3)
179
+ done = sai.learn.wait(job) # timeout: to stop waiting
180
+ run = sai.learn.rollout(job, 1, 1)
181
+ ```
182
+
183
+ ## Run Sai for your customers
184
+
185
+ ```ruby
186
+ org = Sai::Client.new(api_key: "sapi_...", org_id: "org_...") # or SAI_API_KEY, SAI_ORG_ID
187
+
188
+ tenant = org.tenants.create("Riverside Clinic", external_id: "clinic-4821")
189
+ code = org.tenants.create_enrollment(tenant) # hand code.token to your installer
190
+
191
+ machine = org.tenant_machines.create(tenant, os: "windows", request_id: SecureRandom.uuid, wait: true)
192
+ clinic = org.tenants.client(tenant) # acts as the Tenant; renews its token
193
+ clinic.sessions.create(machine: machine.id).run("Open the calendar and read today's appointments")
194
+
195
+ org.tenants.update(tenant, cloud_machine_limit: nil) # nil removes the cap; leaving it out changes nothing
196
+ usage = org.usage.get(granularity: "month", from: "2026-10", to: "2026-10")
197
+ page = org.usage.sessions(tenant, from: "2026-10-01", to: "2026-10-31")
198
+ ```
199
+
200
+ `usage.sessions` pages what each of a Tenant's sessions spent on AI calls, for UTC days `from:` to `to:`
201
+ (at most 93). A page can be empty and still have a `next_cursor`: keep paging until it is `nil`.
202
+
203
+ To charge for a task as soon as it ends, look its session up in real time instead of a date range:
204
+
205
+ ```ruby
206
+ page = org.usage.sessions(tenant, session_ids: [session.id])
207
+ page.sessions.each do |s|
208
+ puts "#{s.session_id} #{s.cost_usd} #{page.as_of}" if s.settled
209
+ end
210
+ ```
211
+
212
+ With `session_ids:` (1 to 50 of the Tenant's sessions) each session comes back with its spend so far and
213
+ `settled`: `true` once its latest task has ended and every charge for it has come in. Ask again every
214
+ 10 seconds or so until it is. `cost_usd` and `model_calls` are `nil` when the session can't be read
215
+ in full right now. An id that is not one of the Tenant's sessions is left out. See
216
+ [Session spend](https://platform.simular.ai/documentation/organisations/session-spend) for the limits.
217
+
218
+ ## Errors
219
+
220
+ Every non-2xx response raises a `Sai::Error` subclass with `status`, `code`, `message`, `body` and
221
+ `retry_after`: `AuthenticationError` (401), `BadRequestError` (400), `PermissionDeniedError` (403),
222
+ `NotFoundError` (404), `ConflictError` (409), `LinkOnlyApprovalError` (`approval_url`), `GoneError`
223
+ (410), `SpendCapReachedError` (403; `resets_at`), `RateLimitError` (429), `ServiceUnavailableError`
224
+ (503), `ConnectionError`, `BlockError`. A wait past its `timeout:` raises `Timeout::Error`.
225
+
226
+ A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
227
+ `insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
228
+
229
+ ## Configuration
230
+
231
+ | Keyword | Env | Default |
232
+ | -------------- | ------------- | ------------------------ |
233
+ | `api_key:` | `SAI_API_KEY` | required |
234
+ | `base_url:` | `SAI_API_URL` | `https://api.simular.ai` |
235
+ | `timeout:` | | 60 seconds per request |
236
+ | `version_tag:` | | none |
237
+ | `org_id:` | `SAI_ORG_ID` | for `tenants.token` |
238
+
239
+ ## Development
240
+
241
+ ```bash
242
+ cd ruby
243
+ bundle install
244
+ bundle exec rake test # parity, conformance cases, docs snippets (SAI_DOCS_DIR)
245
+ gem build sai-sdk.gemspec
246
+ ```
data/lib/sai/client.rb ADDED
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ # The Sai API client.
5
+ #
6
+ # sai = Sai::Client.new # SAI_API_KEY, and SAI_API_URL if set
7
+ # session = sai.sessions.create
8
+ # puts session.run("Open Notepad and type hello").text
9
+ class Client
10
+ attr_reader :machines, :sessions, :turns, :control, :models, :files, :account, :tenant_machines
11
+ # Recorded turns → Simulang code, and code repair (research preview).
12
+ attr_reader :learn
13
+ # Judge recorded turns against new criteria (research preview).
14
+ attr_reader :judge
15
+ # Who the key acts as.
16
+ attr_reader :auth
17
+ # What an Organisation is charged, per Tenant and per session. Takes an Organisation Key.
18
+ attr_reader :usage
19
+ # Your customers under an Organisation. Takes an Organisation Key.
20
+ attr_reader :tenants
21
+
22
+ # +api_key+ defaults to +SAI_API_KEY+, +base_url+ to +SAI_API_URL+ then
23
+ # +https://api.simular.ai+, +org_id+ (the Organisation +tenants.token+, +tenants.client+ and
24
+ # +usage+ act on; only an Organisation Key needs it) to +SAI_ORG_ID+. +timeout+ is seconds
25
+ # per request; +version_tag+ routes requests to a tagged API revision.
26
+ def initialize(api_key: nil, base_url: nil, timeout: 60, version_tag: nil, org_id: nil)
27
+ key = api_key || ENV.fetch("SAI_API_KEY", nil)
28
+ if key.nil? || key.empty?
29
+ raise ArgumentError, "No API key: pass api_key: or set SAI_API_KEY. Create one at https://platform.simular.ai."
30
+ end
31
+
32
+ setup(key, base_url: base_url, timeout: timeout, version_tag: version_tag, org_id: org_id)
33
+ end
34
+
35
+ # @api private
36
+ # A client whose bearer token comes from +credential+ before each request (+tenants.client+).
37
+ def self.with_credential(credential, **opts)
38
+ allocate.tap { |c| c.send(:setup, credential, **opts) }
39
+ end
40
+ private_class_method :with_credential
41
+
42
+ def inspect
43
+ "#<Sai::Client>"
44
+ end
45
+
46
+ private
47
+
48
+ def setup(credential, base_url:, timeout:, version_tag:, org_id:)
49
+ url = base_url || ENV.fetch("SAI_API_URL", nil)
50
+ url = DEFAULT_BASE_URL if url.nil? || url.empty?
51
+ org = org_id.nil? ? ENV.fetch("SAI_ORG_ID", nil) : org_id
52
+ http = Http.new(credential, url, timeout, version_tag)
53
+ opts = { base_url: base_url, timeout: timeout, version_tag: version_tag, org_id: org_id }
54
+ @machines = Machines.new(http)
55
+ @sessions = Sessions.new(http, @machines)
56
+ @turns = Turns.new(http)
57
+ @learn = Learn.new(http)
58
+ @judge = Judge.new(http)
59
+ @control = Control.new(http)
60
+ @models = Models.new(http)
61
+ @files = Files.new(http)
62
+ @account = AccountResource.new(http)
63
+ @auth = Auth.new(http)
64
+ @usage = UsageResource.new(http, org)
65
+ @tenants = Tenants.new(http, org, ->(c) { Client.send(:with_credential, c, **opts) })
66
+ @tenant_machines = TenantMachines.new(http)
67
+ end
68
+ end
69
+
70
+ # Models a session can run on.
71
+ class Models
72
+ # @api private
73
+ def initialize(http)
74
+ @http = http
75
+ end
76
+
77
+ # +allowed+ says whether this plan may use each.
78
+ # @return [Array<Model>]
79
+ def list
80
+ d = Parse.obj(@http.json("GET", "/v1/agents/models"))
81
+ Parse.list(d["models"]).map { |m| Model.parse(Parse.obj(m)) }
82
+ end
83
+ end
84
+
85
+ # Files to attach to a message.
86
+ class Files
87
+ # @api private
88
+ def initialize(http)
89
+ @http = http
90
+ end
91
+
92
+ # Upload one file (max 25 MB): a path, or the bytes as +data:+ with a +name:+. +name+ defaults
93
+ # to the path's file name.
94
+ # @return [Attachment]
95
+ def upload(path = nil, data: nil, name: nil)
96
+ raise ArgumentError, "pass a path, or data: with name:" if path.nil? == data.nil?
97
+
98
+ if path
99
+ data = File.binread(path.to_s)
100
+ name ||= File.basename(path.to_s)
101
+ end
102
+ raise ArgumentError, "name: is required with data:" if name.nil?
103
+
104
+ d = @http.json("POST", "/v1/agents/upload",
105
+ body: data.b,
106
+ # The server reads only a body that has a Content-Type; it derives the MIME
107
+ # type from the name.
108
+ headers: { "Content-Type" => "application/octet-stream", "x-filename" => Util.q(name) },
109
+ timeout: 120)
110
+ Attachment.parse(Parse.obj(d))
111
+ end
112
+ end
113
+
114
+ # The account's plan and credit.
115
+ class AccountResource
116
+ # @api private
117
+ def initialize(http)
118
+ @http = http
119
+ end
120
+
121
+ # Plan and spendable credit. Slow-ish (billing lookup): not for a poll loop.
122
+ # @return [Account]
123
+ def get
124
+ Account.parse(Parse.obj(@http.json("GET", "/v1/agents/account")))
125
+ end
126
+ end
127
+
128
+ # Who the key acts as.
129
+ class Auth
130
+ # @api private
131
+ def initialize(http)
132
+ @http = http
133
+ end
134
+
135
+ # Raises AuthenticationError for a missing or revoked key.
136
+ # @return [AuthInfo]
137
+ def check
138
+ AuthInfo.parse(Parse.obj(@http.json("GET", "/v1/agents/auth")))
139
+ end
140
+ end
141
+
142
+ # What an Organisation is charged. Takes an Organisation Key; +org_id+ defaults to the client's.
143
+ class UsageResource
144
+ # @api private
145
+ def initialize(http, org_id)
146
+ @http = http
147
+ @org_id = org_id
148
+ end
149
+
150
+ # What the Organisation is charged, in total and per Tenant, per +granularity+ (+hour+, +day+
151
+ # or +month+; default +day+) from +from+ to +to+: +2026-10+ for a month, +2026-10-15+ for a
152
+ # day, +2026-10-15T09+ for an hour. Each defaults to a range that ends now.
153
+ # @return [OrgUsage]
154
+ def get(granularity: nil, from: nil, to: nil, org_id: nil)
155
+ params = { "granularity" => granularity, "from" => from, "to" => to }
156
+ OrgUsage.parse(Parse.obj(@http.json("GET", "/v1/orgs/#{Util.q(org(org_id))}/usage", params: params)))
157
+ end
158
+
159
+ # One page of what each of a Tenant's sessions spent on AI calls, oldest first, for the UTC
160
+ # days +from+ to +to+ (+YYYY-MM-DD+, both included, at most 93 days). Keep paging with
161
+ # +next_cursor+ until it is nil: a page can be empty and not be the last.
162
+ #
163
+ # With +session_ids+ (1 to 50) instead of the range, the named sessions' spend right now, each
164
+ # with +settled+, and the page's +as_of+; no paging. Charge for a task once its session is
165
+ # settled.
166
+ # @return [SessionSpendPage]
167
+ def sessions(tenant, from: nil, to: nil, limit: nil, cursor: nil, session_ids: nil, org_id: nil)
168
+ params = { "tenantId" => Util.id_of(tenant), "from" => from, "to" => to, "limit" => limit, "cursor" => cursor,
169
+ "sessionIds" => session_ids.nil? || session_ids.empty? ? nil : session_ids.join(",") }
170
+ d = @http.json("GET", "/v1/orgs/#{Util.q(org(org_id))}/usage/sessions", params: params)
171
+ SessionSpendPage.parse(Parse.obj(d))
172
+ end
173
+
174
+ private
175
+
176
+ def org(org_id)
177
+ id = org_id || @org_id
178
+ raise ArgumentError, "No Organisation id: pass org_id: here or to Sai::Client.new, or set SAI_ORG_ID." if id.nil? || id.empty?
179
+
180
+ id
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sai
6
+ # Direct control: drive a cloud computer with Simulang. No agent. A lease on the machine: at
7
+ # most one per machine. Research preview; model calls the code makes are billed as usual.
8
+ class Control
9
+ OPEN_TIMEOUT = 240
10
+
11
+ # @api private
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Take a cloud computer (a Machine or its id). Returns once the lease holds it, up to about
17
+ # three minutes when a Sai task holds it or it has to start. Sai's tasks on that computer wait
18
+ # until the lease is closed. With a block, yields the lease, closes it after, and returns the
19
+ # block's value.
20
+ # @yieldparam lease [ControlLease]
21
+ # @return [ControlLease]
22
+ def open(machine:)
23
+ machine_id = Util.id_of(machine)
24
+ d = Parse.obj(@http.json("POST", ControlLease.path(machine_id), timeout: OPEN_TIMEOUT))
25
+ lease = ControlLease.new(@http, machine_id, Parse.num(d["expiresAfterIdleSec"]))
26
+ return lease unless block_given?
27
+
28
+ begin
29
+ yield lease
30
+ ensure
31
+ lease.close
32
+ end
33
+ end
34
+ end
35
+
36
+ # The result of ControlLease#shell.
37
+ ShellResult = Struct.new(:stdout, :stderr, :exit_code, :timed_out, keyword_init: true)
38
+
39
+ # A lease on a cloud computer. Close it when done: Sai's tasks on that computer wait until then.
40
+ class ControlLease
41
+ attr_reader :machine_id, :idle_timeout_sec
42
+
43
+ # @api private
44
+ def self.path(machine_id)
45
+ "/v1/machines/#{Util.q(machine_id)}/control"
46
+ end
47
+
48
+ # @api private
49
+ def initialize(http, machine_id, idle_timeout_sec)
50
+ @http = http
51
+ @machine_id = machine_id
52
+ @idle_timeout_sec = idle_timeout_sec
53
+ @path = self.class.path(machine_id)
54
+ @closed = false
55
+ end
56
+
57
+ # Run one Simulang (JavaScript) block. Use +var+ for anything the next block needs. A block
58
+ # that throws, times out or is interrupted still returns: check +result.error+. Raises
59
+ # GoneError once the lease has ended: open again.
60
+ # @return [ExecResult]
61
+ def exec(code, timeout_ms: nil)
62
+ body = { "code" => code }
63
+ body["timeoutMs"] = timeout_ms unless timeout_ms.nil?
64
+ d = @http.json("POST", "#{@path}/exec", json: body, timeout: ((timeout_ms || 120_000) + 45_000) / 1000.0)
65
+ ExecResult.parse(Parse.obj(d))
66
+ end
67
+
68
+ # Run code from +sai.learn.create+: its +main+ with +inputs+ over the parameters' defaults.
69
+ # +main+'s return value is +result.result+. A string runs as code with +inputs+ as is.
70
+ # @return [ExecResult]
71
+ def run(learned, inputs = {}, timeout_ms: nil)
72
+ code = learned.is_a?(String) ? learned : learned.code
73
+ args = learned.is_a?(String) ? {} : learned.parameters.to_h { |p| [p.name, p.default_value] }
74
+ inputs.each { |k, v| args[k.to_s] = v }
75
+ exec("#{code}\nawait main(#{JSON.generate(args)})", timeout_ms: timeout_ms)
76
+ end
77
+
78
+ # Run a shell command.
79
+ # @return [ShellResult]
80
+ def shell(command, cwd: nil, timeout_ms: nil)
81
+ args = { "command" => command }
82
+ args["cwd"] = cwd if cwd && !cwd.empty?
83
+ r = exec("await machine.exec(#{JSON.generate(args)})", timeout_ms: timeout_ms)
84
+ raise_on_block_error(r)
85
+ d = Parse.obj(r.result)
86
+ ShellResult.new(stdout: d["stdout"], stderr: d["stderr"], exit_code: d["exitCode"], timed_out: d["timedOut"])
87
+ end
88
+
89
+ # @return [String]
90
+ def read_file(path)
91
+ r = exec("await machine.readFile(#{JSON.generate({ 'path' => path })})")
92
+ raise_on_block_error(r)
93
+ r.result
94
+ end
95
+
96
+ # @return [nil]
97
+ def write_file(path, text)
98
+ r = exec("await machine.writeToFile(#{JSON.generate({ 'path' => path, 'text' => text, 'overwrite' => true })})")
99
+ raise_on_block_error(r)
100
+ nil
101
+ end
102
+
103
+ # The whole screen as image bytes (usually JPEG; use +exec+ for the MIME type). Needs the
104
+ # computer's screen-capture service; raises BlockError where it is off.
105
+ # @return [String] a binary String
106
+ def screenshot
107
+ r = exec("var __sai_page = await machine.pageContent(); null")
108
+ raise_on_block_error(r)
109
+ unless r.screenshot
110
+ raise BlockError.new("exception", r.screenshot_omitted ? "screenshot too large to return" : "no screenshot")
111
+ end
112
+
113
+ r.screenshot
114
+ end
115
+
116
+ # Stop the running block at its next step.
117
+ # @return [Boolean] false if nothing was running
118
+ def interrupt
119
+ d = @http.json("POST", "#{@path}/interrupt")
120
+ Parse.truthy(Parse.obj(d)["interrupted"])
121
+ end
122
+
123
+ # The lease's state: +{ "status" => "open"|"closed", "closedReason" => ..., ... }+.
124
+ # @return [Hash]
125
+ def get
126
+ @http.json("GET", @path)
127
+ end
128
+
129
+ # Clear the REPL and release the computer. Safe to call twice.
130
+ # @return [nil]
131
+ def close
132
+ return if @closed
133
+
134
+ begin
135
+ @http.request("DELETE", @path)
136
+ rescue GoneError
137
+ # already ended
138
+ end
139
+ @closed = true
140
+ nil
141
+ end
142
+
143
+ def inspect
144
+ "#<Sai::ControlLease machine_id=#{machine_id.inspect} idle_timeout_sec=#{idle_timeout_sec}>"
145
+ end
146
+
147
+ private
148
+
149
+ def raise_on_block_error(r)
150
+ raise BlockError.new(r.error.kind, r.error.message) if r.error
151
+ end
152
+ end
153
+ end