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 +7 -0
- data/LICENSE +21 -0
- data/README.md +246 -0
- data/lib/sai/client.rb +183 -0
- data/lib/sai/control.rb +153 -0
- data/lib/sai/errors.rb +109 -0
- data/lib/sai/http.rb +103 -0
- data/lib/sai/learn.rb +137 -0
- data/lib/sai/machines.rb +48 -0
- data/lib/sai/sessions.rb +333 -0
- data/lib/sai/tenants.rb +174 -0
- data/lib/sai/turns.rb +72 -0
- data/lib/sai/types.rb +688 -0
- data/lib/sai/util.rb +33 -0
- data/lib/sai/version.rb +5 -0
- data/lib/sai.rb +25 -0
- metadata +57 -0
data/lib/sai/errors.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sai
|
|
4
|
+
# A non-2xx response from the Sai API (the surface's +SaiError+). +code+ is the machine-readable
|
|
5
|
+
# +error+ string when the server sent one (+machine_busy+, +link_only+, ...); +message+ is the
|
|
6
|
+
# human text; +body+ is the parsed JSON body, unchanged.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :status, :code, :body, :retry_after
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, status: 0, code: nil, body: {}, retry_after: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@status = status
|
|
13
|
+
@code = code
|
|
14
|
+
@body = body || {}
|
|
15
|
+
@retry_after = retry_after
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The API could not be reached (DNS, TLS, timeout).
|
|
20
|
+
class ConnectionError < Error; end
|
|
21
|
+
# 400: the request was invalid, e.g. several computers and no +machine+.
|
|
22
|
+
class BadRequestError < Error; end
|
|
23
|
+
# 401: no key, or the key is unknown or revoked.
|
|
24
|
+
class AuthenticationError < Error; end
|
|
25
|
+
# 403: the key is valid but the account may not do this (plan, ownership).
|
|
26
|
+
class PermissionDeniedError < Error; end
|
|
27
|
+
# 404.
|
|
28
|
+
class NotFoundError < Error; end
|
|
29
|
+
# 409: e.g. +machine_busy+, +link_only+, approval no longer pending.
|
|
30
|
+
class ConflictError < Error; end
|
|
31
|
+
# 410 +control_closed+: the control lease has ended; +body["reason"]+ says why. Open a new one.
|
|
32
|
+
class GoneError < Error; end
|
|
33
|
+
# 429: a rate limit, or a free-plan budget that resets daily.
|
|
34
|
+
class RateLimitError < Error; end
|
|
35
|
+
# 503: the computer did not come online, or no capacity. Honor +retry_after+.
|
|
36
|
+
class ServiceUnavailableError < Error; end
|
|
37
|
+
|
|
38
|
+
# A control helper's Simulang block failed on the computer. +kind+ is +exception+, +timeout+ or
|
|
39
|
+
# +interrupted+.
|
|
40
|
+
class BlockError < Error
|
|
41
|
+
attr_reader :kind
|
|
42
|
+
|
|
43
|
+
def initialize(kind, message)
|
|
44
|
+
super("#{kind}: #{message}", code: kind)
|
|
45
|
+
@kind = kind
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The approval must be answered by a human in a browser: open +approval_url+.
|
|
50
|
+
class LinkOnlyApprovalError < ConflictError
|
|
51
|
+
def approval_url
|
|
52
|
+
url = body["approvalUrl"]
|
|
53
|
+
url.is_a?(String) ? url : nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 403 +spend_cap_reached+: the key reached its monthly spend limit, or (on a cloud machine
|
|
58
|
+
# create, +body["scope"] == "tenant"+) the Tenant reached its budget. Not retryable: the limit
|
|
59
|
+
# resets at +resets_at+ (ISO 8601), or raise it for the key in the portal.
|
|
60
|
+
class SpendCapReachedError < PermissionDeniedError
|
|
61
|
+
def resets_at
|
|
62
|
+
at = body["resetsAt"]
|
|
63
|
+
at.is_a?(String) ? at : nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @api private
|
|
68
|
+
module Errors
|
|
69
|
+
BY_CODE = {
|
|
70
|
+
"link_only" => LinkOnlyApprovalError,
|
|
71
|
+
"spend_cap_reached" => SpendCapReachedError
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
BY_STATUS = {
|
|
75
|
+
400 => BadRequestError,
|
|
76
|
+
401 => AuthenticationError,
|
|
77
|
+
403 => PermissionDeniedError,
|
|
78
|
+
404 => NotFoundError,
|
|
79
|
+
409 => ConflictError,
|
|
80
|
+
410 => GoneError,
|
|
81
|
+
429 => RateLimitError,
|
|
82
|
+
503 => ServiceUnavailableError
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
module_function
|
|
86
|
+
|
|
87
|
+
def for(status, body, retry_after)
|
|
88
|
+
err = body["error"]
|
|
89
|
+
detail = body["message"]
|
|
90
|
+
# Two body shapes: { error: "<human text>" } (/v1/agents) and
|
|
91
|
+
# { error: "<code>", message: "<human text>" } (/v1/machines/{id}/control, newer errors).
|
|
92
|
+
code = err.is_a?(String) && (body.key?("message") || looks_like_code?(err)) ? err : nil
|
|
93
|
+
message = if detail.is_a?(String) then detail
|
|
94
|
+
elsif err.is_a?(String) then err
|
|
95
|
+
else "HTTP #{status}"
|
|
96
|
+
end
|
|
97
|
+
message += " (SAI_API_KEY was rejected: it may be revoked.)" if status == 401
|
|
98
|
+
cls = BY_CODE[code] || BY_STATUS[status] || Error
|
|
99
|
+
# The Retry-After header wins; retryAfterSeconds is the same value in the body (rate_limited).
|
|
100
|
+
wait = retry_after
|
|
101
|
+
wait = body["retryAfterSeconds"] if wait.nil? && body["retryAfterSeconds"].is_a?(Numeric)
|
|
102
|
+
cls.new(message, status: status, code: code, body: body, retry_after: wait)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def looks_like_code?(s)
|
|
106
|
+
!s.empty? && !s.include?(" ") && s == s.downcase
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/sai/http.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Sai
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.simular.ai"
|
|
10
|
+
|
|
11
|
+
# @api private
|
|
12
|
+
# The one place requests are made: auth header, error mapping, timeouts.
|
|
13
|
+
class Http
|
|
14
|
+
Response = Struct.new(:status, :reason, :body, :headers, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# +credential+ is the bearer token, or a callable that returns it before each request.
|
|
17
|
+
def initialize(credential, base_url, timeout, version_tag)
|
|
18
|
+
@credential = credential
|
|
19
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
@headers = { "User-Agent" => "sai-ruby/#{VERSION}" }
|
|
22
|
+
@headers["Authorization"] = "Bearer #{credential}" unless credential.respond_to?(:call)
|
|
23
|
+
# routes the request to a tagged API revision
|
|
24
|
+
@headers["x-sai-version"] = version_tag if version_tag && !version_tag.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def request(method, path, json: UNSET, params: nil, body: nil, headers: nil, timeout: nil)
|
|
28
|
+
uri = URI(@base_url + path)
|
|
29
|
+
query = (params || {}).reject { |_, v| v.nil? }
|
|
30
|
+
uri.query = URI.encode_www_form(query.map { |k, v| [k.to_s, v.to_s] }) unless query.empty?
|
|
31
|
+
req = Net::HTTPGenericRequest.new(
|
|
32
|
+
method, !body.nil? || !json.equal?(UNSET), true, uri.request_uri
|
|
33
|
+
)
|
|
34
|
+
@headers.merge(headers || {}).each { |k, v| req[k] = v }
|
|
35
|
+
req["Authorization"] = "Bearer #{@credential.call}" if @credential.respond_to?(:call)
|
|
36
|
+
if !json.equal?(UNSET)
|
|
37
|
+
req["Content-Type"] = "application/json"
|
|
38
|
+
req.body = JSON.generate(json)
|
|
39
|
+
elsif body
|
|
40
|
+
req.body = body
|
|
41
|
+
end
|
|
42
|
+
res = perform(uri, req, timeout || @timeout)
|
|
43
|
+
raise to_error(res) if res.status >= 400
|
|
44
|
+
|
|
45
|
+
res
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def json(method, path, **opts)
|
|
49
|
+
res = request(method, path, **opts)
|
|
50
|
+
text = res.body.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
51
|
+
text.empty? ? nil : JSON.parse(text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
UNSET = Object.new.freeze
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def perform(uri, req, timeout)
|
|
59
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
60
|
+
http.use_ssl = uri.scheme == "https"
|
|
61
|
+
http.open_timeout = timeout
|
|
62
|
+
http.read_timeout = timeout
|
|
63
|
+
http.write_timeout = timeout
|
|
64
|
+
res = http.start { http.request(req) }
|
|
65
|
+
headers = {}
|
|
66
|
+
res.each_header { |k, v| headers[k] = v }
|
|
67
|
+
Response.new(status: res.code.to_i, reason: res.message.to_s, body: (res.body || "").b, headers: headers)
|
|
68
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
69
|
+
raise ConnectionError, "Request to #{host(uri)} timed out."
|
|
70
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
71
|
+
raise ConnectionError,
|
|
72
|
+
"Could not reach the Sai API at #{host(uri)}: #{e.message}. Check the network and SAI_API_URL."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def host(uri)
|
|
76
|
+
uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_error(res)
|
|
80
|
+
text = res.body.dup.force_encoding(Encoding::UTF_8)
|
|
81
|
+
body = begin
|
|
82
|
+
parsed = JSON.parse(text)
|
|
83
|
+
if parsed.is_a?(Hash) then parsed
|
|
84
|
+
elsif parsed.is_a?(Array) then { "error" => parsed.join(",") }
|
|
85
|
+
else { "error" => parsed.nil? ? "null" : parsed.to_s }
|
|
86
|
+
end
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
short = text[0, 500]
|
|
89
|
+
{ "error" => short.nil? || short.empty? ? res.reason : short }
|
|
90
|
+
end
|
|
91
|
+
Errors.for(res.status, body, retry_after(res.headers["retry-after"]))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def retry_after(raw)
|
|
95
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
96
|
+
|
|
97
|
+
v = Float(raw)
|
|
98
|
+
v == v.floor ? v.to_i : v
|
|
99
|
+
rescue ArgumentError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/sai/learn.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Sai
|
|
6
|
+
# Learn (research preview): turn recorded turns, or a task the agent does first, into Simulang
|
|
7
|
+
# code you own; repair that code; run, judge and repair it on a machine until it passes.
|
|
8
|
+
class Learn
|
|
9
|
+
# A conversion is two model calls of up to a minute or two each, plus one retry.
|
|
10
|
+
TIMEOUT = 600
|
|
11
|
+
# How often +wait+ reads a learn job; a job's steps take tens of seconds to minutes.
|
|
12
|
+
POLL_INTERVAL = 10
|
|
13
|
+
# Options sent as given, by their API name.
|
|
14
|
+
PASSTHROUGH = {
|
|
15
|
+
history: "history",
|
|
16
|
+
runs_without_reset: "runsWithoutReset",
|
|
17
|
+
max_iterations: "maxIterations",
|
|
18
|
+
rollouts: "rollouts",
|
|
19
|
+
reset: "reset",
|
|
20
|
+
judge_initial_agent_run: "judgeInitialAgentRun",
|
|
21
|
+
continue_with_agent: "continueWithAgent",
|
|
22
|
+
rollout_minutes: "rolloutMinutes",
|
|
23
|
+
agent_run_minutes: "agentRunMinutes",
|
|
24
|
+
continuation_minutes: "continuationMinutes",
|
|
25
|
+
max_job_minutes: "maxJobMinutes",
|
|
26
|
+
model: "model",
|
|
27
|
+
idempotency_key: "idempotency_key"
|
|
28
|
+
}.freeze
|
|
29
|
+
private_constant :PASSTHROUGH
|
|
30
|
+
|
|
31
|
+
# @api private
|
|
32
|
+
def initialize(http)
|
|
33
|
+
@http = http
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Make Simulang code, and optionally run, judge and repair it on a machine.
|
|
37
|
+
#
|
|
38
|
+
# - +create([turn])+: code from recorded turns (one session, each sent with
|
|
39
|
+
# +record_trajectory: true+). Answers with the code.
|
|
40
|
+
# - +create(initial_code:, guidance: or evaluations:)+: a repair. Answers with the code.
|
|
41
|
+
# - +create(turns, max_iterations: 3)+ or +create(task_template:, inputs:, max_iterations: 3)+:
|
|
42
|
+
# a job on the machine. Answers at once with +status == "running"+; read it with +wait+.
|
|
43
|
+
#
|
|
44
|
+
# +criteria+ is a sentence or a list of Check; +reset+ is +{ code: }+ or +{ task: }+;
|
|
45
|
+
# +evaluations+ a list of Evaluation. +template+ and +feedback+ are the old names of
|
|
46
|
+
# +task_template+ and +guidance+. +timeout+ is in seconds.
|
|
47
|
+
# @return [LearnResult]
|
|
48
|
+
def create(turns = [], task_template: nil, template: nil, inputs: nil, initial_code: nil, evaluations: nil,
|
|
49
|
+
guidance: nil, feedback: nil, machine: nil, criteria: nil, timeout: TIMEOUT, **options)
|
|
50
|
+
unknown = options.keys - PASSTHROUGH.keys
|
|
51
|
+
raise ArgumentError, "unknown keyword#{'s' if unknown.length > 1}: #{unknown.join(', ')}" unless unknown.empty?
|
|
52
|
+
|
|
53
|
+
body = { "turns" => turns.map { |t| Util.turn_id(t) } }
|
|
54
|
+
# The old names go out as sent, so code that uses them keeps working with an older API.
|
|
55
|
+
if !task_template.nil? then body["taskTemplate"] = task_template
|
|
56
|
+
elsif !template.nil? then body["template"] = template
|
|
57
|
+
end
|
|
58
|
+
body["inputs"] = inputs unless inputs.nil?
|
|
59
|
+
body["initialCode"] = initial_code unless initial_code.nil?
|
|
60
|
+
body["evaluations"] = evaluations.map { |e| Evaluation.to_api(e) } if evaluations
|
|
61
|
+
if !guidance.nil? then body["guidance"] = guidance
|
|
62
|
+
elsif !feedback.nil? then body["feedback"] = feedback
|
|
63
|
+
end
|
|
64
|
+
body["machineId"] = Util.id_of(machine) unless machine.nil?
|
|
65
|
+
body["criteria"] = Check.list_to_api(criteria) unless criteria.nil?
|
|
66
|
+
PASSTHROUGH.each { |k, name| body[name] = options[k] unless options[k].nil? }
|
|
67
|
+
LearnResult.parse(Parse.obj(@http.json("POST", "/v1/agents/learn", json: body, timeout: timeout)))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A learn call's result, or a job's progress so far.
|
|
71
|
+
# @return [LearnResult]
|
|
72
|
+
def get(learn_id)
|
|
73
|
+
LearnResult.parse(Parse.obj(@http.json("GET", "/v1/agents/learn/#{Util.q(learn_id)}")))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Read a learn job (a LearnResult or its id) until it ends (+succeeded+, +failed+ or
|
|
77
|
+
# +stopped+), every +poll_interval+ seconds. +timeout+ (seconds) raises Timeout::Error with the
|
|
78
|
+
# job still running; it keeps running.
|
|
79
|
+
# @return [LearnResult]
|
|
80
|
+
def wait(job, timeout: nil, poll_interval: POLL_INTERVAL)
|
|
81
|
+
learn_id = Util.id_of(job)
|
|
82
|
+
deadline = timeout.nil? ? nil : Util.monotonic + timeout
|
|
83
|
+
loop do
|
|
84
|
+
begin
|
|
85
|
+
r = get(learn_id)
|
|
86
|
+
rescue RateLimitError => e
|
|
87
|
+
sleep(e.retry_after.nil? ? 30 : e.retry_after)
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
return r if r.status != "running"
|
|
91
|
+
raise Timeout::Error, "Learn job #{learn_id} is still running." if deadline && Util.monotonic + poll_interval > deadline
|
|
92
|
+
|
|
93
|
+
sleep(poll_interval)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Stop a learn job at once, whatever step it is on. It keeps the last complete results.
|
|
98
|
+
# @return [nil]
|
|
99
|
+
def cancel(job)
|
|
100
|
+
@http.json("POST", "/v1/agents/learn/#{Util.q(Util.id_of(job))}/cancel")
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# One run of a learn job's code: what it returned and logged, and its judgement.
|
|
105
|
+
# @return [LearnRun]
|
|
106
|
+
def rollout(job, iteration, rollout)
|
|
107
|
+
path = "/v1/agents/learn/#{Util.q(Util.id_of(job))}/iterations/#{iteration}/rollouts/#{rollout}"
|
|
108
|
+
LearnRun.parse(Parse.obj(@http.json("GET", path)))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Judge recorded turns against new criteria (research preview).
|
|
113
|
+
class Judge
|
|
114
|
+
TIMEOUT = 180
|
|
115
|
+
|
|
116
|
+
# @api private
|
|
117
|
+
def initialize(http)
|
|
118
|
+
@http = http
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Judge recorded +turns+ (one session, as one task) against +criteria+: a sentence for a model
|
|
122
|
+
# to judge, or a list of Check. Nothing runs again, and the turns do not change. +task+
|
|
123
|
+
# defaults to the first turn's message.
|
|
124
|
+
# @return [Judgement]
|
|
125
|
+
def create(turns, criteria, task: nil, idempotency_key: nil, timeout: TIMEOUT)
|
|
126
|
+
body = { "turns" => turns.map { |t| Util.turn_id(t) }, "criteria" => Check.list_to_api(criteria) }
|
|
127
|
+
body["task"] = task unless task.nil?
|
|
128
|
+
body["idempotency_key"] = idempotency_key unless idempotency_key.nil?
|
|
129
|
+
Judgement.parse(Parse.obj(@http.json("POST", "/v1/agents/judge", json: body, timeout: timeout)))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Judgement]
|
|
133
|
+
def get(judgement_id)
|
|
134
|
+
Judgement.parse(Parse.obj(@http.json("GET", "/v1/agents/judge/#{Util.q(judgement_id)}")))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/sai/machines.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sai
|
|
4
|
+
# The computers Sai works on: cloud computers, or the customer's own PC with the Sai app.
|
|
5
|
+
class Machines
|
|
6
|
+
# @api private
|
|
7
|
+
def initialize(http)
|
|
8
|
+
@http = http
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @return [Array<Machine>]
|
|
12
|
+
def list
|
|
13
|
+
d = @http.json("GET", "/v1/agents/machines") || {}
|
|
14
|
+
Parse.list(d["machines"]).map { |m| Machine.parse(m) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @raise [NotFoundError] when the machine is not on this account
|
|
18
|
+
def get(machine_id)
|
|
19
|
+
m = list.find { |x| x.id == machine_id }
|
|
20
|
+
raise NotFoundError.new("Machine #{machine_id} not found on this account.", status: 404) unless m
|
|
21
|
+
|
|
22
|
+
m
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The live screen: +live_url+, a page a person can open without a Simular sign-in, and a
|
|
26
|
+
# Guacamole tunnel for a viewer of your own. The machine must be running.
|
|
27
|
+
# @return [LiveView]
|
|
28
|
+
def live(machine)
|
|
29
|
+
id = Util.id_of(machine)
|
|
30
|
+
d = @http.json("POST", "/v1/agents/machines/#{Util.q(id)}/live") || {}
|
|
31
|
+
LiveView.new(
|
|
32
|
+
machine_id: Parse.str(d["machineId"]) || id,
|
|
33
|
+
live_url: Parse.str(d["liveUrl"]),
|
|
34
|
+
expires_at: Parse.num_or_nil(d["expiresAt"]),
|
|
35
|
+
websocket_url: d["websocketUrl"].to_s,
|
|
36
|
+
width: Parse.num(d["width"]),
|
|
37
|
+
height: Parse.num(d["height"])
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Revokes every +live_url+ handed out for the machine.
|
|
42
|
+
# @return [Integer] how many were revoked
|
|
43
|
+
def revoke_live(machine)
|
|
44
|
+
d = @http.json("DELETE", "/v1/agents/machines/#{Util.q(Util.id_of(machine))}/live")
|
|
45
|
+
Parse.num(Parse.obj(d)["revoked"])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|