a2a-rails 0.2.0 → 0.3.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 +73 -3
- data/README.md +45 -25
- data/lib/a2a/rails/client/agent_card_resolver.rb +190 -0
- data/lib/a2a/rails/client/codec.rb +106 -0
- data/lib/a2a/rails/client/outbound_policy.rb +196 -0
- data/lib/a2a/rails/client/pinned_https_transport.rb +227 -0
- data/lib/a2a/rails/client/public_types.rb +58 -0
- data/lib/a2a/rails/client.rb +295 -0
- data/lib/a2a/rails/configuration.rb +6 -2
- data/lib/a2a/rails/engine.rb +4 -1
- data/lib/a2a/rails/version.rb +1 -1
- data/lib/a2a-rails.rb +1 -0
- metadata +13 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "client/agent_card_resolver"
|
|
5
|
+
require_relative "client/public_types"
|
|
6
|
+
require_relative "client/codec"
|
|
7
|
+
|
|
8
|
+
module A2A
|
|
9
|
+
module Rails
|
|
10
|
+
# Outbound A2A v1.0 Client. Does not require a local Agent, Task Store or
|
|
11
|
+
# server Runtime. This is an unreleased implementation; the production
|
|
12
|
+
# release gates in docs/design/a2a-client-public-api.md still apply.
|
|
13
|
+
class Client
|
|
14
|
+
def initialize(agent_card_url:, allowed_origins:, authorization: nil,
|
|
15
|
+
credential_origin: nil, card_authorization: nil,
|
|
16
|
+
card_credential_origin: nil, open_timeout: 3,
|
|
17
|
+
read_timeout: 10, total_timeout: 15)
|
|
18
|
+
validate_callback!(authorization)
|
|
19
|
+
validate_callback!(card_authorization)
|
|
20
|
+
@policy = OutboundPolicy.new(allowed_origins: allowed_origins)
|
|
21
|
+
card_uri = @policy.validate_url!(agent_card_url)
|
|
22
|
+
# Default each token audience to the explicitly configured card
|
|
23
|
+
# origin. When RPC is advertised on a different origin, callers must
|
|
24
|
+
# explicitly bind the RPC credential to that origin.
|
|
25
|
+
card_origin = origin_for(card_uri)
|
|
26
|
+
rpc_origin = credential_origin || (authorization && card_origin)
|
|
27
|
+
discovery_origin = card_credential_origin || (card_authorization && card_origin)
|
|
28
|
+
check_credential_binding!(authorization, rpc_origin)
|
|
29
|
+
check_credential_binding!(card_authorization, discovery_origin)
|
|
30
|
+
|
|
31
|
+
transport = PinnedHttpsTransport.new(
|
|
32
|
+
policy: @policy,
|
|
33
|
+
open_timeout: open_timeout,
|
|
34
|
+
read_timeout: read_timeout,
|
|
35
|
+
total_timeout: total_timeout
|
|
36
|
+
)
|
|
37
|
+
@resolver = AgentCardResolver.new(
|
|
38
|
+
agent_card_url: card_uri.to_s, policy: @policy, transport: transport,
|
|
39
|
+
authorization: authorization, credential_origin: rpc_origin,
|
|
40
|
+
card_authorization: card_authorization,
|
|
41
|
+
card_credential_origin: discovery_origin
|
|
42
|
+
)
|
|
43
|
+
rescue OutboundPolicy::RejectedTarget => error
|
|
44
|
+
raise ConfigurationError.new(error.reason), cause: nil
|
|
45
|
+
rescue PinnedHttpsTransport::Error => error
|
|
46
|
+
raise ConfigurationError.new(error.reason), cause: nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def agent_card
|
|
50
|
+
with_errors(:agent_card) { Codec.decode(@resolver.discover.card) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def send_message(message:, configuration: nil, metadata: nil)
|
|
54
|
+
with_errors(:send_message) do
|
|
55
|
+
validate_message!(message)
|
|
56
|
+
params = { "message" => Codec.encode(message) }
|
|
57
|
+
params["configuration"] = encode_hash!(configuration, :configuration) unless configuration.nil?
|
|
58
|
+
params["metadata"] = encode_hash!(metadata, :metadata, opaque: true) unless metadata.nil?
|
|
59
|
+
result = rpc(:send_message, "SendMessage", params)
|
|
60
|
+
task = result["task"]
|
|
61
|
+
direct = result["message"]
|
|
62
|
+
unless (task.is_a?(Hash) && direct.nil?) || (direct.is_a?(Hash) && task.nil?)
|
|
63
|
+
raise InvalidResponseError.new(:invalid_send_result, operation: :send_message)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if task
|
|
67
|
+
SendResult.new(kind: :task, task: normalize_task!(task, :send_message), message: nil).freeze
|
|
68
|
+
else
|
|
69
|
+
SendResult.new(kind: :message, task: nil, message: normalize_message!(direct, :send_message)).freeze
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def get_task(id:, history_length: nil)
|
|
75
|
+
with_errors(:get_task) do
|
|
76
|
+
params = { "id" => required_text!(id, :id) }
|
|
77
|
+
params["historyLength"] = nonnegative_integer!(history_length, :history_length) unless history_length.nil?
|
|
78
|
+
normalize_task!(rpc(:get_task, "GetTask", params), :get_task)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def list_tasks(context_id: nil, status: nil, page_size: nil, page_token: nil,
|
|
83
|
+
history_length: nil, status_timestamp_after: nil, include_artifacts: nil)
|
|
84
|
+
with_errors(:list_tasks) do
|
|
85
|
+
params = {}
|
|
86
|
+
params["contextId"] = required_text!(context_id, :context_id) unless context_id.nil?
|
|
87
|
+
params["status"] = required_text!(status, :status) unless status.nil?
|
|
88
|
+
unless page_size.nil?
|
|
89
|
+
raise InvalidInputError, :page_size unless page_size.is_a?(Integer) && page_size.between?(1, 100)
|
|
90
|
+
params["pageSize"] = page_size
|
|
91
|
+
end
|
|
92
|
+
params["pageToken"] = string_value!(page_token, :page_token) unless page_token.nil?
|
|
93
|
+
params["historyLength"] = nonnegative_integer!(history_length, :history_length) unless history_length.nil?
|
|
94
|
+
params["statusTimestampAfter"] = required_text!(status_timestamp_after, :status_timestamp_after) unless status_timestamp_after.nil?
|
|
95
|
+
unless include_artifacts.nil?
|
|
96
|
+
raise InvalidInputError, :include_artifacts unless include_artifacts == true || include_artifacts == false
|
|
97
|
+
params["includeArtifacts"] = include_artifacts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
raw = rpc(:list_tasks, "ListTasks", params)
|
|
101
|
+
unless raw["tasks"].is_a?(Array) &&
|
|
102
|
+
raw["nextPageToken"].is_a?(String) &&
|
|
103
|
+
raw["pageSize"].is_a?(Integer) && raw["pageSize"] >= 0 &&
|
|
104
|
+
raw["totalSize"].is_a?(Integer) && raw["totalSize"] >= 0
|
|
105
|
+
raise InvalidResponseError.new(:invalid_page, operation: :list_tasks)
|
|
106
|
+
end
|
|
107
|
+
tasks = raw["tasks"].map { |value| normalize_task!(value, :list_tasks) }.freeze
|
|
108
|
+
ListResult.new(tasks: tasks,
|
|
109
|
+
next_page_token: raw.fetch("nextPageToken").dup.freeze,
|
|
110
|
+
page_size: raw.fetch("pageSize"),
|
|
111
|
+
total_size: raw.fetch("totalSize")).freeze
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cancel_task(id:, metadata: nil)
|
|
116
|
+
with_errors(:cancel_task) do
|
|
117
|
+
params = { "id" => required_text!(id, :id) }
|
|
118
|
+
params["metadata"] = encode_hash!(metadata, :metadata, opaque: true) unless metadata.nil?
|
|
119
|
+
normalize_task!(rpc(:cancel_task, "CancelTask", params), :cancel_task)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def rpc(operation, name, params)
|
|
126
|
+
@resolver.rpc(method: name, params: params, id: SecureRandom.uuid)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalize_task!(value, operation)
|
|
130
|
+
unless value.is_a?(Hash) && text?(value["id"]) &&
|
|
131
|
+
value["status"].is_a?(Hash) && text?(value["status"]["state"])
|
|
132
|
+
raise InvalidResponseError.new(:invalid_task, operation: operation)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
validate_remote_protocol_keys!(value, operation)
|
|
136
|
+
validate_remote_protocol_keys!(value["status"], operation)
|
|
137
|
+
status_message = value["status"]["message"]
|
|
138
|
+
validate_remote_message!(status_message, operation) unless status_message.nil?
|
|
139
|
+
|
|
140
|
+
artifacts = value["artifacts"]
|
|
141
|
+
unless artifacts.nil?
|
|
142
|
+
raise InvalidResponseError.new(:invalid_artifacts, operation: operation) unless artifacts.is_a?(Array)
|
|
143
|
+
artifacts.each { |artifact| validate_remote_artifact!(artifact, operation) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
history = value["history"]
|
|
147
|
+
unless history.nil?
|
|
148
|
+
raise InvalidResponseError.new(:invalid_history, operation: operation) unless history.is_a?(Array)
|
|
149
|
+
history.each { |message| validate_remote_message!(message, operation) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
decode_remote!(value, operation)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_message!(value, operation)
|
|
156
|
+
validate_remote_message!(value, operation)
|
|
157
|
+
decode_remote!(value, operation)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_remote_message!(message, operation)
|
|
161
|
+
unless message.is_a?(Hash) && text?(message["messageId"]) &&
|
|
162
|
+
text?(message["role"]) && message["parts"].is_a?(Array) &&
|
|
163
|
+
!message["parts"].empty?
|
|
164
|
+
raise InvalidResponseError.new(:invalid_message, operation: operation)
|
|
165
|
+
end
|
|
166
|
+
validate_remote_protocol_keys!(message, operation)
|
|
167
|
+
message["parts"].each { |part| validate_remote_part!(part, operation) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_remote_artifact!(artifact, operation)
|
|
171
|
+
unless artifact.is_a?(Hash) && text?(artifact["artifactId"]) &&
|
|
172
|
+
artifact["parts"].is_a?(Array) && !artifact["parts"].empty?
|
|
173
|
+
raise InvalidResponseError.new(:invalid_artifact, operation: operation)
|
|
174
|
+
end
|
|
175
|
+
validate_remote_protocol_keys!(artifact, operation)
|
|
176
|
+
artifact["parts"].each { |part| validate_remote_part!(part, operation) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate_remote_part!(part, operation)
|
|
180
|
+
kinds = %w[text raw url data]
|
|
181
|
+
unless part.is_a?(Hash) && kinds.count { |key| part.key?(key) } == 1
|
|
182
|
+
raise InvalidResponseError.new(:invalid_message_part, operation: operation)
|
|
183
|
+
end
|
|
184
|
+
validate_remote_protocol_keys!(part, operation)
|
|
185
|
+
type = kinds.find { |key| part.key?(key) }
|
|
186
|
+
valid_value = type == "data" ? part[type].is_a?(Hash) : part[type].is_a?(String)
|
|
187
|
+
unless valid_value
|
|
188
|
+
raise InvalidResponseError.new(:invalid_message_part, operation: operation)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_remote_protocol_keys!(object, operation)
|
|
193
|
+
# Reject duplicate canonical/snake aliases in protocol structures.
|
|
194
|
+
# This is deliberately NOT recursive: custom metadata/data/extension
|
|
195
|
+
# keys remain opaque, even when they resemble protocol field names.
|
|
196
|
+
Codec::REVERSE.each do |wire_key, ruby_key|
|
|
197
|
+
next unless object.key?(wire_key) && object.key?(ruby_key)
|
|
198
|
+
|
|
199
|
+
raise InvalidResponseError.new(:ambiguous_protocol_key, operation: operation)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def decode_remote!(value, operation)
|
|
204
|
+
Codec.decode(value)
|
|
205
|
+
rescue InvalidInputError => error
|
|
206
|
+
# JSON from the remote endpoint is untrusted output, not caller input.
|
|
207
|
+
# Colliding protocol keys must become a sanitized response error.
|
|
208
|
+
raise InvalidResponseError.new(error.reason, operation: operation), cause: nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def validate_message!(message)
|
|
212
|
+
unless message.is_a?(Hash) && text?(field(message, :message_id, "messageId")) &&
|
|
213
|
+
field(message, :role, "role") == "ROLE_USER" &&
|
|
214
|
+
(parts = field(message, :parts, "parts")).is_a?(Array) && !parts.empty?
|
|
215
|
+
raise InvalidInputError, :message
|
|
216
|
+
end
|
|
217
|
+
parts.each do |part|
|
|
218
|
+
raise InvalidInputError, :part unless part.is_a?(Hash)
|
|
219
|
+
count = %w[text raw url data].count { |key| part.key?(key) || part.key?(key.to_sym) }
|
|
220
|
+
raise InvalidInputError, :part_oneof unless count == 1
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def field(hash, key, wire)
|
|
225
|
+
raise InvalidInputError, :ambiguous_protocol_key if hash.key?(key) && hash.key?(wire)
|
|
226
|
+
hash.key?(key) ? hash[key] : (hash.key?(wire) ? hash[wire] : hash[key.to_s])
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def encode_hash!(value, label, opaque: false)
|
|
230
|
+
raise InvalidInputError, label unless value.is_a?(Hash)
|
|
231
|
+
Codec.encode(value, opaque: opaque)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def text?(value)
|
|
235
|
+
value.is_a?(String) && !value.empty? && !value.strip.empty?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def required_text!(value, name)
|
|
239
|
+
raise InvalidInputError, name unless text?(value)
|
|
240
|
+
value
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def string_value!(value, name)
|
|
244
|
+
raise InvalidInputError, name unless value.is_a?(String)
|
|
245
|
+
value
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def nonnegative_integer!(value, name)
|
|
249
|
+
raise InvalidInputError, name unless value.is_a?(Integer) && value >= 0
|
|
250
|
+
value
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def origin_for(uri)
|
|
254
|
+
uri.port == 443 ? "https://#{uri.host}" : "https://#{uri.host}:#{uri.port}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_callback!(callback)
|
|
258
|
+
raise ConfigurationError, :credential_callback unless callback.nil? || callback.respond_to?(:call)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_credential_binding!(callback, origin)
|
|
262
|
+
if callback && (!origin.is_a?(String) || origin.empty?)
|
|
263
|
+
raise ConfigurationError, :credential_origin
|
|
264
|
+
end
|
|
265
|
+
raise ConfigurationError, :credential_origin unless callback || origin.nil?
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def with_errors(operation)
|
|
269
|
+
yield
|
|
270
|
+
rescue Error
|
|
271
|
+
raise
|
|
272
|
+
rescue AgentCardResolver::UnsupportedInterface => error
|
|
273
|
+
raise UnsupportedInterfaceError.new(error.reason, operation: operation), cause: nil
|
|
274
|
+
rescue AgentCardResolver::RemoteError => error
|
|
275
|
+
raise RemoteError.new(error.code, operation: operation), cause: nil
|
|
276
|
+
rescue AgentCardResolver::InvalidRPC => error
|
|
277
|
+
raise InvalidResponseError.new(error.reason, operation: operation), cause: nil
|
|
278
|
+
rescue AgentCardResolver::InvalidCard, AgentCardResolver::Error => error
|
|
279
|
+
raise DiscoveryError.new(error.reason, operation: operation), cause: nil
|
|
280
|
+
rescue PinnedHttpsTransport::DeadlineExceeded
|
|
281
|
+
raise TimeoutError.new(operation: operation,
|
|
282
|
+
may_have_executed: %i[send_message cancel_task].include?(operation)), cause: nil
|
|
283
|
+
rescue PinnedHttpsTransport::HTTPError => error
|
|
284
|
+
if [401, 403].include?(error.status)
|
|
285
|
+
raise AuthenticationError.new(error.status, operation: operation), cause: nil
|
|
286
|
+
end
|
|
287
|
+
raise TransportError.new(:http_error, operation: operation), cause: nil
|
|
288
|
+
rescue PinnedHttpsTransport::InvalidResponse, PinnedHttpsTransport::ResponseTooLarge => error
|
|
289
|
+
raise InvalidResponseError.new(error.reason, operation: operation), cause: nil
|
|
290
|
+
rescue PinnedHttpsTransport::Error, OutboundPolicy::RejectedTarget => error
|
|
291
|
+
raise TransportError.new(error.reason, operation: operation), cause: nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -19,7 +19,7 @@ module A2A
|
|
|
19
19
|
:task_store, :task_page_token_secret, :task_retention,
|
|
20
20
|
:task_prune_batch_size, :max_tasks_per_owner,
|
|
21
21
|
:max_task_history_entries, :max_task_artifacts,
|
|
22
|
-
:task_execution_mode
|
|
22
|
+
:task_execution_mode, :server_enabled
|
|
23
23
|
|
|
24
24
|
def initialize
|
|
25
25
|
@agent = nil
|
|
@@ -37,6 +37,7 @@ module A2A
|
|
|
37
37
|
@max_task_history_entries = DEFAULT_MAX_TASK_HISTORY_ENTRIES
|
|
38
38
|
@max_task_artifacts = DEFAULT_MAX_TASK_ARTIFACTS
|
|
39
39
|
@task_execution_mode = :sync
|
|
40
|
+
@server_enabled = true
|
|
40
41
|
@logger_set = false
|
|
41
42
|
end
|
|
42
43
|
|
|
@@ -53,7 +54,10 @@ module A2A
|
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
def validate!
|
|
56
|
-
|
|
57
|
+
unless @server_enabled == true || @server_enabled == false
|
|
58
|
+
raise ConfigurationError, "config.server_enabled must be true or false"
|
|
59
|
+
end
|
|
60
|
+
validate_agent_name! if @server_enabled
|
|
57
61
|
normalized_public_base_url
|
|
58
62
|
validate_task_execution_mode!(@task_execution_mode, "config.task_execution_mode")
|
|
59
63
|
self
|
data/lib/a2a/rails/engine.rb
CHANGED
|
@@ -24,8 +24,11 @@ module A2A
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
initializer "a2a-rails.mount_engine" do |app|
|
|
27
|
+
# Defer this decision to route assembly, after host initializers have
|
|
28
|
+
# had a chance to set config.server_enabled = false. Existing Rails
|
|
29
|
+
# Server applications retain the default true behavior.
|
|
27
30
|
app.routes.append do
|
|
28
|
-
mount A2A::Rails::Engine => "/"
|
|
31
|
+
mount A2A::Rails::Engine => "/" if A2A::Rails.configuration.server_enabled
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
end
|
data/lib/a2a/rails/version.rb
CHANGED
data/lib/a2a-rails.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "a2a/rails/protocol/adapter"
|
|
|
20
20
|
require_relative "a2a/rails/protocol/task_mapper"
|
|
21
21
|
require_relative "a2a/rails/protocol/request_handler"
|
|
22
22
|
require_relative "a2a/rails/protocol/agent2agent_adapter"
|
|
23
|
+
require_relative "a2a/rails/client"
|
|
23
24
|
require_relative "a2a/rails/runtime"
|
|
24
25
|
require_relative "a2a/rails/engine"
|
|
25
26
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: a2a-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- cuichangquan
|
|
@@ -67,6 +67,9 @@ dependencies:
|
|
|
67
67
|
name: json
|
|
68
68
|
requirement: !ruby/object:Gem::Requirement
|
|
69
69
|
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: 2.19.9
|
|
70
73
|
- - "<"
|
|
71
74
|
- !ruby/object:Gem::Version
|
|
72
75
|
version: '3'
|
|
@@ -74,6 +77,9 @@ dependencies:
|
|
|
74
77
|
prerelease: false
|
|
75
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
76
79
|
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: 2.19.9
|
|
77
83
|
- - "<"
|
|
78
84
|
- !ruby/object:Gem::Version
|
|
79
85
|
version: '3'
|
|
@@ -224,6 +230,12 @@ files:
|
|
|
224
230
|
- lib/a2a/rails/agent_card/security.rb
|
|
225
231
|
- lib/a2a/rails/agent_card/validator.rb
|
|
226
232
|
- lib/a2a/rails/authentication.rb
|
|
233
|
+
- lib/a2a/rails/client.rb
|
|
234
|
+
- lib/a2a/rails/client/agent_card_resolver.rb
|
|
235
|
+
- lib/a2a/rails/client/codec.rb
|
|
236
|
+
- lib/a2a/rails/client/outbound_policy.rb
|
|
237
|
+
- lib/a2a/rails/client/pinned_https_transport.rb
|
|
238
|
+
- lib/a2a/rails/client/public_types.rb
|
|
227
239
|
- lib/a2a/rails/configuration.rb
|
|
228
240
|
- lib/a2a/rails/dispatcher.rb
|
|
229
241
|
- lib/a2a/rails/engine.rb
|