arcp 1.0.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/CHANGELOG.md +31 -0
- data/CONFORMANCE.md +71 -0
- data/LICENSE +202 -0
- data/README.md +135 -0
- data/lib/arcp/auth/auth_scheme.rb +16 -0
- data/lib/arcp/auth/bearer.rb +38 -0
- data/lib/arcp/auth.rb +4 -0
- data/lib/arcp/client.rb +354 -0
- data/lib/arcp/clock.rb +35 -0
- data/lib/arcp/credential.rb +65 -0
- data/lib/arcp/credential_provisioner.rb +132 -0
- data/lib/arcp/envelope.rb +115 -0
- data/lib/arcp/errors.rb +154 -0
- data/lib/arcp/ids.rb +18 -0
- data/lib/arcp/job/accepted.rb +37 -0
- data/lib/arcp/job/agent_ref.rb +18 -0
- data/lib/arcp/job/cancel.rb +18 -0
- data/lib/arcp/job/event.rb +68 -0
- data/lib/arcp/job/event_body/delegate.rb +24 -0
- data/lib/arcp/job/event_body/log.rb +20 -0
- data/lib/arcp/job/event_body/metric.rb +20 -0
- data/lib/arcp/job/event_body/progress.rb +27 -0
- data/lib/arcp/job/event_body/result_chunk.rb +42 -0
- data/lib/arcp/job/event_body/status.rb +25 -0
- data/lib/arcp/job/event_body/thought.rb +12 -0
- data/lib/arcp/job/event_body/tool_call.rb +16 -0
- data/lib/arcp/job/event_body/tool_result.rb +23 -0
- data/lib/arcp/job/event_body/trace_span.rb +30 -0
- data/lib/arcp/job/handle.rb +16 -0
- data/lib/arcp/job/job_error.rb +31 -0
- data/lib/arcp/job/result.rb +30 -0
- data/lib/arcp/job/submit.rb +32 -0
- data/lib/arcp/job/subscribe.rb +22 -0
- data/lib/arcp/job/subscribed.rb +14 -0
- data/lib/arcp/job/summary.rb +27 -0
- data/lib/arcp/job/unsubscribe.rb +10 -0
- data/lib/arcp/job.rb +15 -0
- data/lib/arcp/lease.rb +212 -0
- data/lib/arcp/message_types.rb +35 -0
- data/lib/arcp/runtime/credential_registry.rb +67 -0
- data/lib/arcp/runtime/event_log.rb +62 -0
- data/lib/arcp/runtime/job_context.rb +167 -0
- data/lib/arcp/runtime/job_manager.rb +256 -0
- data/lib/arcp/runtime/lease_manager.rb +88 -0
- data/lib/arcp/runtime/runtime.rb +125 -0
- data/lib/arcp/runtime/session_actor.rb +300 -0
- data/lib/arcp/runtime/subscription_manager.rb +57 -0
- data/lib/arcp/runtime.rb +10 -0
- data/lib/arcp/serializer.rb +43 -0
- data/lib/arcp/session/ack.rb +14 -0
- data/lib/arcp/session/agent_inventory.rb +51 -0
- data/lib/arcp/session/bye.rb +10 -0
- data/lib/arcp/session/capability_set.rb +29 -0
- data/lib/arcp/session/feature.rb +25 -0
- data/lib/arcp/session/hello.rb +34 -0
- data/lib/arcp/session/jobs_response.rb +18 -0
- data/lib/arcp/session/list_jobs.rb +19 -0
- data/lib/arcp/session/ping.rb +14 -0
- data/lib/arcp/session/pong.rb +14 -0
- data/lib/arcp/session/session_error.rb +23 -0
- data/lib/arcp/session/welcome.rb +38 -0
- data/lib/arcp/session.rb +26 -0
- data/lib/arcp/trace.rb +51 -0
- data/lib/arcp/transport/base.rb +29 -0
- data/lib/arcp/transport/memory_transport.rb +54 -0
- data/lib/arcp/transport/stdio_transport.rb +56 -0
- data/lib/arcp/transport/websocket_transport.rb +47 -0
- data/lib/arcp/transport.rb +6 -0
- data/lib/arcp/version.rb +7 -0
- data/lib/arcp.rb +19 -0
- data/sig/arcp/client.rbs +16 -0
- data/sig/arcp/credential.rbs +51 -0
- data/sig/arcp/envelope.rbs +25 -0
- data/sig/arcp/errors.rbs +40 -0
- data/sig/arcp/job.rbs +83 -0
- data/sig/arcp/lease.rbs +41 -0
- data/sig/arcp/runtime.rbs +41 -0
- data/sig/arcp/serializer.rbs +8 -0
- data/sig/arcp/session.rbs +56 -0
- data/sig/arcp/transport.rbs +18 -0
- data/sig/arcp.rbs +5 -0
- metadata +226 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arcp
|
|
4
|
+
module Job
|
|
5
|
+
Subscribe = Data.define(:job_id, :from_event_seq, :history) do
|
|
6
|
+
def self.from_h(h)
|
|
7
|
+
h = h.transform_keys(&:to_s)
|
|
8
|
+
new(
|
|
9
|
+
job_id: h.fetch('job_id'),
|
|
10
|
+
from_event_seq: h['from_event_seq'],
|
|
11
|
+
history: h.fetch('history', false)
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
out = { 'job_id' => job_id, 'history' => history }
|
|
17
|
+
out['from_event_seq'] = from_event_seq if from_event_seq
|
|
18
|
+
out
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arcp
|
|
4
|
+
module Job
|
|
5
|
+
Subscribed = Data.define(:job_id, :subscribed_from) do
|
|
6
|
+
def self.from_h(h)
|
|
7
|
+
h = h.transform_keys(&:to_s)
|
|
8
|
+
new(job_id: h.fetch('job_id'), subscribed_from: h.fetch('subscribed_from'))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_h = { 'job_id' => job_id, 'subscribed_from' => subscribed_from }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arcp
|
|
4
|
+
module Job
|
|
5
|
+
Summary = Data.define(:job_id, :agent, :status, :created_at, :lease_expires_at, :budget_remaining) do
|
|
6
|
+
def self.from_h(h)
|
|
7
|
+
h = h.transform_keys(&:to_s)
|
|
8
|
+
new(
|
|
9
|
+
job_id: h.fetch('job_id'),
|
|
10
|
+
agent: h.fetch('agent'),
|
|
11
|
+
status: h.fetch('status'),
|
|
12
|
+
created_at: h['created_at'],
|
|
13
|
+
lease_expires_at: h['lease_expires_at'],
|
|
14
|
+
budget_remaining: h['budget_remaining']
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
out = { 'job_id' => job_id, 'agent' => agent, 'status' => status }
|
|
20
|
+
out['created_at'] = created_at if created_at
|
|
21
|
+
out['lease_expires_at'] = lease_expires_at if lease_expires_at
|
|
22
|
+
out['budget_remaining'] = budget_remaining if budget_remaining
|
|
23
|
+
out
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/arcp/job.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lease'
|
|
4
|
+
require_relative 'job/agent_ref'
|
|
5
|
+
require_relative 'job/submit'
|
|
6
|
+
require_relative 'job/accepted'
|
|
7
|
+
require_relative 'job/event'
|
|
8
|
+
require_relative 'job/result'
|
|
9
|
+
require_relative 'job/job_error'
|
|
10
|
+
require_relative 'job/cancel'
|
|
11
|
+
require_relative 'job/subscribe'
|
|
12
|
+
require_relative 'job/subscribed'
|
|
13
|
+
require_relative 'job/unsubscribe'
|
|
14
|
+
require_relative 'job/handle'
|
|
15
|
+
require_relative 'job/summary'
|
data/lib/arcp/lease.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
require_relative 'errors'
|
|
7
|
+
require_relative 'credential'
|
|
8
|
+
|
|
9
|
+
module Arcp
|
|
10
|
+
module Lease
|
|
11
|
+
LeaseConstraints = Data.define(:expires_at, :max_budget) do
|
|
12
|
+
def self.from_h(h)
|
|
13
|
+
return nil if h.nil?
|
|
14
|
+
|
|
15
|
+
h = h.transform_keys(&:to_s)
|
|
16
|
+
new(expires_at: h['expires_at'], max_budget: h['max_budget'])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
out = {}
|
|
21
|
+
out['expires_at'] = expires_at if expires_at
|
|
22
|
+
out['max_budget'] = max_budget if max_budget
|
|
23
|
+
out
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate!
|
|
27
|
+
return if expires_at.nil?
|
|
28
|
+
|
|
29
|
+
t = Time.iso8601(expires_at)
|
|
30
|
+
raise Arcp::Errors::InvalidRequest, "expires_at must be UTC (use 'Z'): #{expires_at}" unless t.utc?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
CostBudget = Data.define(:per_currency) do
|
|
35
|
+
def self.parse(entries)
|
|
36
|
+
h = {}
|
|
37
|
+
Array(entries).each do |entry|
|
|
38
|
+
ccy, amount = entry.to_s.split(':', 2)
|
|
39
|
+
if ccy.nil? || amount.nil?
|
|
40
|
+
raise Arcp::Errors::InvalidRequest,
|
|
41
|
+
"malformed budget entry: #{entry.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
h[ccy] = BigDecimal(amount)
|
|
45
|
+
end
|
|
46
|
+
new(per_currency: h.freeze)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_a = per_currency.map { |ccy, amt| "#{ccy}:#{amt.to_s('F')}" }
|
|
50
|
+
def to_h = { 'cost.budget' => to_a }
|
|
51
|
+
|
|
52
|
+
def remaining(currency) = per_currency[currency] || BigDecimal('0')
|
|
53
|
+
def currencies = per_currency.keys
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class BudgetCounter
|
|
57
|
+
attr_reader :remaining
|
|
58
|
+
|
|
59
|
+
def initialize(initial:)
|
|
60
|
+
@remaining = initial.dup
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def try_decrement(currency, amount)
|
|
64
|
+
balance = @remaining[currency]
|
|
65
|
+
return false if balance.nil?
|
|
66
|
+
return false if balance < amount
|
|
67
|
+
|
|
68
|
+
@remaining[currency] = balance - amount
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get(currency) = @remaining[currency] || BigDecimal('0')
|
|
73
|
+
def negative?(currency) = (@remaining[currency] || BigDecimal('0')).negative?
|
|
74
|
+
|
|
75
|
+
def snapshot
|
|
76
|
+
@remaining.transform_values(&:dup).freeze
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
LeaseRequest = Data.define(:capabilities, :budget, :model_use, :expires_at) do
|
|
81
|
+
def initialize(capabilities:, budget: nil, model_use: nil, expires_at: nil)
|
|
82
|
+
super(
|
|
83
|
+
capabilities: Array(capabilities).freeze,
|
|
84
|
+
budget: budget,
|
|
85
|
+
model_use: model_use ? Array(model_use).freeze : nil,
|
|
86
|
+
expires_at: expires_at
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.from_h(h)
|
|
91
|
+
return nil if h.nil?
|
|
92
|
+
|
|
93
|
+
h = h.transform_keys(&:to_s)
|
|
94
|
+
new(
|
|
95
|
+
capabilities: Array(h['capabilities']).freeze,
|
|
96
|
+
budget: h['cost.budget'] ? CostBudget.parse(h['cost.budget']) : nil,
|
|
97
|
+
model_use: h['model.use'] ? Array(h['model.use']).freeze : nil,
|
|
98
|
+
expires_at: h['expires_at']
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_h
|
|
103
|
+
out = { 'capabilities' => capabilities }
|
|
104
|
+
out['cost.budget'] = budget.to_a if budget
|
|
105
|
+
out['model.use'] = model_use if model_use
|
|
106
|
+
out['expires_at'] = expires_at if expires_at
|
|
107
|
+
out
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Lease = Data.define(:id, :capabilities, :budget, :model_use, :expires_at, :issued_at) do
|
|
112
|
+
def initialize(id:, capabilities:, issued_at:, budget: nil, model_use: nil, expires_at: nil)
|
|
113
|
+
super(
|
|
114
|
+
id: id,
|
|
115
|
+
capabilities: Array(capabilities).freeze,
|
|
116
|
+
budget: budget,
|
|
117
|
+
model_use: model_use ? Array(model_use).freeze : nil,
|
|
118
|
+
expires_at: expires_at,
|
|
119
|
+
issued_at: issued_at
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.from_h(h)
|
|
124
|
+
h = h.transform_keys(&:to_s)
|
|
125
|
+
new(
|
|
126
|
+
id: h.fetch('id'),
|
|
127
|
+
capabilities: Array(h['capabilities']).freeze,
|
|
128
|
+
budget: h['cost.budget'] ? CostBudget.parse(h['cost.budget']) : nil,
|
|
129
|
+
model_use: h['model.use'] ? Array(h['model.use']).freeze : nil,
|
|
130
|
+
expires_at: h['expires_at'],
|
|
131
|
+
issued_at: h['issued_at']
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def to_h
|
|
136
|
+
out = { 'id' => id, 'capabilities' => capabilities, 'issued_at' => issued_at }
|
|
137
|
+
out['cost.budget'] = budget.to_a if budget
|
|
138
|
+
out['model.use'] = model_use if model_use
|
|
139
|
+
out['expires_at'] = expires_at if expires_at
|
|
140
|
+
out
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def expired?(now)
|
|
144
|
+
return false if expires_at.nil?
|
|
145
|
+
|
|
146
|
+
Time.iso8601(expires_at) <= now
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
module Subsetting
|
|
151
|
+
module_function
|
|
152
|
+
|
|
153
|
+
# Compute a delegate lease bounded by the parent. Raises
|
|
154
|
+
# `LeaseSubsetViolation` if requested capabilities exceed parent,
|
|
155
|
+
# requested expires_at is beyond parent, or remaining budget can't
|
|
156
|
+
# cover the requested amount.
|
|
157
|
+
def bound(parent:, request:, parent_remaining: nil)
|
|
158
|
+
excess = request.capabilities - parent.capabilities
|
|
159
|
+
unless excess.empty?
|
|
160
|
+
raise Arcp::Errors::LeaseSubsetViolation,
|
|
161
|
+
"child lease capabilities not in parent: #{excess.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if request.expires_at && parent.expires_at
|
|
165
|
+
parent_t = Time.iso8601(parent.expires_at)
|
|
166
|
+
req_t = Time.iso8601(request.expires_at)
|
|
167
|
+
if req_t > parent_t
|
|
168
|
+
raise Arcp::Errors::LeaseSubsetViolation,
|
|
169
|
+
"child expires_at #{request.expires_at} exceeds parent #{parent.expires_at}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
budget = nil
|
|
174
|
+
if request.budget
|
|
175
|
+
parent_pc = parent_remaining || (parent.budget&.per_currency || {})
|
|
176
|
+
missing = request.budget.per_currency.filter_map do |ccy, amt|
|
|
177
|
+
available = parent_pc[ccy] || BigDecimal('0')
|
|
178
|
+
(ccy if amt > available)
|
|
179
|
+
end
|
|
180
|
+
unless missing.empty?
|
|
181
|
+
raise Arcp::Errors::LeaseSubsetViolation,
|
|
182
|
+
"child budget exceeds parent remaining for: #{missing.inspect}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
budget = request.budget
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
model_use = bound_model_use(parent: parent, request: request)
|
|
189
|
+
|
|
190
|
+
Lease.new(
|
|
191
|
+
id: Arcp::Ids.session_id.sub(/^ses_/, 'lse_'),
|
|
192
|
+
capabilities: request.capabilities,
|
|
193
|
+
budget: budget,
|
|
194
|
+
model_use: model_use,
|
|
195
|
+
expires_at: request.expires_at || parent.expires_at,
|
|
196
|
+
issued_at: Time.now.utc.iso8601
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def bound_model_use(parent:, request:)
|
|
201
|
+
return nil unless request.model_use
|
|
202
|
+
|
|
203
|
+
unless request.model_use.all? { |pattern| Arcp::ModelPattern.implied_by?(parent.model_use, pattern) }
|
|
204
|
+
raise Arcp::Errors::LeaseSubsetViolation,
|
|
205
|
+
"child model.use expands beyond parent: #{request.model_use.inspect}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
request.model_use
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arcp
|
|
4
|
+
module MessageTypes
|
|
5
|
+
SESSION_HELLO = 'session.hello'
|
|
6
|
+
SESSION_WELCOME = 'session.welcome'
|
|
7
|
+
SESSION_BYE = 'session.bye'
|
|
8
|
+
SESSION_ERROR = 'session.error'
|
|
9
|
+
SESSION_PING = 'session.ping'
|
|
10
|
+
SESSION_PONG = 'session.pong'
|
|
11
|
+
SESSION_ACK = 'session.ack'
|
|
12
|
+
SESSION_LIST_JOBS = 'session.list_jobs'
|
|
13
|
+
SESSION_JOBS = 'session.jobs'
|
|
14
|
+
|
|
15
|
+
JOB_SUBMIT = 'job.submit'
|
|
16
|
+
JOB_ACCEPTED = 'job.accepted'
|
|
17
|
+
JOB_EVENT = 'job.event'
|
|
18
|
+
JOB_RESULT = 'job.result'
|
|
19
|
+
JOB_ERROR = 'job.error'
|
|
20
|
+
JOB_CANCEL = 'job.cancel'
|
|
21
|
+
JOB_SUBSCRIBE = 'job.subscribe'
|
|
22
|
+
JOB_SUBSCRIBED = 'job.subscribed'
|
|
23
|
+
JOB_UNSUBSCRIBE = 'job.unsubscribe'
|
|
24
|
+
|
|
25
|
+
ALL = [
|
|
26
|
+
SESSION_HELLO, SESSION_WELCOME, SESSION_BYE, SESSION_ERROR,
|
|
27
|
+
SESSION_PING, SESSION_PONG, SESSION_ACK,
|
|
28
|
+
SESSION_LIST_JOBS, SESSION_JOBS,
|
|
29
|
+
JOB_SUBMIT, JOB_ACCEPTED, JOB_EVENT, JOB_RESULT, JOB_ERROR,
|
|
30
|
+
JOB_CANCEL, JOB_SUBSCRIBE, JOB_SUBSCRIBED, JOB_UNSUBSCRIBE
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def self.known?(type) = ALL.include?(type)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../clock'
|
|
4
|
+
require_relative '../credential'
|
|
5
|
+
require_relative '../credential_provisioner'
|
|
6
|
+
|
|
7
|
+
module Arcp
|
|
8
|
+
module Runtime
|
|
9
|
+
class CredentialRegistry
|
|
10
|
+
def initialize(provisioner:, store:, clock: Arcp::SystemClock.new)
|
|
11
|
+
@provisioner = provisioner
|
|
12
|
+
@store = store
|
|
13
|
+
@clock = clock
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def issue_for(job_id:, lease:, agent:, principal_id:)
|
|
18
|
+
credentials = @provisioner.issue(
|
|
19
|
+
lease: lease, job_id: job_id, agent: agent, principal_id: principal_id
|
|
20
|
+
)
|
|
21
|
+
Array(credentials).each do |credential|
|
|
22
|
+
@store.record(job_id: job_id, credential_id: credential.id)
|
|
23
|
+
end
|
|
24
|
+
Array(credentials).freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rotate(job_id:, credential_id:, new_value:)
|
|
28
|
+
revoke(credential_id)
|
|
29
|
+
new_id = "#{credential_id}_rotated_#{@clock.now.to_i}"
|
|
30
|
+
@store.record(job_id: job_id, credential_id: new_id)
|
|
31
|
+
new_id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def revoke_all(job_id:)
|
|
35
|
+
@store.outstanding(job_id: job_id).count do |credential_id|
|
|
36
|
+
revoke(credential_id).tap do |revoked|
|
|
37
|
+
@store.forget(job_id: job_id, credential_id: credential_id) if revoked
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reconcile_on_startup!
|
|
43
|
+
@store.all_outstanding.each do |job_id, credential_ids|
|
|
44
|
+
credential_ids.each do |credential_id|
|
|
45
|
+
@store.forget(job_id: job_id, credential_id: credential_id) if revoke(credential_id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def revoke(credential_id)
|
|
54
|
+
attempts = 0
|
|
55
|
+
begin
|
|
56
|
+
attempts += 1
|
|
57
|
+
@provisioner.revoke(credential_id: credential_id)
|
|
58
|
+
true
|
|
59
|
+
rescue StandardError
|
|
60
|
+
retry if attempts < 2
|
|
61
|
+
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arcp
|
|
4
|
+
module Runtime
|
|
5
|
+
# In-memory ring of buffered events keyed by session_id. The runtime
|
|
6
|
+
# uses this for the resume window and `session.ack`-driven early
|
|
7
|
+
# eviction. A SQLite-backed variant (same API) is suitable for
|
|
8
|
+
# multi-process runtimes; for v1 we ship the in-memory implementation
|
|
9
|
+
# used by tests and the Falcon-hosted single-process runtime.
|
|
10
|
+
class EventLog
|
|
11
|
+
def initialize(window_sec: 300, clock: Arcp::SystemClock.new)
|
|
12
|
+
@window_sec = window_sec
|
|
13
|
+
@clock = clock
|
|
14
|
+
@sessions = Hash.new { |h, k| h[k] = [] }
|
|
15
|
+
@floor = Hash.new(0)
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def append(session_id, envelope)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@sessions[session_id] << [envelope, @clock.monotonic]
|
|
22
|
+
end
|
|
23
|
+
envelope
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def floor(session_id) = @floor[session_id]
|
|
27
|
+
|
|
28
|
+
def evict_up_to(session_id, seq)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@floor[session_id] = [@floor[session_id], seq].max
|
|
31
|
+
@sessions[session_id].reject! do |env, _t|
|
|
32
|
+
env.event_seq && env.event_seq <= seq
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def replay(session_id, from_event_seq: nil)
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@sessions[session_id].each_with_object([]) do |(env, _t), out|
|
|
40
|
+
next if env.event_seq.nil?
|
|
41
|
+
next if from_event_seq && env.event_seq < from_event_seq
|
|
42
|
+
|
|
43
|
+
out << env
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Evict events past the resume window (advisory; consumer drives via timer).
|
|
49
|
+
def expire!
|
|
50
|
+
now = @clock.monotonic
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@sessions.each_value do |buf|
|
|
53
|
+
buf.reject! { |(_e, t)| (now - t) > @window_sec }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
def buffer_size(session_id) = @sessions[session_id].size
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async/queue'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Arcp
|
|
8
|
+
module Runtime
|
|
9
|
+
# Passed to an agent handler. Exposes emission seams (events,
|
|
10
|
+
# progress, tool calls, streamed result) and read-only state
|
|
11
|
+
# (job_id, agent, input, lease).
|
|
12
|
+
class JobContext
|
|
13
|
+
attr_reader :job_id, :agent, :input, :lease, :event_seq
|
|
14
|
+
|
|
15
|
+
def initialize(job_id:, agent:, input:, lease:, sink:)
|
|
16
|
+
@job_id = job_id
|
|
17
|
+
@agent = agent
|
|
18
|
+
@input = input
|
|
19
|
+
@lease = lease
|
|
20
|
+
@sink = sink
|
|
21
|
+
@event_seq = 0
|
|
22
|
+
@result_id = nil
|
|
23
|
+
@result_buffer = []
|
|
24
|
+
@done = false
|
|
25
|
+
@chunked = false
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def emit(kind:, body:)
|
|
30
|
+
event = Arcp::Job::Event.new(kind: kind, body: body)
|
|
31
|
+
@event_seq = @sink.publish_event(@job_id, event)
|
|
32
|
+
event
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log(level:, message:, **fields)
|
|
36
|
+
emit(kind: Arcp::Job::EventKind::LOG,
|
|
37
|
+
body: Arcp::Job::EventBody::Log.new(level: level, message: message, fields: fields))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def progress(current:, total: nil, units: nil, message: nil)
|
|
41
|
+
emit(kind: Arcp::Job::EventKind::PROGRESS,
|
|
42
|
+
body: Arcp::Job::EventBody::Progress.new(current: current, total: total, units: units,
|
|
43
|
+
message: message))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def metric(name:, value:, unit: nil)
|
|
47
|
+
emit(kind: Arcp::Job::EventKind::METRIC,
|
|
48
|
+
body: Arcp::Job::EventBody::Metric.new(name: name, value: value, unit: unit))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def status(phase:, message: nil, fields: {})
|
|
52
|
+
emit(kind: Arcp::Job::EventKind::STATUS,
|
|
53
|
+
body: Arcp::Job::EventBody::Status.new(phase: phase, message: message,
|
|
54
|
+
fields: fields))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def rotate_credential(id:, new_value:)
|
|
58
|
+
new_id = @sink.runtime.credential_registry&.rotate(
|
|
59
|
+
job_id: job_id,
|
|
60
|
+
credential_id: id,
|
|
61
|
+
new_value: new_value
|
|
62
|
+
)
|
|
63
|
+
status(
|
|
64
|
+
phase: 'credential_rotated',
|
|
65
|
+
fields: { 'id' => new_id || id, 'value' => new_value }
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def tool_call(call_id:, tool:, args:)
|
|
70
|
+
emit(kind: Arcp::Job::EventKind::TOOL_CALL,
|
|
71
|
+
body: Arcp::Job::EventBody::ToolCall.new(call_id: call_id, tool: tool, args: args))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tool_result(call_id:, result: nil, error: nil)
|
|
75
|
+
emit(kind: Arcp::Job::EventKind::TOOL_RESULT,
|
|
76
|
+
body: Arcp::Job::EventBody::ToolResult.new(call_id: call_id, result: result, error: error))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def stream_result(encoding: 'utf8', &block)
|
|
80
|
+
raise Arcp::Errors::ProtocolViolation, 'result already finalized' if @done
|
|
81
|
+
|
|
82
|
+
@chunked = true
|
|
83
|
+
@result_id = Arcp::Ids.result_id
|
|
84
|
+
|
|
85
|
+
writer = ChunkWriter.new(ctx: self, encoding: encoding, result_id: @result_id)
|
|
86
|
+
if block
|
|
87
|
+
yield writer
|
|
88
|
+
writer.close
|
|
89
|
+
@result_buffer = writer.totals
|
|
90
|
+
@result_buffer
|
|
91
|
+
else
|
|
92
|
+
writer
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def finish(result: nil)
|
|
97
|
+
raise Arcp::Errors::ProtocolViolation, 'result already finalized' if @done
|
|
98
|
+
|
|
99
|
+
if @chunked && !result.nil?
|
|
100
|
+
raise Arcp::Errors::ProtocolViolation, 'cannot mix inline result with result_chunk stream'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@done = true
|
|
104
|
+
|
|
105
|
+
@sink.publish_result(
|
|
106
|
+
@job_id,
|
|
107
|
+
Arcp::Job::Result.new(
|
|
108
|
+
job_id: @job_id, final_status: 'success',
|
|
109
|
+
result: result,
|
|
110
|
+
result_id: @chunked ? @result_id : nil,
|
|
111
|
+
result_size: @chunked ? @result_buffer[:bytes] : nil,
|
|
112
|
+
completed_at: Time.now.utc.iso8601
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fail!(code:, message: nil, retryable: false, details: {})
|
|
118
|
+
raise Arcp::Errors::ProtocolViolation, 'result already finalized' if @done
|
|
119
|
+
|
|
120
|
+
@done = true
|
|
121
|
+
@sink.publish_error(
|
|
122
|
+
@job_id,
|
|
123
|
+
Arcp::Job::JobError.new(
|
|
124
|
+
job_id: @job_id, final_status: 'error',
|
|
125
|
+
code: code, message: message, retryable: retryable, details: details
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @api private
|
|
131
|
+
class ChunkWriter
|
|
132
|
+
def initialize(ctx:, encoding:, result_id:)
|
|
133
|
+
@ctx = ctx
|
|
134
|
+
@encoding = encoding
|
|
135
|
+
@result_id = result_id
|
|
136
|
+
@seq = 0
|
|
137
|
+
@bytes = 0
|
|
138
|
+
@closed = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def write(chunk, more: true)
|
|
142
|
+
raise Arcp::Errors::ProtocolViolation, 'stream closed' if @closed
|
|
143
|
+
|
|
144
|
+
data = case @encoding
|
|
145
|
+
when 'base64' then Base64.strict_encode64(chunk)
|
|
146
|
+
else chunk.dup.force_encoding('UTF-8')
|
|
147
|
+
end
|
|
148
|
+
@bytes += chunk.bytesize
|
|
149
|
+
body = Arcp::Job::EventBody::ResultChunk.new(
|
|
150
|
+
result_id: @result_id, chunk_seq: @seq, data: data,
|
|
151
|
+
encoding: @encoding, more: more
|
|
152
|
+
)
|
|
153
|
+
@ctx.emit(kind: Arcp::Job::EventKind::RESULT_CHUNK, body: body)
|
|
154
|
+
@seq += 1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def close
|
|
158
|
+
return if @closed
|
|
159
|
+
|
|
160
|
+
@closed = true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def totals = { bytes: @bytes, chunks: @seq, result_id: @result_id }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|