actionagent 0.0.0 → 1.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. metadata +209 -12
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class SessionRecordingsController < BaseController
6
+ # Every action authenticates. Recordings carry a replayable timeline of
7
+ # a real browser session — including values typed into forms — so the
8
+ # lander-analytics exemption that used to cover start_user_session,
9
+ # record_action, complete_session and demo published exactly the data
10
+ # that most needs a login. A marketing site that wants to record its
11
+ # own visitors should do so against its own endpoint, not one the
12
+ # engine exposes on every host that mounts it.
13
+
14
+ before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff ]
15
+
16
+ # GET /api/session_recordings
17
+ # List recordings with optional filters
18
+ def index
19
+ # Recordings the caller owns, plus the shared demo. Ownership is a
20
+ # real column rather than a JSON metadata key, so this works on
21
+ # every adapter.
22
+ recordings = owned(SessionRecording).or(SessionRecording.where(name: "lander_demo")).recent
23
+
24
+ # Filter by status
25
+ recordings = recordings.where(status: params[:status]) if params[:status].present?
26
+
27
+ # Filter by agent
28
+ if params[:agent_id].present?
29
+ recordings = recordings.joins(:agent_run)
30
+ .where(agent_runs: { agent_id: params[:agent_id] })
31
+ end
32
+
33
+ # Filter by sandbox session
34
+ if params[:sandbox_session_id].present?
35
+ recordings = recordings.where(sandbox_session_id: params[:sandbox_session_id])
36
+ end
37
+
38
+ # Pagination
39
+ page = (params[:page] || 1).to_i
40
+ per_page = [ (params[:per_page] || 20).to_i, 100 ].min
41
+ offset = (page - 1) * per_page
42
+
43
+ total = recordings.count
44
+ recordings = recordings.offset(offset).limit(per_page)
45
+
46
+ render json: {
47
+ recordings: recordings.map { |r| recording_summary(r) },
48
+ pagination: {
49
+ page: page,
50
+ per_page: per_page,
51
+ total: total,
52
+ total_pages: (total.to_f / per_page).ceil
53
+ }
54
+ }
55
+ end
56
+
57
+ # GET /api/session_recordings/recent
58
+ # Get recent recordings for the current user
59
+ def recent
60
+ recordings = owned(SessionRecording).recent.limit(10)
61
+
62
+ render json: {
63
+ recordings: recordings.map { |r| recording_summary(r) }
64
+ }
65
+ end
66
+
67
+ # GET /api/session_recordings/:id
68
+ # Get full recording details for playback
69
+ def show
70
+ render json: {
71
+ recording: recording_detail(@recording)
72
+ }
73
+ end
74
+
75
+ # GET /api/session_recordings/:id/actions
76
+ # Get the action timeline for playback
77
+ def actions
78
+ actions = @recording.recording_actions.ordered
79
+
80
+ # Support pagination for large recordings
81
+ if params[:after_sequence].present?
82
+ actions = actions.where("sequence > ?", params[:after_sequence].to_i)
83
+ end
84
+
85
+ limit = [ params[:limit]&.to_i || 100, 500 ].min
86
+ actions = actions.limit(limit)
87
+
88
+ render json: {
89
+ actions: actions.map(&:as_json_for_api),
90
+ has_more: actions.count == limit,
91
+ total_actions: @recording.action_count
92
+ }
93
+ end
94
+
95
+ # GET /api/session_recordings/:id/snapshot/:action_id
96
+ # Get a specific snapshot (screenshot or DOM)
97
+ def snapshot
98
+ action = @recording.recording_actions.find(params[:action_id])
99
+
100
+ snapshot_type = params[:type] || "screenshot"
101
+
102
+ case snapshot_type
103
+ when "screenshot"
104
+ url = action.screenshot_url
105
+ render json: { url: url, type: "screenshot" }
106
+ when "dom"
107
+ content = action.dom_snapshot_content
108
+ render json: { content: content, type: "dom" }
109
+ else
110
+ render json: { error: "Unknown snapshot type" }, status: :bad_request
111
+ end
112
+ rescue ActiveRecord::RecordNotFound
113
+ render json: { error: "Action not found" }, status: :not_found
114
+ end
115
+
116
+ # POST /api/session_recordings/:id/export
117
+ # Export recording as VCR cassette
118
+ def export
119
+ format = params[:format] || "json"
120
+
121
+ cassette = build_cassette(@recording, format)
122
+
123
+ render json: {
124
+ cassette: cassette,
125
+ filename: "#{@recording.name}_recording.#{format}"
126
+ }
127
+ end
128
+
129
+ # POST /api/session_recordings/start_user_session
130
+ # Start a new user takeover session for analytics
131
+ def start_user_session
132
+ recording = SessionRecording.start_user_session!(
133
+ visitor_id: params[:visitor_id] || generate_visitor_id,
134
+ parent_demo_id: params[:parent_demo_id],
135
+ page_url: params[:page_url]
136
+ )
137
+
138
+ # Set user agent from request
139
+ recording.update!(
140
+ metadata: recording.metadata.merge(
141
+ user_agent: request.user_agent,
142
+ ip_hash: Digest::SHA256.hexdigest(request.remote_ip.to_s)[0..16]
143
+ )
144
+ )
145
+
146
+ # Record the handoff action
147
+ recording.record_action!(
148
+ action_type: "handoff",
149
+ value: "User took over from agent demo",
150
+ metadata: {
151
+ source: "lander_demo",
152
+ step: params[:step] || 4
153
+ }
154
+ )
155
+
156
+ render json: {
157
+ recording_id: recording.id,
158
+ visitor_id: recording.metadata["visitor_id"],
159
+ message: "User session started"
160
+ }, status: :created
161
+ end
162
+
163
+ # POST /api/session_recordings/:id/record_action
164
+ # Record a user action in an active session
165
+ def record_action
166
+ recording = SessionRecording.find(params[:id])
167
+ return not_found unless can_manage_recording?(recording)
168
+
169
+ unless recording.recording?
170
+ render json: { error: "Recording already completed" }, status: :unprocessable_entity
171
+ return
172
+ end
173
+
174
+ action = recording.record_action!(
175
+ action_type: params[:action_type],
176
+ selector: params[:selector],
177
+ value: params[:value],
178
+ metadata: params[:metadata]&.to_unsafe_h || {}
179
+ )
180
+
181
+ render json: {
182
+ action_id: action.id,
183
+ sequence: action.sequence,
184
+ timestamp_ms: action.timestamp_ms
185
+ }
186
+ end
187
+
188
+ # POST /api/session_recordings/:id/complete
189
+ # Complete a user session recording
190
+ def complete_session
191
+ recording = SessionRecording.find(params[:id])
192
+ return not_found unless can_manage_recording?(recording)
193
+
194
+ unless recording.recording?
195
+ render json: { error: "Recording already completed" }, status: :unprocessable_entity
196
+ return
197
+ end
198
+
199
+ # Record the completion action
200
+ recording.record_action!(
201
+ action_type: "completion",
202
+ value: params[:completion_type] || "session_end",
203
+ metadata: {
204
+ email_submitted: params[:email_submitted],
205
+ success: params[:success]
206
+ }
207
+ )
208
+
209
+ recording.complete!
210
+
211
+ render json: {
212
+ recording_id: recording.id,
213
+ status: recording.status,
214
+ action_count: recording.action_count,
215
+ duration_ms: recording.duration_ms
216
+ }
217
+ end
218
+
219
+ # POST /api/session_recordings/:id/handoff
220
+ # Get handoff state to continue where agent left off
221
+ def handoff
222
+ handoff_state = @recording.metadata["handoff_state"]
223
+
224
+ unless handoff_state
225
+ render json: { error: "No handoff state available" }, status: :unprocessable_entity
226
+ return
227
+ end
228
+
229
+ # Create a new recording for the user's continuation
230
+ continuation = SessionRecording.create!(
231
+ sandbox_session: @recording.sandbox_session,
232
+ agent_run: @recording.agent_run,
233
+ name: "#{@recording.name}_continuation",
234
+ status: :recording,
235
+ metadata: {
236
+ parent_recording_id: @recording.id,
237
+ handoff_from: @recording.action_count,
238
+ started_at: Time.current.iso8601,
239
+ user_id: current_user&.id
240
+ }
241
+ )
242
+
243
+ render json: {
244
+ handoff_state: handoff_state,
245
+ continuation_recording_id: continuation.id,
246
+ parent_recording: recording_summary(@recording),
247
+ message: "Ready to continue from action #{@recording.action_count}"
248
+ }
249
+ end
250
+
251
+ # DELETE /api/session_recordings/:id
252
+ def destroy
253
+ @recording = SessionRecording.find(params[:id])
254
+
255
+ # Only allow deletion of own recordings (or any if admin)
256
+ unless can_manage_recording?(@recording)
257
+ render json: { error: "Not authorized" }, status: :forbidden
258
+ return
259
+ end
260
+
261
+ @recording.destroy!
262
+ render json: { message: "Recording deleted" }
263
+ end
264
+
265
+ private
266
+
267
+ # Scoped through can_manage_recording? rather than owned(): nothing in
268
+ # the engine writes user_id/account_id onto a recording, so an
269
+ # ownership scope would hide it from the person who made it. 404 rather
270
+ # than 403 so ids stay unenumerable.
271
+ def set_recording
272
+ @recording = SessionRecording.find(params[:id])
273
+ return if can_manage_recording?(@recording)
274
+
275
+ not_found
276
+ end
277
+
278
+ def can_manage_recording?(recording)
279
+ # An install with no owner model owns everything it can see.
280
+ return true if SessionRecording.owner_association.nil?
281
+ return true if owned(SessionRecording).exists?(id: recording.id)
282
+
283
+ # Recordings made inside a sandbox belong to whoever opened it.
284
+ session = recording.sandbox_session
285
+ return true if session && session.owner.present? && session.owner == current_owner
286
+
287
+ false
288
+ end
289
+
290
+ def recording_summary(recording)
291
+ {
292
+ id: recording.id,
293
+ name: recording.name,
294
+ status: recording.status,
295
+ action_count: recording.action_count,
296
+ duration_ms: recording.duration_ms,
297
+ created_at: recording.created_at.iso8601,
298
+ thumbnail_url: first_screenshot_url(recording),
299
+ agent_name: recording.agent_run&.agent&.name,
300
+ sandbox_type: recording.sandbox_session&.sandbox_type
301
+ }
302
+ end
303
+
304
+ def recording_detail(recording)
305
+ {
306
+ id: recording.id,
307
+ name: recording.name,
308
+ status: recording.status,
309
+ action_count: recording.action_count,
310
+ duration_ms: recording.duration_ms,
311
+ metadata: safe_metadata(recording.metadata),
312
+ created_at: recording.created_at.iso8601,
313
+ updated_at: recording.updated_at.iso8601,
314
+ timeline: recording.timeline,
315
+ handoff_state: recording.metadata["handoff_state"],
316
+ agent: recording.agent_run&.agent&.slice(:id, :name),
317
+ sandbox_session: recording.sandbox_session&.summary
318
+ }
319
+ end
320
+
321
+ def first_screenshot_url(recording)
322
+ action = recording.recording_actions.with_screenshots.first
323
+ action&.screenshot_url(expires_in: 1.hour)
324
+ end
325
+
326
+ def safe_metadata(metadata)
327
+ # Remove sensitive data from metadata
328
+ metadata.except("cookies", "session_storage", "local_storage")
329
+ end
330
+
331
+ def generate_visitor_id
332
+ # Generate a stable visitor ID based on IP and user agent
333
+ fingerprint = "#{request.remote_ip}:#{request.user_agent}"
334
+ "v_#{Digest::SHA256.hexdigest(fingerprint)[0..16]}"
335
+ end
336
+
337
+ def build_cassette(recording, format)
338
+ cassette = {
339
+ name: recording.name,
340
+ recorded_at: recording.created_at.iso8601,
341
+ duration_ms: recording.duration_ms,
342
+ action_count: recording.action_count,
343
+ actions: recording.recording_actions.ordered.map do |action|
344
+ {
345
+ type: action.action_type,
346
+ sequence: action.sequence,
347
+ timestamp_ms: action.timestamp_ms,
348
+ selector: action.selector,
349
+ value: action.value,
350
+ metadata: action.metadata
351
+ }
352
+ end
353
+ }
354
+
355
+ # Include screenshots as base64 if requested
356
+ if params[:include_screenshots] == "true"
357
+ cassette[:actions].each_with_index do |action_data, i|
358
+ action = recording.recording_actions.find_by(sequence: action_data[:sequence])
359
+ if action&.screenshot_key
360
+ snapshot = RecordingSnapshot.find_by(storage_key: action.screenshot_key)
361
+ if snapshot&.file&.attached?
362
+ action_data[:screenshot_base64] = Base64.encode64(snapshot.file.download)
363
+ end
364
+ end
365
+ end
366
+ end
367
+
368
+ cassette
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ class TemplatesController < BaseController
6
+ # No anonymous exemption: #show looks a template up by bare id, so the
7
+ # exemption served unpublished drafts and private prompt libraries to
8
+ # anyone walking the id space. #index was already limited to public
9
+ # templates; #show was not.
10
+
11
+ # GET /api/templates
12
+ def index
13
+ @templates = AgentTemplate.public_templates.order(usage_count: :desc)
14
+
15
+ # Filter by category
16
+ @templates = @templates.by_category(params[:category]) if params[:category].present?
17
+
18
+ # Featured only
19
+ @templates = @templates.featured if params[:featured].present?
20
+
21
+ render json: {
22
+ templates: @templates.map { |t| template_json(t) },
23
+ categories: AgentTemplate::CATEGORIES
24
+ }
25
+ end
26
+
27
+ # GET /api/templates/:id
28
+ def show
29
+ @template = AgentTemplate.find(params[:id])
30
+ render json: { template: template_json(@template, include_details: true) }
31
+ end
32
+
33
+ # POST /api/templates/:id/use
34
+ def use
35
+ @template = AgentTemplate.find(params[:id])
36
+
37
+ agent = @template.create_agent_for(
38
+ current_user,
39
+ name: params[:name] || @template.name
40
+ )
41
+
42
+ if agent.persisted?
43
+ render json: { agent: agent_json(agent) }, status: :created
44
+ else
45
+ render json: { errors: agent.errors.full_messages }, status: :unprocessable_entity
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def template_json(template, include_details: false)
52
+ json = {
53
+ id: template.id,
54
+ name: template.name,
55
+ slug: template.slug,
56
+ description: template.description,
57
+ category: template.category,
58
+ provider: template.provider,
59
+ model: template.model,
60
+ preset_type: template.preset_type,
61
+ appearance: template.appearance,
62
+ icon: template.icon,
63
+ usage_count: template.usage_count,
64
+ featured: template.featured,
65
+ tools: template.tools
66
+ }
67
+
68
+ if include_details
69
+ json.merge!(
70
+ instructions: template.instructions,
71
+ instruction_sets: template.instruction_sets,
72
+ model_config: template.model_config
73
+ )
74
+ end
75
+
76
+ json
77
+ end
78
+
79
+ def agent_json(agent)
80
+ {
81
+ id: agent.id,
82
+ name: agent.name,
83
+ slug: agent.slug,
84
+ description: agent.description,
85
+ provider: agent.provider,
86
+ model: agent.model,
87
+ status: agent.status,
88
+ preset_type: agent.preset_type,
89
+ appearance: agent.appearance
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Read API for the tool inventory, backing the dashboard Tools view.
6
+ #
7
+ # Nothing here is registered by hand: ToolDiscovery derives the
8
+ # inventory from the tool roster each generation request offered, from
9
+ # telemetry tool spans, and from solid_agent generation/message records,
10
+ # then unions it with the tools each agent has enabled in the builder so
11
+ # configured-but-unused tools are visible too.
12
+ class ToolsController < BaseController
13
+ before_action :require_owner!
14
+
15
+ # Filter labels for the view's origin tabs, so the set of buckets is
16
+ # defined server-side alongside the classification that produces them.
17
+ ORIGIN_FILTERS = {
18
+ "all" => "All tools",
19
+ ToolDiscovery::ORIGIN_MCP => "MCP",
20
+ ToolDiscovery::ORIGIN_BUILTIN => "Dashboard",
21
+ ToolDiscovery::ORIGIN_AGENT => "Agent-defined"
22
+ }.freeze
23
+
24
+ # GET /api/tools
25
+ def index
26
+ inventory = discovery.inventory
27
+
28
+ render json: inventory.merge(
29
+ tools: filtered(inventory[:tools]),
30
+ origins: ORIGIN_FILTERS
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ def discovery
37
+ ToolDiscovery.new(traces: owned_traces, agents: owner_agents, hours: window_hours)
38
+ end
39
+
40
+ def filtered(tools)
41
+ if ORIGIN_FILTERS.key?(params[:origin].to_s) && params[:origin] != "all"
42
+ tools = tools.select { |tool| tool[:origin] == params[:origin] }
43
+ end
44
+ tools = tools.select { |tool| tool[:mcp_server] == params[:server] } if params[:server].present?
45
+
46
+ if (query = params[:q].to_s.strip.downcase).present?
47
+ tools = tools.select { |tool| tool[:name].downcase.include?(query) }
48
+ end
49
+
50
+ tools
51
+ end
52
+
53
+ def window_hours
54
+ params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Read API for telemetry traces, backing the dashboard's Traces view.
6
+ #
7
+ # Query logic mirrors the server-rendered TracesController (same scopes
8
+ # on ActionAgent.trace_model), scoped to the caller's tenant.
9
+ # Named apart from Api::TracesController because that one is the ingest
10
+ # endpoint: same /api/traces path, different verb and different auth.
11
+ class TraceReportsController < BaseController
12
+ before_action :require_owner!
13
+
14
+ DEFAULT_WINDOW_MINUTES = 60
15
+ MAX_WINDOW_MINUTES = 60 * 24 * 90
16
+ DEFAULT_LIMIT = 500
17
+
18
+ # GET /api/traces
19
+ def index
20
+ window = params.fetch(:minutes, DEFAULT_WINDOW_MINUTES).to_i.clamp(1, MAX_WINDOW_MINUTES)
21
+ window_scope = traces_scope.for_date_range(window.minutes.ago, Time.current)
22
+
23
+ scope = window_scope
24
+ scope = scope.for_agent(params[:agent]) if params[:agent].present?
25
+ scope = scope.for_service(params[:service]) if params[:service].present?
26
+ scope = scope.with_errors if params[:status] == "error"
27
+
28
+ limit = params.fetch(:limit, DEFAULT_LIMIT).to_i.clamp(1, 1000)
29
+ traces = scope.recent.limit(limit)
30
+
31
+ render json: {
32
+ traces: traces.map { |trace| TelemetryTraceSerializer.summary(trace) },
33
+ agents: window_scope.distinct.pluck(:agent_class).compact.sort,
34
+ # agent_class => platform Agent id, so the Traces view can link a
35
+ # trace back to the agent it belongs to (AgentRegistrar attributes
36
+ # SDK-reported traces to an Agent record on ingest).
37
+ agent_ids: agent_ids_by_class(window_scope),
38
+ window_minutes: window
39
+ }
40
+ end
41
+
42
+ # GET /api/traces/:id — accepts the record id or the OTel trace_id
43
+ # (full or 8-char short form), so generation refs can deep-link.
44
+ def show
45
+ trace = traces_scope.find_by(trace_id: params[:id]) ||
46
+ traces_scope.where("trace_id LIKE ?", "#{ActionAgent.trace_model.sanitize_sql_like(params[:id].to_s)}%").first ||
47
+ traces_scope.find(params[:id])
48
+ render json: { trace: TelemetryTraceSerializer.detail(trace) }
49
+ end
50
+
51
+ private
52
+
53
+ def traces_scope
54
+ ActionAgent.trace_model.for_account(current_account)
55
+ end
56
+
57
+ # One grouped query. A class can appear under several agent records
58
+ # (same class, different action); the most recently active one wins,
59
+ # since that's what the operator most likely means by "this agent".
60
+ def agent_ids_by_class(scope)
61
+ scope
62
+ .where.not(agent_id: nil, agent_class: nil)
63
+ .group(:agent_class)
64
+ .maximum(:agent_id)
65
+ end
66
+ end
67
+ end
68
+ end