a2a-rails 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +262 -205
- data/app/controllers/a2a/rails/agent_cards_controller.rb +3 -0
- data/app/controllers/a2a/rails/requests_controller.rb +53 -0
- data/lib/a2a/rails/agent.rb +33 -2
- data/lib/a2a/rails/agent_card/builder.rb +3 -1
- data/lib/a2a/rails/agent_card/security.rb +115 -0
- data/lib/a2a/rails/authentication.rb +71 -0
- data/lib/a2a/rails/configuration.rb +95 -1
- data/lib/a2a/rails/dispatcher.rb +36 -4
- data/lib/a2a/rails/engine.rb +9 -0
- data/lib/a2a/rails/errors.rb +3 -0
- data/lib/a2a/rails/execution_plan.rb +40 -0
- data/lib/a2a/rails/file_artifact.rb +77 -0
- data/lib/a2a/rails/protocol/agent2agent_adapter.rb +11 -0
- data/lib/a2a/rails/protocol/request_handler.rb +124 -13
- data/lib/a2a/rails/protocol/task_mapper.rb +21 -0
- data/lib/a2a/rails/request_guard.rb +76 -0
- data/lib/a2a/rails/runtime.rb +15 -6
- data/lib/a2a/rails/skill.rb +7 -2
- data/lib/a2a/rails/task/active_record_store.rb +451 -0
- data/lib/a2a/rails/task/artifact_mapper.rb +2 -0
- data/lib/a2a/rails/task/lifecycle.rb +18 -8
- data/lib/a2a/rails/task/memory_store.rb +39 -15
- data/lib/a2a/rails/task/store.rb +16 -4
- data/lib/a2a/rails/task_execution_arguments.rb +78 -0
- data/lib/a2a/rails/task_execution_job.rb +79 -0
- data/lib/a2a/rails/version.rb +1 -1
- data/lib/a2a-rails.rb +7 -0
- data/lib/generators/a2a/rails/task_store_generator.rb +46 -0
- data/lib/generators/a2a/rails/templates/create_a2a_rails_tasks.rb.tt +31 -0
- data/lib/generators/a2a/rails/templates/initializer.rb.tt +4 -0
- data/lib/tasks/a2a_rails_tasks.rake +54 -0
- metadata +94 -3
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support/message_verifier"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "json"
|
|
7
|
+
require "time"
|
|
8
|
+
require_relative "../task"
|
|
9
|
+
|
|
10
|
+
module A2A
|
|
11
|
+
module Rails
|
|
12
|
+
module Task
|
|
13
|
+
class ActiveRecordStore < Store
|
|
14
|
+
UNSET = Object.new.freeze
|
|
15
|
+
DEFAULT_PAGE_SIZE = 50
|
|
16
|
+
MAX_PAGE_SIZE = 100
|
|
17
|
+
MAX_PRUNE_BATCH_SIZE = 10_000
|
|
18
|
+
CURSOR_PURPOSE = "a2a-rails/task-page".freeze
|
|
19
|
+
|
|
20
|
+
class Record < ::ActiveRecord::Base
|
|
21
|
+
self.table_name = "a2a_rails_tasks"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(cursor_secret:, clock: -> { Time.now.utc }, retention: nil,
|
|
25
|
+
prune_batch_size: 1_000, max_tasks_per_owner: nil,
|
|
26
|
+
max_history_entries: 100, max_artifacts: 50)
|
|
27
|
+
unless cursor_secret.is_a?(String) && cursor_secret.bytesize >= 32
|
|
28
|
+
raise ArgumentError, "cursor_secret must be at least 32 bytes"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@clock = clock
|
|
32
|
+
@retention_seconds = normalize_retention(retention)
|
|
33
|
+
@prune_batch_size = normalize_prune_batch_size(prune_batch_size)
|
|
34
|
+
@max_tasks_per_owner = normalize_optional_positive_integer(
|
|
35
|
+
max_tasks_per_owner,
|
|
36
|
+
"max_tasks_per_owner"
|
|
37
|
+
)
|
|
38
|
+
@max_history_entries = normalize_positive_integer(
|
|
39
|
+
max_history_entries,
|
|
40
|
+
"max_history_entries"
|
|
41
|
+
)
|
|
42
|
+
@max_artifacts = normalize_positive_integer(max_artifacts, "max_artifacts")
|
|
43
|
+
@verifier = ActiveSupport::MessageVerifier.new(
|
|
44
|
+
cursor_secret,
|
|
45
|
+
digest: "SHA256",
|
|
46
|
+
serializer: JSON
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def save(task)
|
|
51
|
+
state = normalized_state(task.dig(:status, :state))
|
|
52
|
+
timestamp = normalize_time(task.dig(:status, :timestamp), field: "task status timestamp")
|
|
53
|
+
validate_payload_collection!("history", task[:history], @max_history_entries) if task.key?(:history)
|
|
54
|
+
validate_payload_collection!("artifacts", task[:artifacts], @max_artifacts) if task.key?(:artifacts)
|
|
55
|
+
|
|
56
|
+
record = Record.transaction do
|
|
57
|
+
enforce_owner_quota!(task[:owner_id], at: now)
|
|
58
|
+
|
|
59
|
+
Record.create!(
|
|
60
|
+
task_id: task.fetch(:id),
|
|
61
|
+
owner_id: task[:owner_id],
|
|
62
|
+
context_id: task.fetch(:context_id),
|
|
63
|
+
state: state.to_s,
|
|
64
|
+
status_timestamp: timestamp,
|
|
65
|
+
status_message: task.dig(:status, :message),
|
|
66
|
+
history: task.key?(:history) ? deep_stringify(task[:history]) : nil,
|
|
67
|
+
artifacts: task.key?(:artifacts) ? deep_stringify(task[:artifacts]) : nil,
|
|
68
|
+
expires_at: terminal_state?(state) ? expiration_at(timestamp) : nil
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
deserialize(record)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find(task_id, principal_id: nil)
|
|
76
|
+
deserialize(find_record!(task_id, principal_id: principal_id))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def claim_execution(task_id, timestamp: Time.now.utc, principal_id: nil)
|
|
80
|
+
timestamp = normalize_time(timestamp, field: "task status timestamp")
|
|
81
|
+
|
|
82
|
+
Record.transaction do
|
|
83
|
+
record = locked_record!(task_id, principal_id: principal_id)
|
|
84
|
+
if record.state == "submitted"
|
|
85
|
+
record.update!(
|
|
86
|
+
state: "working",
|
|
87
|
+
status_timestamp: timestamp,
|
|
88
|
+
status_message: nil
|
|
89
|
+
)
|
|
90
|
+
deserialize(record)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def transition(task_id, state:, timestamp: Time.now.utc, artifacts: UNSET, message: UNSET,
|
|
96
|
+
principal_id: nil)
|
|
97
|
+
state = normalized_state(state)
|
|
98
|
+
timestamp = normalize_time(timestamp, field: "task status timestamp")
|
|
99
|
+
validate_payload_collection!("artifacts", artifacts, @max_artifacts) unless artifacts.equal?(UNSET)
|
|
100
|
+
|
|
101
|
+
Record.transaction do
|
|
102
|
+
record = locked_record!(task_id, principal_id: principal_id)
|
|
103
|
+
|
|
104
|
+
unless terminal_record?(record)
|
|
105
|
+
attributes = {
|
|
106
|
+
state: state.to_s,
|
|
107
|
+
status_timestamp: timestamp,
|
|
108
|
+
status_message: message.equal?(UNSET) ? nil : deep_stringify(message)
|
|
109
|
+
}
|
|
110
|
+
attributes[:artifacts] = deep_stringify(artifacts) unless artifacts.equal?(UNSET)
|
|
111
|
+
if terminal_state?(state) && record.expires_at.nil?
|
|
112
|
+
attributes[:expires_at] = expiration_at(timestamp)
|
|
113
|
+
end
|
|
114
|
+
record.update!(attributes)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
deserialize(record)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def cancel(task_id, timestamp: Time.now.utc, principal_id: nil)
|
|
122
|
+
timestamp = normalize_time(timestamp, field: "task status timestamp")
|
|
123
|
+
|
|
124
|
+
Record.transaction do
|
|
125
|
+
record = locked_record!(task_id, principal_id: principal_id)
|
|
126
|
+
state = record.state.to_sym
|
|
127
|
+
raise TaskNotCancelableError.new(task_id, state: state) if TERMINAL_STATES.include?(state)
|
|
128
|
+
|
|
129
|
+
record.update!(
|
|
130
|
+
state: "canceled",
|
|
131
|
+
status_timestamp: timestamp,
|
|
132
|
+
status_message: nil,
|
|
133
|
+
expires_at: record.expires_at || expiration_at(timestamp)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
deserialize(record)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def list(context_id: nil, status: nil, status_timestamp_after: nil,
|
|
141
|
+
page_size: DEFAULT_PAGE_SIZE, page_token: nil, principal_id: nil)
|
|
142
|
+
validate_page_size!(page_size)
|
|
143
|
+
unless page_token.nil? || page_token.is_a?(String)
|
|
144
|
+
raise InvalidTaskQueryError, "page_token must be a String"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
status = normalized_state(status) if status
|
|
148
|
+
after = normalize_time(status_timestamp_after, field: "status_timestamp_after") if status_timestamp_after
|
|
149
|
+
fingerprint = query_fingerprint(
|
|
150
|
+
principal_id: principal_id,
|
|
151
|
+
context_id: context_id,
|
|
152
|
+
status: status,
|
|
153
|
+
status_timestamp_after: after,
|
|
154
|
+
page_size: page_size
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if page_token && !page_token.empty?
|
|
158
|
+
payload = decode_cursor(page_token)
|
|
159
|
+
unless payload.fetch("fingerprint") == fingerprint
|
|
160
|
+
raise InvalidTaskQueryError, "Invalid page_token or changed query"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
snapshot_id = Integer(payload.fetch("snapshot_id"))
|
|
164
|
+
last_timestamp = normalize_time(payload.fetch("last_timestamp"), field: "page_token timestamp")
|
|
165
|
+
last_task_id = payload.fetch("last_task_id")
|
|
166
|
+
raise TypeError unless last_task_id.is_a?(String)
|
|
167
|
+
total_size = Integer(payload.fetch("total_size"))
|
|
168
|
+
else
|
|
169
|
+
snapshot_id = Record.maximum(:id).to_i
|
|
170
|
+
last_timestamp = nil
|
|
171
|
+
last_task_id = nil
|
|
172
|
+
total_size = filtered_scope(
|
|
173
|
+
principal_id: principal_id,
|
|
174
|
+
context_id: context_id,
|
|
175
|
+
status: status,
|
|
176
|
+
status_timestamp_after: after,
|
|
177
|
+
snapshot_id: snapshot_id
|
|
178
|
+
).count
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
scope = filtered_scope(
|
|
182
|
+
principal_id: principal_id,
|
|
183
|
+
context_id: context_id,
|
|
184
|
+
status: status,
|
|
185
|
+
status_timestamp_after: after,
|
|
186
|
+
snapshot_id: snapshot_id
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if last_timestamp
|
|
190
|
+
scope = scope.where(
|
|
191
|
+
"status_timestamp < :timestamp OR (status_timestamp = :timestamp AND task_id < :task_id)",
|
|
192
|
+
timestamp: last_timestamp,
|
|
193
|
+
task_id: last_task_id
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
rows = scope
|
|
198
|
+
.order(status_timestamp: :desc, task_id: :desc)
|
|
199
|
+
.limit(page_size + 1)
|
|
200
|
+
.to_a
|
|
201
|
+
|
|
202
|
+
page = rows.first(page_size)
|
|
203
|
+
next_page_token = ""
|
|
204
|
+
|
|
205
|
+
if rows.length > page_size && page.any?
|
|
206
|
+
last = page.last
|
|
207
|
+
next_page_token = encode_cursor(
|
|
208
|
+
"fingerprint" => fingerprint,
|
|
209
|
+
"snapshot_id" => snapshot_id,
|
|
210
|
+
"last_timestamp" => last.status_timestamp.utc.iso8601(6),
|
|
211
|
+
"last_task_id" => last.task_id,
|
|
212
|
+
"total_size" => total_size
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
{
|
|
217
|
+
tasks: page.map { |record| deserialize(record) },
|
|
218
|
+
total_size: total_size,
|
|
219
|
+
page_size: page_size,
|
|
220
|
+
next_page_token: next_page_token
|
|
221
|
+
}
|
|
222
|
+
rescue KeyError, ArgumentError, TypeError
|
|
223
|
+
raise InvalidTaskQueryError, "Invalid page_token"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Deletes at most one bounded batch. Callers decide whether and how
|
|
227
|
+
# often to loop; request handling never performs maintenance deletes.
|
|
228
|
+
def prune_expired(batch_size: @prune_batch_size, at: now)
|
|
229
|
+
batch_size = normalize_prune_batch_size(batch_size)
|
|
230
|
+
at = normalize_time(at, field: "prune timestamp")
|
|
231
|
+
|
|
232
|
+
ids = Record
|
|
233
|
+
.where.not(expires_at: nil)
|
|
234
|
+
.where("expires_at <= ?", at)
|
|
235
|
+
.order(:id)
|
|
236
|
+
.limit(batch_size)
|
|
237
|
+
.pluck(:id)
|
|
238
|
+
|
|
239
|
+
return 0 if ids.empty?
|
|
240
|
+
|
|
241
|
+
Record.where(id: ids).delete_all
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def maintenance_stats(at: now)
|
|
245
|
+
at = normalize_time(at, field: "maintenance timestamp")
|
|
246
|
+
terminal_states = TERMINAL_STATES.map(&:to_s)
|
|
247
|
+
total = Record.count
|
|
248
|
+
terminal = Record.where(state: terminal_states).count
|
|
249
|
+
expired = Record.where.not(expires_at: nil).where("expires_at <= ?", at).count
|
|
250
|
+
|
|
251
|
+
{
|
|
252
|
+
total: total,
|
|
253
|
+
terminal: terminal,
|
|
254
|
+
active: total - terminal,
|
|
255
|
+
expired: expired
|
|
256
|
+
}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
|
|
261
|
+
def find_record!(task_id, principal_id:)
|
|
262
|
+
record = Record.where(owner_id: principal_id, task_id: task_id).first
|
|
263
|
+
raise TaskNotFoundError, task_id unless record
|
|
264
|
+
|
|
265
|
+
record
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def locked_record!(task_id, principal_id:)
|
|
269
|
+
record = Record.lock.where(owner_id: principal_id, task_id: task_id).first
|
|
270
|
+
raise TaskNotFoundError, task_id unless record
|
|
271
|
+
|
|
272
|
+
record
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def filtered_scope(principal_id:, context_id:, status:, status_timestamp_after:, snapshot_id:)
|
|
276
|
+
scope = Record.where(owner_id: principal_id)
|
|
277
|
+
.where("id <= ?", snapshot_id)
|
|
278
|
+
|
|
279
|
+
scope = scope.where(context_id: context_id) if context_id
|
|
280
|
+
scope = scope.where(state: status.to_s) if status
|
|
281
|
+
scope = scope.where("status_timestamp >= ?", status_timestamp_after) if status_timestamp_after
|
|
282
|
+
scope
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def enforce_owner_quota!(owner_id, at:)
|
|
286
|
+
return unless @max_tasks_per_owner
|
|
287
|
+
|
|
288
|
+
retained = Record
|
|
289
|
+
.where(owner_id: owner_id)
|
|
290
|
+
.where("expires_at IS NULL OR expires_at > ?", at)
|
|
291
|
+
.count
|
|
292
|
+
|
|
293
|
+
return if retained < @max_tasks_per_owner
|
|
294
|
+
|
|
295
|
+
raise TaskStoreCapacityError, "Task owner capacity reached"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def validate_payload_collection!(name, value, limit)
|
|
299
|
+
return if value.nil?
|
|
300
|
+
|
|
301
|
+
unless value.is_a?(Array)
|
|
302
|
+
raise TaskStorePayloadLimitError, "Task #{name} must be an Array"
|
|
303
|
+
end
|
|
304
|
+
return if value.length <= limit
|
|
305
|
+
|
|
306
|
+
raise TaskStorePayloadLimitError, "Task #{name} exceeds configured limit"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def terminal_record?(record)
|
|
310
|
+
terminal_state?(record.state.to_sym)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def terminal_state?(state)
|
|
314
|
+
TERMINAL_STATES.include?(state)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def expiration_at(timestamp)
|
|
318
|
+
return nil unless @retention_seconds
|
|
319
|
+
|
|
320
|
+
timestamp + @retention_seconds
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def normalized_state(state)
|
|
324
|
+
state = state.to_sym if state.respond_to?(:to_sym)
|
|
325
|
+
return state if STATES.include?(state)
|
|
326
|
+
|
|
327
|
+
raise InvalidTaskStateError, "Unknown task state: #{state.inspect}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def validate_page_size!(page_size)
|
|
331
|
+
return if page_size.is_a?(Integer) && (1..MAX_PAGE_SIZE).cover?(page_size)
|
|
332
|
+
|
|
333
|
+
raise InvalidTaskQueryError, "page_size must be an integer from 1 to #{MAX_PAGE_SIZE}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def normalize_retention(value)
|
|
337
|
+
return nil if value.nil?
|
|
338
|
+
if value.is_a?(String) || !value.respond_to?(:to_i)
|
|
339
|
+
raise ArgumentError, "retention must be a nonnegative duration in seconds"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
seconds = value.to_i
|
|
343
|
+
raise ArgumentError, "retention must be nonnegative" if seconds.negative?
|
|
344
|
+
|
|
345
|
+
seconds
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def normalize_prune_batch_size(value)
|
|
349
|
+
unless value.is_a?(Integer) && (1..MAX_PRUNE_BATCH_SIZE).cover?(value)
|
|
350
|
+
raise ArgumentError, "prune_batch_size must be an integer from 1 to #{MAX_PRUNE_BATCH_SIZE}"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
value
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def normalize_optional_positive_integer(value, name)
|
|
357
|
+
return nil if value.nil?
|
|
358
|
+
|
|
359
|
+
normalize_positive_integer(value, name)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def normalize_positive_integer(value, name)
|
|
363
|
+
unless value.is_a?(Integer) && value.positive?
|
|
364
|
+
raise ArgumentError, "#{name} must be a positive integer"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
value
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def normalize_time(value, field:)
|
|
371
|
+
return value.utc if value.respond_to?(:utc) && !value.is_a?(String)
|
|
372
|
+
|
|
373
|
+
Time.iso8601(value.to_s).utc
|
|
374
|
+
rescue ArgumentError
|
|
375
|
+
raise InvalidTaskQueryError, "#{field} must be an ISO 8601 timestamp"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def query_fingerprint(principal_id:, context_id:, status:, status_timestamp_after:, page_size:)
|
|
379
|
+
normalized = [
|
|
380
|
+
principal_id,
|
|
381
|
+
context_id,
|
|
382
|
+
status&.to_s,
|
|
383
|
+
status_timestamp_after&.iso8601(6),
|
|
384
|
+
page_size
|
|
385
|
+
]
|
|
386
|
+
Digest::SHA256.hexdigest(JSON.generate(normalized))
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def encode_cursor(payload)
|
|
390
|
+
@verifier.generate(payload, purpose: CURSOR_PURPOSE)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def decode_cursor(token)
|
|
394
|
+
value = @verifier.verify(token, purpose: CURSOR_PURPOSE)
|
|
395
|
+
unless value.is_a?(Hash)
|
|
396
|
+
raise InvalidTaskQueryError, "Invalid page_token"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
value
|
|
400
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
401
|
+
raise InvalidTaskQueryError, "Invalid page_token"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def deserialize(record)
|
|
405
|
+
task = {
|
|
406
|
+
id: record.task_id,
|
|
407
|
+
owner_id: record.owner_id,
|
|
408
|
+
context_id: record.context_id,
|
|
409
|
+
status: {
|
|
410
|
+
state: record.state.to_sym,
|
|
411
|
+
timestamp: record.status_timestamp.utc.iso8601(6)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
task[:status][:message] = deep_symbolize(record.status_message) unless record.status_message.nil?
|
|
416
|
+
task[:history] = deep_symbolize(record.history) unless record.history.nil?
|
|
417
|
+
task[:artifacts] = deep_symbolize(record.artifacts) unless record.artifacts.nil?
|
|
418
|
+
task
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def deep_stringify(value)
|
|
422
|
+
case value
|
|
423
|
+
when Hash
|
|
424
|
+
value.each_with_object({}) { |(key, nested), result| result[key.to_s] = deep_stringify(nested) }
|
|
425
|
+
when Array
|
|
426
|
+
value.map { |nested| deep_stringify(nested) }
|
|
427
|
+
else
|
|
428
|
+
value
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def deep_symbolize(value)
|
|
433
|
+
case value
|
|
434
|
+
when Hash
|
|
435
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
436
|
+
result[key.respond_to?(:to_sym) ? key.to_sym : key] = deep_symbolize(nested)
|
|
437
|
+
end
|
|
438
|
+
when Array
|
|
439
|
+
value.map { |nested| deep_symbolize(nested) }
|
|
440
|
+
else
|
|
441
|
+
value
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def now
|
|
446
|
+
@clock.call.utc
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
@@ -18,6 +18,8 @@ module A2A
|
|
|
18
18
|
artifact(parts: [{ text: value }])
|
|
19
19
|
when Hash, Array
|
|
20
20
|
artifact(parts: [{ data: copy(value) }])
|
|
21
|
+
when A2A::Rails::FileArtifact
|
|
22
|
+
artifact(parts: [value.to_part])
|
|
21
23
|
else
|
|
22
24
|
raise ArtifactMappingError, "Unsupported Handler result type: #{value.class}"
|
|
23
25
|
end
|
|
@@ -6,18 +6,23 @@ module A2A
|
|
|
6
6
|
module Rails
|
|
7
7
|
module Task
|
|
8
8
|
class Lifecycle
|
|
9
|
+
attr_reader :principal_id
|
|
10
|
+
|
|
9
11
|
def initialize(store:, result_mapper: ResultMapper.new, logger: nil,
|
|
10
|
-
clock: -> { Time.now.utc }, id_generator: -> { SecureRandom.uuid }
|
|
12
|
+
clock: -> { Time.now.utc }, id_generator: -> { SecureRandom.uuid },
|
|
13
|
+
principal_id: nil)
|
|
11
14
|
@store = store
|
|
12
15
|
@result_mapper = result_mapper
|
|
13
16
|
@logger = logger
|
|
14
17
|
@clock = clock
|
|
15
18
|
@id_generator = id_generator
|
|
19
|
+
@principal_id = principal_id
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def create(message:, context_id: nil)
|
|
19
23
|
task = {
|
|
20
24
|
id: next_id,
|
|
25
|
+
owner_id: @principal_id,
|
|
21
26
|
context_id: present_context_id(context_id),
|
|
22
27
|
status: status(:submitted),
|
|
23
28
|
history: [copy(message)]
|
|
@@ -27,7 +32,11 @@ module A2A
|
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def start(task_id)
|
|
30
|
-
@store.transition(task_id, state: :working, timestamp: now)
|
|
35
|
+
@store.transition(task_id, state: :working, timestamp: now, principal_id: @principal_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def claim_execution(task_id)
|
|
39
|
+
@store.claim_execution(task_id, timestamp: now, principal_id: @principal_id)
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
def complete(task_id, result)
|
|
@@ -44,15 +53,15 @@ module A2A
|
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
def cancel(task_id)
|
|
47
|
-
@store.cancel(task_id, timestamp: now)
|
|
56
|
+
@store.cancel(task_id, timestamp: now, principal_id: @principal_id)
|
|
48
57
|
end
|
|
49
58
|
|
|
50
59
|
def find(task_id)
|
|
51
|
-
@store.find(task_id)
|
|
60
|
+
@store.find(task_id, principal_id: @principal_id)
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
def list(**filters)
|
|
55
|
-
@store.list(**filters)
|
|
64
|
+
@store.list(**filters, principal_id: @principal_id)
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
private
|
|
@@ -61,7 +70,7 @@ module A2A
|
|
|
61
70
|
attributes = { state: outcome.fetch(:state), timestamp: now }
|
|
62
71
|
attributes[:artifacts] = outcome[:artifacts] if outcome.key?(:artifacts)
|
|
63
72
|
attributes[:message] = outcome[:message] if outcome.key?(:message)
|
|
64
|
-
@store.transition(task_id, **attributes)
|
|
73
|
+
@store.transition(task_id, **attributes, principal_id: @principal_id)
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
def status(state)
|
|
@@ -85,8 +94,9 @@ module A2A
|
|
|
85
94
|
def log_failure(task_id, error)
|
|
86
95
|
return unless @logger
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
# Handler exception messages/backtraces can contain API keys,
|
|
98
|
+
# credentials, or request data. Only emit the exception class.
|
|
99
|
+
@logger.error("[a2a-rails] task_id=#{task_id} execution failed: #{error.class}")
|
|
90
100
|
end
|
|
91
101
|
|
|
92
102
|
def copy(value)
|
|
@@ -12,6 +12,7 @@ module A2A
|
|
|
12
12
|
UNSET = Object.new.freeze
|
|
13
13
|
DEFAULT_PAGE_SIZE = 50
|
|
14
14
|
MAX_PAGE_SIZE = 100
|
|
15
|
+
MAX_CACHED_PAGES = 128
|
|
15
16
|
|
|
16
17
|
def initialize
|
|
17
18
|
@tasks = {}
|
|
@@ -27,15 +28,26 @@ module A2A
|
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
def find(task_id)
|
|
31
|
-
@mutex.synchronize { copy(find!(task_id)) }
|
|
31
|
+
def find(task_id, principal_id: nil)
|
|
32
|
+
@mutex.synchronize { copy(find!(task_id, principal_id: principal_id)) }
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
+
def claim_execution(task_id, timestamp: Time.now.utc, principal_id: nil)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
task = find!(task_id, principal_id: principal_id)
|
|
38
|
+
return nil unless task.dig(:status, :state) == :submitted
|
|
39
|
+
|
|
40
|
+
task[:status] = status_hash(:working, timestamp, UNSET)
|
|
41
|
+
copy(task)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def transition(task_id, state:, timestamp: Time.now.utc, artifacts: UNSET, message: UNSET,
|
|
46
|
+
principal_id: nil)
|
|
35
47
|
validate_state!(state)
|
|
36
48
|
|
|
37
49
|
@mutex.synchronize do
|
|
38
|
-
task = find!(task_id)
|
|
50
|
+
task = find!(task_id, principal_id: principal_id)
|
|
39
51
|
return copy(task) if terminal?(task)
|
|
40
52
|
|
|
41
53
|
task[:status] = status_hash(state, timestamp, message)
|
|
@@ -44,9 +56,9 @@ module A2A
|
|
|
44
56
|
end
|
|
45
57
|
end
|
|
46
58
|
|
|
47
|
-
def cancel(task_id, timestamp: Time.now.utc)
|
|
59
|
+
def cancel(task_id, timestamp: Time.now.utc, principal_id: nil)
|
|
48
60
|
@mutex.synchronize do
|
|
49
|
-
task = find!(task_id)
|
|
61
|
+
task = find!(task_id, principal_id: principal_id)
|
|
50
62
|
state = task.dig(:status, :state)
|
|
51
63
|
raise TaskNotCancelableError.new(task_id, state: state) if TERMINAL_STATES.include?(state)
|
|
52
64
|
|
|
@@ -56,21 +68,28 @@ module A2A
|
|
|
56
68
|
end
|
|
57
69
|
|
|
58
70
|
def list(context_id: nil, status: nil, status_timestamp_after: nil,
|
|
59
|
-
page_size: DEFAULT_PAGE_SIZE, page_token: nil)
|
|
71
|
+
page_size: DEFAULT_PAGE_SIZE, page_token: nil, principal_id: nil)
|
|
60
72
|
validate_page_size!(page_size)
|
|
73
|
+
unless page_token.nil? || page_token.is_a?(String)
|
|
74
|
+
raise InvalidTaskQueryError, "page_token must be a String"
|
|
75
|
+
end
|
|
61
76
|
validate_state!(status) if status
|
|
62
77
|
after = normalize_time(status_timestamp_after) if status_timestamp_after
|
|
63
|
-
fingerprint = query_fingerprint(context_id:, status:, status_timestamp_after: after, page_size
|
|
78
|
+
fingerprint = query_fingerprint(context_id:, status:, status_timestamp_after: after, page_size:,
|
|
79
|
+
principal_id:)
|
|
64
80
|
|
|
65
81
|
@mutex.synchronize do
|
|
66
82
|
rows, offset = page_rows(page_token, fingerprint) do
|
|
67
|
-
filtered_rows(context_id:, status:, status_timestamp_after: after)
|
|
83
|
+
filtered_rows(context_id:, status:, status_timestamp_after: after, principal_id:)
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
next_offset = offset + page_size
|
|
71
87
|
next_token = ""
|
|
72
88
|
if next_offset < rows.length
|
|
73
89
|
next_token = SecureRandom.uuid
|
|
90
|
+
# Avoid unbounded growth from repeated list/pagination requests.
|
|
91
|
+
# The oldest snapshot token expires once this per-process cap is reached.
|
|
92
|
+
@pages.shift if @pages.size >= MAX_CACHED_PAGES
|
|
74
93
|
@pages[next_token] = { rows: rows, offset: next_offset, fingerprint: fingerprint }
|
|
75
94
|
end
|
|
76
95
|
|
|
@@ -85,8 +104,12 @@ module A2A
|
|
|
85
104
|
|
|
86
105
|
private
|
|
87
106
|
|
|
88
|
-
def find!(task_id)
|
|
89
|
-
@tasks[task_id]
|
|
107
|
+
def find!(task_id, principal_id:)
|
|
108
|
+
task = @tasks[task_id]
|
|
109
|
+
# A foreign Task must appear indistinguishable from a missing one.
|
|
110
|
+
raise TaskNotFoundError, task_id unless task && task[:owner_id] == principal_id
|
|
111
|
+
|
|
112
|
+
task
|
|
90
113
|
end
|
|
91
114
|
|
|
92
115
|
def terminal?(task)
|
|
@@ -132,10 +155,11 @@ module A2A
|
|
|
132
155
|
end
|
|
133
156
|
end
|
|
134
157
|
|
|
135
|
-
def filtered_rows(context_id:, status:, status_timestamp_after:)
|
|
158
|
+
def filtered_rows(context_id:, status:, status_timestamp_after:, principal_id:)
|
|
136
159
|
@tasks.values
|
|
137
160
|
.select do |task|
|
|
138
|
-
|
|
161
|
+
task[:owner_id] == principal_id &&
|
|
162
|
+
(!context_id || task[:context_id] == context_id) &&
|
|
139
163
|
(!status || task.dig(:status, :state) == status) &&
|
|
140
164
|
(!status_timestamp_after || task_time(task) >= status_timestamp_after)
|
|
141
165
|
end
|
|
@@ -147,8 +171,8 @@ module A2A
|
|
|
147
171
|
Time.iso8601(task.dig(:status, :timestamp))
|
|
148
172
|
end
|
|
149
173
|
|
|
150
|
-
def query_fingerprint(context_id:, status:, status_timestamp_after:, page_size:)
|
|
151
|
-
normalized = [context_id, status, status_timestamp_after&.iso8601(6), page_size]
|
|
174
|
+
def query_fingerprint(context_id:, status:, status_timestamp_after:, page_size:, principal_id:)
|
|
175
|
+
normalized = [principal_id, context_id, status, status_timestamp_after&.iso8601(6), page_size]
|
|
152
176
|
Digest::SHA256.hexdigest(Marshal.dump(normalized))
|
|
153
177
|
end
|
|
154
178
|
|
data/lib/a2a/rails/task/store.rb
CHANGED
|
@@ -8,21 +8,33 @@ module A2A
|
|
|
8
8
|
raise NotImplementedError, "Task stores must implement #save"
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def find(_task_id)
|
|
11
|
+
def find(_task_id, principal_id: nil)
|
|
12
12
|
raise NotImplementedError, "Task stores must implement #find"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def transition(_task_id, state:, **_attributes)
|
|
15
|
+
def transition(_task_id, state:, principal_id: nil, **_attributes)
|
|
16
16
|
raise NotImplementedError, "Task stores must implement #transition"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def claim_execution(_task_id, principal_id: nil, **_attributes)
|
|
20
|
+
raise NotImplementedError, "Task store does not implement async execution claim"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cancel(_task_id, principal_id: nil, **_attributes)
|
|
20
24
|
raise NotImplementedError, "Task stores must implement #cancel"
|
|
21
25
|
end
|
|
22
26
|
|
|
23
|
-
def list(**_filters)
|
|
27
|
+
def list(principal_id: nil, **_filters)
|
|
24
28
|
raise NotImplementedError, "Task stores must implement #list"
|
|
25
29
|
end
|
|
30
|
+
|
|
31
|
+
def prune_expired(**_options)
|
|
32
|
+
raise NotImplementedError, "Task store does not implement maintenance pruning"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def maintenance_stats(**_options)
|
|
36
|
+
raise NotImplementedError, "Task store does not implement maintenance stats"
|
|
37
|
+
end
|
|
26
38
|
end
|
|
27
39
|
end
|
|
28
40
|
end
|