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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +262 -205
  4. data/app/controllers/a2a/rails/agent_cards_controller.rb +3 -0
  5. data/app/controllers/a2a/rails/requests_controller.rb +53 -0
  6. data/lib/a2a/rails/agent.rb +33 -2
  7. data/lib/a2a/rails/agent_card/builder.rb +3 -1
  8. data/lib/a2a/rails/agent_card/security.rb +115 -0
  9. data/lib/a2a/rails/authentication.rb +71 -0
  10. data/lib/a2a/rails/configuration.rb +95 -1
  11. data/lib/a2a/rails/dispatcher.rb +36 -4
  12. data/lib/a2a/rails/engine.rb +9 -0
  13. data/lib/a2a/rails/errors.rb +3 -0
  14. data/lib/a2a/rails/execution_plan.rb +40 -0
  15. data/lib/a2a/rails/file_artifact.rb +77 -0
  16. data/lib/a2a/rails/protocol/agent2agent_adapter.rb +11 -0
  17. data/lib/a2a/rails/protocol/request_handler.rb +124 -13
  18. data/lib/a2a/rails/protocol/task_mapper.rb +21 -0
  19. data/lib/a2a/rails/request_guard.rb +76 -0
  20. data/lib/a2a/rails/runtime.rb +15 -6
  21. data/lib/a2a/rails/skill.rb +7 -2
  22. data/lib/a2a/rails/task/active_record_store.rb +451 -0
  23. data/lib/a2a/rails/task/artifact_mapper.rb +2 -0
  24. data/lib/a2a/rails/task/lifecycle.rb +18 -8
  25. data/lib/a2a/rails/task/memory_store.rb +39 -15
  26. data/lib/a2a/rails/task/store.rb +16 -4
  27. data/lib/a2a/rails/task_execution_arguments.rb +78 -0
  28. data/lib/a2a/rails/task_execution_job.rb +79 -0
  29. data/lib/a2a/rails/version.rb +1 -1
  30. data/lib/a2a-rails.rb +7 -0
  31. data/lib/generators/a2a/rails/task_store_generator.rb +46 -0
  32. data/lib/generators/a2a/rails/templates/create_a2a_rails_tasks.rb.tt +31 -0
  33. data/lib/generators/a2a/rails/templates/initializer.rb.tt +4 -0
  34. data/lib/tasks/a2a_rails_tasks.rake +54 -0
  35. 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
- @logger.error("[a2a-rails] task_id=#{task_id} execution failed: #{error.class}: #{error.message}")
89
- @logger.error(error.backtrace.join("\n")) if error.backtrace && !error.backtrace.empty?
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 transition(task_id, state:, timestamp: Time.now.utc, artifacts: UNSET, message: UNSET)
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] || raise(TaskNotFoundError, 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
- (!context_id || task[:context_id] == context_id) &&
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
 
@@ -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 cancel(_task_id, **_attributes)
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