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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +31 -0
  3. data/CONFORMANCE.md +71 -0
  4. data/LICENSE +202 -0
  5. data/README.md +135 -0
  6. data/lib/arcp/auth/auth_scheme.rb +16 -0
  7. data/lib/arcp/auth/bearer.rb +38 -0
  8. data/lib/arcp/auth.rb +4 -0
  9. data/lib/arcp/client.rb +354 -0
  10. data/lib/arcp/clock.rb +35 -0
  11. data/lib/arcp/credential.rb +65 -0
  12. data/lib/arcp/credential_provisioner.rb +132 -0
  13. data/lib/arcp/envelope.rb +115 -0
  14. data/lib/arcp/errors.rb +154 -0
  15. data/lib/arcp/ids.rb +18 -0
  16. data/lib/arcp/job/accepted.rb +37 -0
  17. data/lib/arcp/job/agent_ref.rb +18 -0
  18. data/lib/arcp/job/cancel.rb +18 -0
  19. data/lib/arcp/job/event.rb +68 -0
  20. data/lib/arcp/job/event_body/delegate.rb +24 -0
  21. data/lib/arcp/job/event_body/log.rb +20 -0
  22. data/lib/arcp/job/event_body/metric.rb +20 -0
  23. data/lib/arcp/job/event_body/progress.rb +27 -0
  24. data/lib/arcp/job/event_body/result_chunk.rb +42 -0
  25. data/lib/arcp/job/event_body/status.rb +25 -0
  26. data/lib/arcp/job/event_body/thought.rb +12 -0
  27. data/lib/arcp/job/event_body/tool_call.rb +16 -0
  28. data/lib/arcp/job/event_body/tool_result.rb +23 -0
  29. data/lib/arcp/job/event_body/trace_span.rb +30 -0
  30. data/lib/arcp/job/handle.rb +16 -0
  31. data/lib/arcp/job/job_error.rb +31 -0
  32. data/lib/arcp/job/result.rb +30 -0
  33. data/lib/arcp/job/submit.rb +32 -0
  34. data/lib/arcp/job/subscribe.rb +22 -0
  35. data/lib/arcp/job/subscribed.rb +14 -0
  36. data/lib/arcp/job/summary.rb +27 -0
  37. data/lib/arcp/job/unsubscribe.rb +10 -0
  38. data/lib/arcp/job.rb +15 -0
  39. data/lib/arcp/lease.rb +212 -0
  40. data/lib/arcp/message_types.rb +35 -0
  41. data/lib/arcp/runtime/credential_registry.rb +67 -0
  42. data/lib/arcp/runtime/event_log.rb +62 -0
  43. data/lib/arcp/runtime/job_context.rb +167 -0
  44. data/lib/arcp/runtime/job_manager.rb +256 -0
  45. data/lib/arcp/runtime/lease_manager.rb +88 -0
  46. data/lib/arcp/runtime/runtime.rb +125 -0
  47. data/lib/arcp/runtime/session_actor.rb +300 -0
  48. data/lib/arcp/runtime/subscription_manager.rb +57 -0
  49. data/lib/arcp/runtime.rb +10 -0
  50. data/lib/arcp/serializer.rb +43 -0
  51. data/lib/arcp/session/ack.rb +14 -0
  52. data/lib/arcp/session/agent_inventory.rb +51 -0
  53. data/lib/arcp/session/bye.rb +10 -0
  54. data/lib/arcp/session/capability_set.rb +29 -0
  55. data/lib/arcp/session/feature.rb +25 -0
  56. data/lib/arcp/session/hello.rb +34 -0
  57. data/lib/arcp/session/jobs_response.rb +18 -0
  58. data/lib/arcp/session/list_jobs.rb +19 -0
  59. data/lib/arcp/session/ping.rb +14 -0
  60. data/lib/arcp/session/pong.rb +14 -0
  61. data/lib/arcp/session/session_error.rb +23 -0
  62. data/lib/arcp/session/welcome.rb +38 -0
  63. data/lib/arcp/session.rb +26 -0
  64. data/lib/arcp/trace.rb +51 -0
  65. data/lib/arcp/transport/base.rb +29 -0
  66. data/lib/arcp/transport/memory_transport.rb +54 -0
  67. data/lib/arcp/transport/stdio_transport.rb +56 -0
  68. data/lib/arcp/transport/websocket_transport.rb +47 -0
  69. data/lib/arcp/transport.rb +6 -0
  70. data/lib/arcp/version.rb +7 -0
  71. data/lib/arcp.rb +19 -0
  72. data/sig/arcp/client.rbs +16 -0
  73. data/sig/arcp/credential.rbs +51 -0
  74. data/sig/arcp/envelope.rbs +25 -0
  75. data/sig/arcp/errors.rbs +40 -0
  76. data/sig/arcp/job.rbs +83 -0
  77. data/sig/arcp/lease.rbs +41 -0
  78. data/sig/arcp/runtime.rbs +41 -0
  79. data/sig/arcp/serializer.rbs +8 -0
  80. data/sig/arcp/session.rbs +56 -0
  81. data/sig/arcp/transport.rbs +18 -0
  82. data/sig/arcp.rbs +5 -0
  83. metadata +226 -0
@@ -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