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
data/lib/arcp/client.rb
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'async/queue'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
require_relative 'envelope'
|
|
8
|
+
require_relative 'errors'
|
|
9
|
+
require_relative 'message_types'
|
|
10
|
+
require_relative 'session'
|
|
11
|
+
require_relative 'job'
|
|
12
|
+
require_relative 'lease'
|
|
13
|
+
require_relative 'trace'
|
|
14
|
+
require_relative 'ids'
|
|
15
|
+
require_relative 'clock'
|
|
16
|
+
|
|
17
|
+
module Arcp
|
|
18
|
+
# ARCP client: opens a session over a transport, submits jobs,
|
|
19
|
+
# consumes events, and provides cursored job listings.
|
|
20
|
+
#
|
|
21
|
+
# @example Connect over an in-memory transport pair
|
|
22
|
+
# server_t, client_t = Arcp::Transport::MemoryTransport.pair
|
|
23
|
+
# Sync do
|
|
24
|
+
# client = Arcp::Client.open(
|
|
25
|
+
# transport: client_t,
|
|
26
|
+
# auth: { 'scheme' => 'bearer', 'token' => 'demo' }
|
|
27
|
+
# )
|
|
28
|
+
# handle = client.submit_job(agent: 'echo', input: { 'msg' => 'hi' })
|
|
29
|
+
# handle.subscribe(client: client).each { |ev| puts ev.kind }
|
|
30
|
+
# client.close
|
|
31
|
+
# end
|
|
32
|
+
class Client
|
|
33
|
+
attr_reader :session, :transport
|
|
34
|
+
|
|
35
|
+
def self.open(transport:, auth:, client_name: 'arcp-ruby', client_version: Arcp::VERSION,
|
|
36
|
+
capabilities: nil, resume: nil, clock: Arcp::SystemClock.new)
|
|
37
|
+
client = new(transport: transport, clock: clock)
|
|
38
|
+
client.handshake!(auth: auth, client_name: client_name, client_version: client_version,
|
|
39
|
+
capabilities: capabilities, resume: resume)
|
|
40
|
+
client
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(transport:, clock: Arcp::SystemClock.new)
|
|
44
|
+
@transport = transport
|
|
45
|
+
@clock = clock
|
|
46
|
+
@session = nil
|
|
47
|
+
@inbox = Async::Queue.new
|
|
48
|
+
@pending = {}
|
|
49
|
+
@job_streams = {}
|
|
50
|
+
@job_results = {}
|
|
51
|
+
@result_waiters = {}
|
|
52
|
+
@reader_task = nil
|
|
53
|
+
@heartbeat_task = nil
|
|
54
|
+
@next_outbound_seq = 0
|
|
55
|
+
@inbound_seq = 0
|
|
56
|
+
@closed = false
|
|
57
|
+
@mutex = Mutex.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def handshake!(auth:, client_name:, client_version:, capabilities: nil, resume: nil)
|
|
61
|
+
caps = capabilities || Arcp::Session::CapabilitySet.local
|
|
62
|
+
session_id = Arcp::Ids.session_id
|
|
63
|
+
hello = Arcp::Session::Hello.new(
|
|
64
|
+
client_name: client_name, client_version: client_version,
|
|
65
|
+
auth: auth, capabilities: caps, resume: resume
|
|
66
|
+
)
|
|
67
|
+
env = Arcp::Envelope.build(
|
|
68
|
+
type: Arcp::MessageTypes::SESSION_HELLO,
|
|
69
|
+
session_id: session_id,
|
|
70
|
+
payload: hello.to_h
|
|
71
|
+
)
|
|
72
|
+
@transport.send(env)
|
|
73
|
+
|
|
74
|
+
welcome_env = @transport.receive
|
|
75
|
+
raise Arcp::Errors::ProtocolViolation, 'transport closed before welcome' if welcome_env.nil?
|
|
76
|
+
|
|
77
|
+
case welcome_env.type
|
|
78
|
+
when Arcp::MessageTypes::SESSION_WELCOME
|
|
79
|
+
welcome = Arcp::Session::Welcome.from_h(welcome_env.payload)
|
|
80
|
+
effective = caps.intersect(welcome.capabilities)
|
|
81
|
+
@session = Arcp::Session::Info.new(
|
|
82
|
+
id: welcome_env.session_id,
|
|
83
|
+
runtime_version: welcome.runtime_version,
|
|
84
|
+
capabilities: effective,
|
|
85
|
+
agents: welcome.capabilities.agents,
|
|
86
|
+
heartbeat_interval_sec: welcome.heartbeat_interval_sec,
|
|
87
|
+
resume_token: welcome.resume_token,
|
|
88
|
+
resume_window_sec: welcome.resume_window_sec
|
|
89
|
+
)
|
|
90
|
+
when Arcp::MessageTypes::SESSION_ERROR
|
|
91
|
+
err = Arcp::Session::SessionError.from_h(welcome_env.payload)
|
|
92
|
+
raise Arcp::Errors.for(err.code, message: err.message, details: err.details || {})
|
|
93
|
+
else
|
|
94
|
+
raise Arcp::Errors::ProtocolViolation, "expected session.welcome, got #{welcome_env.type}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
start_reader!
|
|
98
|
+
if @session.supports?(Arcp::Session::Feature::HEARTBEAT) && @session.heartbeat_interval_sec
|
|
99
|
+
start_heartbeat!
|
|
100
|
+
end
|
|
101
|
+
@session
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def list_jobs(status: nil, agent: nil, created_after: nil, limit: nil, cursor: nil)
|
|
105
|
+
require_feature!(Arcp::Session::Feature::LIST_JOBS)
|
|
106
|
+
|
|
107
|
+
Enumerator.new do |yielder|
|
|
108
|
+
next_cursor = cursor
|
|
109
|
+
loop do
|
|
110
|
+
payload = Arcp::Session::ListJobs.new(
|
|
111
|
+
filter: { 'status' => status, 'agent' => agent, 'created_after' => created_after }.compact,
|
|
112
|
+
limit: limit,
|
|
113
|
+
cursor: next_cursor
|
|
114
|
+
).to_h
|
|
115
|
+
response = request(type: Arcp::MessageTypes::SESSION_LIST_JOBS,
|
|
116
|
+
expect: Arcp::MessageTypes::SESSION_JOBS,
|
|
117
|
+
payload: payload)
|
|
118
|
+
jobs = Arcp::Session::JobsResponse.from_h(response.payload)
|
|
119
|
+
jobs.jobs.each { |j| yielder << Arcp::Job::Summary.from_h(j) }
|
|
120
|
+
next_cursor = jobs.next_cursor
|
|
121
|
+
break if next_cursor.nil?
|
|
122
|
+
end
|
|
123
|
+
end.lazy
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def submit_job(agent:, input: nil, lease_request: nil, lease_constraints: nil,
|
|
127
|
+
idempotency_key: nil, max_runtime_sec: nil)
|
|
128
|
+
lease_constraints&.validate!
|
|
129
|
+
|
|
130
|
+
submit = Arcp::Job::Submit.new(
|
|
131
|
+
agent: agent, input: input,
|
|
132
|
+
lease_request: lease_request, lease_constraints: lease_constraints,
|
|
133
|
+
idempotency_key: idempotency_key, max_runtime_sec: max_runtime_sec
|
|
134
|
+
)
|
|
135
|
+
accepted_env = request(
|
|
136
|
+
type: Arcp::MessageTypes::JOB_SUBMIT,
|
|
137
|
+
expect: Arcp::MessageTypes::JOB_ACCEPTED,
|
|
138
|
+
payload: submit.to_h
|
|
139
|
+
)
|
|
140
|
+
accepted = Arcp::Job::Accepted.from_h(accepted_env.payload)
|
|
141
|
+
Arcp::Job::Handle.new(
|
|
142
|
+
job_id: accepted.job_id, agent: accepted.agent,
|
|
143
|
+
submitted_at: accepted.accepted_at,
|
|
144
|
+
lease: accepted.lease,
|
|
145
|
+
credentials: accepted.credentials
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def subscribe_job(job_id:, from_event_seq: nil, history: false)
|
|
150
|
+
queue = @mutex.synchronize { @job_streams[job_id] ||= Async::Queue.new }
|
|
151
|
+
|
|
152
|
+
if @session.supports?(Arcp::Session::Feature::SUBSCRIBE) && from_event_seq
|
|
153
|
+
send_envelope(type: Arcp::MessageTypes::JOB_SUBSCRIBE,
|
|
154
|
+
job_id: job_id,
|
|
155
|
+
payload: Arcp::Job::Subscribe.new(job_id: job_id, from_event_seq: from_event_seq,
|
|
156
|
+
history: history).to_h)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Enumerator.new do |yielder|
|
|
160
|
+
loop do
|
|
161
|
+
item = queue.dequeue
|
|
162
|
+
break if item.nil? || item == :__arcp_end__
|
|
163
|
+
|
|
164
|
+
yielder << item
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def cancel_job(job_id:, reason: nil)
|
|
170
|
+
send_envelope(type: Arcp::MessageTypes::JOB_CANCEL,
|
|
171
|
+
job_id: job_id,
|
|
172
|
+
payload: Arcp::Job::Cancel.new(job_id: job_id, reason: reason).to_h)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def get_result(job_id:)
|
|
176
|
+
env = @mutex.synchronize { @job_results[job_id] }
|
|
177
|
+
if env.nil?
|
|
178
|
+
queue = Async::Queue.new
|
|
179
|
+
@mutex.synchronize { @result_waiters[job_id] = queue }
|
|
180
|
+
env = queue.dequeue
|
|
181
|
+
end
|
|
182
|
+
case env.type
|
|
183
|
+
when Arcp::MessageTypes::JOB_RESULT
|
|
184
|
+
Arcp::Job::Result.from_h(env.payload)
|
|
185
|
+
when Arcp::MessageTypes::JOB_ERROR
|
|
186
|
+
raise Arcp::Job::JobError.from_h(env.payload).to_exception
|
|
187
|
+
else
|
|
188
|
+
raise Arcp::Errors::ProtocolViolation, "unexpected #{env.type}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def ack(seq)
|
|
193
|
+
require_feature!(Arcp::Session::Feature::ACK)
|
|
194
|
+
send_envelope(type: Arcp::MessageTypes::SESSION_ACK,
|
|
195
|
+
payload: Arcp::Session::Ack.new(last_processed_seq: seq).to_h)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def send_envelope(type:, payload:, job_id: nil)
|
|
199
|
+
raise Arcp::Errors::Internal, 'session not open' unless @session
|
|
200
|
+
raise IOError, 'client closed' if @closed
|
|
201
|
+
|
|
202
|
+
env = Arcp::Envelope.build(
|
|
203
|
+
type: type, session_id: @session.id,
|
|
204
|
+
trace_id: Arcp::Trace.current.trace_id,
|
|
205
|
+
job_id: job_id, payload: payload
|
|
206
|
+
)
|
|
207
|
+
@transport.send(env)
|
|
208
|
+
env
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def close(reason: nil)
|
|
212
|
+
return if @closed
|
|
213
|
+
|
|
214
|
+
@closed = true
|
|
215
|
+
begin
|
|
216
|
+
send_envelope(type: Arcp::MessageTypes::SESSION_BYE,
|
|
217
|
+
payload: Arcp::Session::Bye.new(reason: reason).to_h)
|
|
218
|
+
rescue StandardError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
@heartbeat_task&.stop
|
|
222
|
+
@reader_task&.stop
|
|
223
|
+
@transport.close(reason: reason)
|
|
224
|
+
drain_streams
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def require_feature!(feature)
|
|
231
|
+
return if @session.supports?(feature)
|
|
232
|
+
|
|
233
|
+
raise Arcp::Errors::UnnegotiatedFeature, "feature not negotiated: #{feature}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def request(type:, expect:, payload:)
|
|
237
|
+
env = send_envelope(type: type, payload: payload)
|
|
238
|
+
queue = Async::Queue.new
|
|
239
|
+
@mutex.synchronize { @pending[env.id] = [expect, queue] }
|
|
240
|
+
response = queue.dequeue
|
|
241
|
+
raise Arcp::Errors::ProtocolViolation, 'transport closed' if response.nil?
|
|
242
|
+
|
|
243
|
+
case response.type
|
|
244
|
+
when expect
|
|
245
|
+
response
|
|
246
|
+
when Arcp::MessageTypes::JOB_ERROR
|
|
247
|
+
raise Arcp::Job::JobError.from_h(response.payload).to_exception
|
|
248
|
+
when Arcp::MessageTypes::SESSION_ERROR
|
|
249
|
+
err = Arcp::Session::SessionError.from_h(response.payload)
|
|
250
|
+
raise Arcp::Errors.for(err.code, message: err.message, details: err.details || {})
|
|
251
|
+
else
|
|
252
|
+
raise Arcp::Errors::ProtocolViolation, "expected #{expect}, got #{response.type}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def start_reader!
|
|
257
|
+
@reader_task = Async do |_task|
|
|
258
|
+
loop do
|
|
259
|
+
env = @transport.receive
|
|
260
|
+
break if env.nil?
|
|
261
|
+
|
|
262
|
+
dispatch(env)
|
|
263
|
+
end
|
|
264
|
+
rescue Async::Stop
|
|
265
|
+
nil
|
|
266
|
+
ensure
|
|
267
|
+
drain_streams
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def start_heartbeat!
|
|
272
|
+
interval = @session.heartbeat_interval_sec
|
|
273
|
+
@heartbeat_task = Async do |task|
|
|
274
|
+
loop do
|
|
275
|
+
task.sleep(interval)
|
|
276
|
+
next if @closed
|
|
277
|
+
|
|
278
|
+
send_envelope(
|
|
279
|
+
type: Arcp::MessageTypes::SESSION_PING,
|
|
280
|
+
payload: Arcp::Session::Ping.new(nonce: Arcp::Ids.envelope_id, sent_at: @clock.now.iso8601).to_h
|
|
281
|
+
)
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
rescue Async::Stop
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def dispatch(env)
|
|
291
|
+
@inbound_seq = env.event_seq if env.event_seq
|
|
292
|
+
|
|
293
|
+
case env.type
|
|
294
|
+
when Arcp::MessageTypes::JOB_EVENT
|
|
295
|
+
feed_job_stream(env)
|
|
296
|
+
when Arcp::MessageTypes::JOB_RESULT, Arcp::MessageTypes::JOB_ERROR
|
|
297
|
+
feed_pending(env) # may satisfy a pending submit/get_result waiter
|
|
298
|
+
feed_result(env)
|
|
299
|
+
feed_job_stream(env, end_stream: true)
|
|
300
|
+
when Arcp::MessageTypes::SESSION_PING
|
|
301
|
+
ping = Arcp::Session::Ping.from_h(env.payload)
|
|
302
|
+
send_envelope(type: Arcp::MessageTypes::SESSION_PONG,
|
|
303
|
+
payload: Arcp::Session::Pong.new(ping_nonce: ping.nonce,
|
|
304
|
+
received_at: @clock.now.iso8601).to_h)
|
|
305
|
+
when Arcp::MessageTypes::SESSION_PONG
|
|
306
|
+
# noop — receipt of any inbound resets timer (implicit)
|
|
307
|
+
else
|
|
308
|
+
feed_pending(env)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def feed_job_stream(env, end_stream: false)
|
|
313
|
+
queue = @mutex.synchronize { @job_streams[env.job_id] ||= Async::Queue.new }
|
|
314
|
+
|
|
315
|
+
queue.enqueue(Arcp::Job::Event.from_h(env.payload)) if env.type == Arcp::MessageTypes::JOB_EVENT
|
|
316
|
+
|
|
317
|
+
queue.enqueue(:__arcp_end__) if end_stream
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def feed_result(env)
|
|
321
|
+
waiter = @mutex.synchronize do
|
|
322
|
+
@job_results[env.job_id] = env
|
|
323
|
+
@result_waiters.delete(env.job_id)
|
|
324
|
+
end
|
|
325
|
+
waiter&.enqueue(env)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def feed_pending(env)
|
|
329
|
+
reply_to = env.payload.is_a?(Hash) ? env.payload['reply_to'] : nil
|
|
330
|
+
key = reply_to || @mutex.synchronize do
|
|
331
|
+
@pending.keys.find do |k|
|
|
332
|
+
@pending[k].is_a?(Array) && @pending[k][0] == env.type
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
return unless key
|
|
336
|
+
|
|
337
|
+
pair = @mutex.synchronize { @pending.delete(key) }
|
|
338
|
+
pair&.last&.enqueue(env)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def drain_streams
|
|
342
|
+
@mutex.synchronize do
|
|
343
|
+
@job_streams.each_value { |q| q.enqueue(:__arcp_end__) }
|
|
344
|
+
@job_streams.clear
|
|
345
|
+
@pending.each_value do |v|
|
|
346
|
+
(v.is_a?(Array) ? v[1] : v).enqueue(nil)
|
|
347
|
+
end
|
|
348
|
+
@pending.clear
|
|
349
|
+
@result_waiters.each_value { |q| q.enqueue(nil) }
|
|
350
|
+
@result_waiters.clear
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
data/lib/arcp/clock.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Arcp
|
|
6
|
+
module Clock
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
def now = Time.now.utc
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class SystemClock
|
|
14
|
+
def now = Time.now.utc
|
|
15
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class FakeClock
|
|
19
|
+
attr_accessor :now_value, :monotonic_value
|
|
20
|
+
|
|
21
|
+
def initialize(now: Time.utc(2026, 1, 1))
|
|
22
|
+
@now_value = now.utc
|
|
23
|
+
@monotonic_value = 0.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def now = @now_value
|
|
27
|
+
def monotonic = @monotonic_value
|
|
28
|
+
|
|
29
|
+
def advance(seconds)
|
|
30
|
+
@now_value += seconds
|
|
31
|
+
@monotonic_value += seconds
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arcp
|
|
4
|
+
Credential = Data.define(:id, :scheme, :value, :endpoint, :profile, :constraints) do
|
|
5
|
+
def initialize(id:, scheme:, value:, endpoint:, profile: nil, constraints: nil)
|
|
6
|
+
super(
|
|
7
|
+
id: id,
|
|
8
|
+
scheme: scheme,
|
|
9
|
+
value: value,
|
|
10
|
+
endpoint: endpoint,
|
|
11
|
+
profile: profile,
|
|
12
|
+
constraints: constraints || {}
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_h(h)
|
|
17
|
+
h = h.transform_keys(&:to_s)
|
|
18
|
+
new(
|
|
19
|
+
id: h.fetch('id'),
|
|
20
|
+
scheme: h.fetch('scheme'),
|
|
21
|
+
value: h.fetch('value'),
|
|
22
|
+
endpoint: h.fetch('endpoint'),
|
|
23
|
+
profile: h['profile'],
|
|
24
|
+
constraints: h['constraints'] || {}
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
out = { 'id' => id, 'scheme' => scheme, 'value' => value, 'endpoint' => endpoint }
|
|
30
|
+
out['profile'] = profile if profile
|
|
31
|
+
out['constraints'] = constraints if constraints && !constraints.empty?
|
|
32
|
+
out
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_redacted_h
|
|
36
|
+
to_h.merge('value' => '[REDACTED]')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Credential.const_set(:SCHEME_BEARER, 'bearer') unless Credential.const_defined?(:SCHEME_BEARER)
|
|
41
|
+
|
|
42
|
+
module ModelPattern
|
|
43
|
+
FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def match?(patterns, model_id)
|
|
48
|
+
Array(patterns).any? { |pattern| File.fnmatch?(pattern, model_id, FLAGS) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def implied_by?(parent_patterns, child_pattern)
|
|
52
|
+
Array(parent_patterns).any? do |parent|
|
|
53
|
+
child_pattern == parent || literal_match?(parent, child_pattern)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def literal_match?(parent_pattern, child_pattern)
|
|
58
|
+
!glob?(child_pattern) && match?([parent_pattern], child_pattern)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def glob?(pattern)
|
|
62
|
+
pattern.match?(/[*?\[\]{}]/)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'credential'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
|
|
6
|
+
module Arcp
|
|
7
|
+
module CredentialProvisioner
|
|
8
|
+
def issue(lease:, job_id:, agent:, principal_id:)
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def revoke(credential_id:)
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Credentials
|
|
18
|
+
BUDGET_EXHAUSTED_CODES = %w[BUDGET_EXHAUSTED budget_exhausted insufficient_quota].freeze
|
|
19
|
+
|
|
20
|
+
def self.translate_upstream_error(error)
|
|
21
|
+
return error unless budget_exhausted?(error)
|
|
22
|
+
|
|
23
|
+
Arcp::Errors::BudgetExhausted.new(
|
|
24
|
+
error.message,
|
|
25
|
+
details: { 'upstream_class' => error.class.name }
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.budget_exhausted?(error)
|
|
30
|
+
code = error.respond_to?(:code) ? error.code.to_s : nil
|
|
31
|
+
status = error.respond_to?(:status) ? error.status.to_i : nil
|
|
32
|
+
BUDGET_EXHAUSTED_CODES.include?(code) || status == 402
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class InMemoryProvisioner
|
|
36
|
+
include Arcp::CredentialProvisioner
|
|
37
|
+
|
|
38
|
+
attr_reader :issued, :revoked
|
|
39
|
+
|
|
40
|
+
def initialize(endpoint: 'https://gateway.test/v1', profile: 'openai')
|
|
41
|
+
@endpoint = endpoint
|
|
42
|
+
@profile = profile
|
|
43
|
+
@issued = []
|
|
44
|
+
@revoked = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def issue(lease:, job_id:, agent:, principal_id:)
|
|
48
|
+
credential = Arcp::Credential.new(
|
|
49
|
+
id: "cred_#{job_id}_0",
|
|
50
|
+
scheme: Arcp::Credential::SCHEME_BEARER,
|
|
51
|
+
value: "sk-test-#{job_id}",
|
|
52
|
+
endpoint: @endpoint,
|
|
53
|
+
profile: @profile,
|
|
54
|
+
constraints: constraints_for(lease)
|
|
55
|
+
)
|
|
56
|
+
@issued << {
|
|
57
|
+
credential: credential,
|
|
58
|
+
job_id: job_id,
|
|
59
|
+
agent: agent,
|
|
60
|
+
principal_id: principal_id
|
|
61
|
+
}
|
|
62
|
+
[credential]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def revoke(credential_id:)
|
|
66
|
+
@revoked << credential_id
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def constraints_for(lease)
|
|
73
|
+
return {} unless lease
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
'cost.budget' => lease.budget&.to_a,
|
|
77
|
+
'model.use' => lease.model_use,
|
|
78
|
+
'expires_at' => lease.expires_at
|
|
79
|
+
}.compact
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class CredentialStore
|
|
84
|
+
def record(job_id:, credential_id:)
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def forget(job_id:, credential_id:)
|
|
89
|
+
raise NotImplementedError
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def outstanding(job_id:)
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def all_outstanding
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class InMemoryStore < CredentialStore
|
|
102
|
+
def initialize
|
|
103
|
+
super
|
|
104
|
+
@by_job = Hash.new { |hash, key| hash[key] = [] }
|
|
105
|
+
@mutex = Mutex.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def record(job_id:, credential_id:)
|
|
109
|
+
@mutex.synchronize { @by_job[job_id] |= [credential_id] }
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def forget(job_id:, credential_id:)
|
|
114
|
+
@mutex.synchronize do
|
|
115
|
+
@by_job[job_id].delete(credential_id)
|
|
116
|
+
@by_job.delete(job_id) if @by_job[job_id].empty?
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def outstanding(job_id:)
|
|
122
|
+
@mutex.synchronize { @by_job[job_id].dup.freeze }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def all_outstanding
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@by_job.transform_values { |ids| ids.dup.freeze }.freeze
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
require_relative 'ids'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'serializer'
|
|
7
|
+
require_relative 'message_types'
|
|
8
|
+
|
|
9
|
+
module Arcp
|
|
10
|
+
# ARCP wire envelope per spec §5.1. Eight fields:
|
|
11
|
+
# arcp, id, type, session_id, trace_id, job_id, event_seq, payload.
|
|
12
|
+
#
|
|
13
|
+
# `trace_id` and `job_id` may be nil for session-level envelopes.
|
|
14
|
+
# `event_seq` is nil for non-event traffic; events carry a monotonic Integer.
|
|
15
|
+
Envelope = Data.define(:arcp, :id, :type, :session_id, :trace_id, :job_id, :event_seq, :payload) do
|
|
16
|
+
HEX32 = /\A[0-9a-f]{32}\z/
|
|
17
|
+
|
|
18
|
+
def self.build(type:, session_id:, payload:, trace_id: nil, job_id: nil, event_seq: nil, id: nil)
|
|
19
|
+
raise Arcp::Errors::InvalidRequest, 'trace_id must be 32 hex chars' if trace_id && trace_id !~ HEX32
|
|
20
|
+
|
|
21
|
+
new(
|
|
22
|
+
arcp: Arcp::PROTOCOL_VERSION,
|
|
23
|
+
id: id || Arcp::Ids.envelope_id,
|
|
24
|
+
type: type,
|
|
25
|
+
session_id: session_id,
|
|
26
|
+
trace_id: trace_id,
|
|
27
|
+
job_id: job_id,
|
|
28
|
+
event_seq: event_seq,
|
|
29
|
+
payload: payload || {}
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from_h(hash)
|
|
34
|
+
raise Arcp::Errors::InvalidRequest, 'envelope must be a Hash' unless hash.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
h = hash.transform_keys(&:to_s)
|
|
37
|
+
arcp = h['arcp']
|
|
38
|
+
unless arcp == Arcp::PROTOCOL_VERSION
|
|
39
|
+
raise Arcp::Errors::InvalidRequest, "unsupported arcp version: #{arcp.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
type = h['type']
|
|
43
|
+
raise Arcp::Errors::InvalidRequest, 'envelope type must be a String' unless type.is_a?(String)
|
|
44
|
+
|
|
45
|
+
session_id = h['session_id']
|
|
46
|
+
unless session_id.is_a?(String)
|
|
47
|
+
raise Arcp::Errors::InvalidRequest,
|
|
48
|
+
'envelope session_id must be a String'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
event_seq = h['event_seq']
|
|
52
|
+
unless event_seq.nil? || event_seq.is_a?(Integer)
|
|
53
|
+
raise Arcp::Errors::InvalidRequest,
|
|
54
|
+
'event_seq must be an Integer'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
trace_id = h['trace_id']
|
|
58
|
+
raise Arcp::Errors::InvalidRequest, 'trace_id must be 32 hex chars' if trace_id && trace_id !~ HEX32
|
|
59
|
+
|
|
60
|
+
payload = h['payload']
|
|
61
|
+
raise Arcp::Errors::InvalidRequest, 'payload must be a Hash' unless payload.is_a?(Hash) || payload.nil?
|
|
62
|
+
|
|
63
|
+
new(
|
|
64
|
+
arcp: arcp,
|
|
65
|
+
id: h.fetch('id'),
|
|
66
|
+
type: type,
|
|
67
|
+
session_id: session_id,
|
|
68
|
+
trace_id: trace_id,
|
|
69
|
+
job_id: h['job_id'],
|
|
70
|
+
event_seq: event_seq,
|
|
71
|
+
payload: deep_freeze(payload || {})
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.from_json(bytes)
|
|
76
|
+
from_h(Arcp::Serializer.load(bytes))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.deep_freeze(value)
|
|
80
|
+
case value
|
|
81
|
+
when Hash
|
|
82
|
+
value.each_value { |v| deep_freeze(v) }
|
|
83
|
+
when Array
|
|
84
|
+
value.each { |v| deep_freeze(v) }
|
|
85
|
+
end
|
|
86
|
+
value.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_h
|
|
90
|
+
h = { 'arcp' => arcp, 'id' => id, 'type' => type, 'session_id' => session_id,
|
|
91
|
+
'payload' => stringify(payload) }
|
|
92
|
+
h['trace_id'] = trace_id if trace_id
|
|
93
|
+
h['job_id'] = job_id if job_id
|
|
94
|
+
h['event_seq'] = event_seq if event_seq
|
|
95
|
+
h
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def to_json(*_args) = Arcp::Serializer.dump(to_h)
|
|
99
|
+
|
|
100
|
+
def stringify(value)
|
|
101
|
+
case value
|
|
102
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify(v) }
|
|
103
|
+
when Array then value.map { |v| stringify(v) }
|
|
104
|
+
else value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def known? = Arcp::MessageTypes.known?(type)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
UnknownEnvelope = Data.define(:envelope) do
|
|
112
|
+
def type = envelope.type
|
|
113
|
+
def payload = envelope.payload
|
|
114
|
+
end
|
|
115
|
+
end
|