actionagent 1.8.0 → 1.9.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -0,0 +1,505 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Used to build the timeline of a session: a conversation, a run or a
5
+ # recording, with what happened in it laid out in lanes on one time axis.
6
+ #
7
+ # Lanes:
8
+ # message the conversation's messages
9
+ # llm model calls: the trace's llm spans when the trace is stored,
10
+ # else the run's llm events (agent_runs.logs), else generations
11
+ # tool tool calls: the trace's tool spans when the trace is stored,
12
+ # else the run's tool events, else tool messages
13
+ # browser what the session's recordings captured: recording events
14
+ # other than rrweb, and the legacy recording actions
15
+ #
16
+ # Every entry has an `id`, its `lane`, an absolute `start` (ISO 8601 with
17
+ # milliseconds), a `duration_ms` and the `trace_id` it belongs to, nil when
18
+ # unknown. Each lane is in time order. rrweb events are not in the
19
+ # timeline: they are read page by page from the recording's events
20
+ # endpoint, and `recordings` says how many there are.
21
+ #
22
+ # A session reaches its runs, conversations and traces only through ids
23
+ # the server set (a recording's agent_run_id and agent_context_id, a run's
24
+ # trace_id), each looked up within the caller's Scope. Ids inside event
25
+ # payloads are shown, never followed. Browser tool calls are shown with
26
+ # their typed values masked (BrowserToolRedaction), and no entry carries
27
+ # SessionRecording::SENSITIVE_STATE_KEYS.
28
+ class SessionTimeline
29
+ # What the caller may reach:
30
+ # agents the caller's agents (Api::BaseController#owner_agents)
31
+ # traces the caller's telemetry traces
32
+ # recordings the recordings the caller may open
33
+ Scope = Struct.new(:agents, :traces, :recordings, keyword_init: true)
34
+
35
+ TEXT_LIMIT = 4000
36
+ LANE_LIMIT = 2000
37
+ # Recording event rows loaded per query while the browser lane is filled.
38
+ BROWSER_ROWS_PER_QUERY = 20
39
+ RUN_LOG_KINDS = { "llm" => %w[llm], "tool" => %w[tool agent] }.freeze
40
+ # What the llm lane reads from a generation; its raw response is not.
41
+ GENERATION_COLUMNS = %i[id agent_context_id trace_id model provider finish_reason input_tokens output_tokens
42
+ duration_seconds created_at].freeze
43
+
44
+ # The timeline of +context+, an AgentContext the caller owns: its
45
+ # messages, the runs and traces of its generations, and the recordings
46
+ # linked to the conversation or to one of those runs.
47
+ def self.for_context(context, scope, recordings: nil)
48
+ trace_ids = context.generations.where.not(trace_id: [ nil, "" ]).distinct.pluck(:trace_id)
49
+ runs = trace_ids.any? ? AgentRun.where(agent: scope.agents, trace_id: trace_ids).to_a : []
50
+ recordings ||= scope.recordings.where(agent_context_id: context.id)
51
+ .or(scope.recordings.where(agent_run_id: runs.map(&:id)))
52
+
53
+ new(
54
+ scope, kind: "context", id: context.id,
55
+ contexts: [ context ], runs: runs, trace_ids: trace_ids,
56
+ messages: context.messages.chronological.to_a,
57
+ generations: context.generations.select(*GENERATION_COLUMNS).to_a,
58
+ recordings: recordings
59
+ )
60
+ end
61
+
62
+ # The timeline of +run+, an AgentRun the caller owns: its trace, its
63
+ # slice of the conversations its generations were written to, and the
64
+ # recordings linked to it.
65
+ def self.for_run(run, scope, recordings: nil)
66
+ contexts = AgentContext.for_agents(scope.agents)
67
+ .where(id: AgentGeneration.where(trace_id: run.trace_id).select(:agent_context_id)).to_a
68
+ messages = contexts.flat_map { |context| run_slice(context.messages.chronological.to_a, run) }
69
+
70
+ new(
71
+ scope, kind: "run", id: run.id,
72
+ contexts: contexts, runs: [ run ], trace_ids: [ run.trace_id ],
73
+ messages: messages,
74
+ generations: AgentGeneration.where(trace_id: run.trace_id, agent_context_id: contexts.map(&:id))
75
+ .select(*GENERATION_COLUMNS).to_a,
76
+ recordings: recordings || scope.recordings.where(agent_run_id: run.id)
77
+ )
78
+ end
79
+
80
+ # The timeline of +recording+, one the caller may open: its own browser
81
+ # lane, with the lanes of its conversation when it has one the caller
82
+ # owns, else of its run.
83
+ def self.for_recording(recording, scope)
84
+ context = recording.agent_context_id && AgentContext.for_agents(scope.agents).find_by(id: recording.agent_context_id)
85
+ run = recording.agent_run_id && AgentRun.where(agent: scope.agents).find_by(id: recording.agent_run_id)
86
+
87
+ timeline =
88
+ if context then for_context(context, scope, recordings: [ recording ])
89
+ elsif run then for_run(run, scope, recordings: [ recording ])
90
+ else new(scope, kind: "recording", id: recording.id, recordings: [ recording ])
91
+ end
92
+ timeline.retitle("recording", recording.id)
93
+ end
94
+
95
+ # The messages of +run+ in a conversation: from the user message stamped
96
+ # with the run's trace id up to the next user message stamped with
97
+ # another, as the run detail shows them. A run whose user message carries
98
+ # no trace id gets the messages written while it ran.
99
+ def self.run_slice(messages, run)
100
+ start = messages.index { |message| message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id }
101
+ return messages.select { |message| written_during?(message, run) } unless start
102
+
103
+ slice = [ messages[start] ]
104
+ messages[(start + 1)..].each do |message|
105
+ trace = message.provenance&.dig("trace_id")
106
+ break if message.role == "user" && trace.present? && trace != run.trace_id
107
+
108
+ slice << message
109
+ end
110
+ slice
111
+ end
112
+
113
+ def self.written_during?(message, run)
114
+ started = run.started_at || run.created_at
115
+ finished = run.completed_at || Time.current
116
+ message.created_at.between?(started, finished)
117
+ end
118
+
119
+ def initialize(scope, kind:, id:, contexts: [], runs: [], trace_ids: [], messages: [], generations: [], recordings: [])
120
+ @scope = scope
121
+ @kind = kind
122
+ @id = id
123
+ @contexts = contexts
124
+ @runs = runs
125
+ @trace_ids = trace_ids.compact.uniq
126
+ @messages = messages
127
+ @generations = generations
128
+ @recordings = recordings.to_a
129
+ end
130
+
131
+ # Names the session as the caller addressed it, and returns the timeline.
132
+ def retitle(kind, id)
133
+ @kind = kind
134
+ @id = id
135
+ self
136
+ end
137
+
138
+ # The timeline as the JSON API returns it.
139
+ # @return [Hash]
140
+ def as_json(*)
141
+ full = { message: message_lane, llm: llm_lane, tool: tool_lane, browser: browser_lane }
142
+ lanes = full.transform_values { |entries| entries.first(LANE_LIMIT) }
143
+ entries = lanes.values.flatten
144
+ starts = entries.map { |entry| Time.iso8601(entry[:start]) }
145
+ ends = entries.zip(starts).map { |entry, start| start + (entry[:duration_ms].to_f / 1000) }
146
+
147
+ SessionRecording.without_sensitive_state(
148
+ session: {
149
+ kind: @kind,
150
+ id: @id,
151
+ started_at: starts.min&.then { |start| timestamp(start) },
152
+ ended_at: ends.max&.iso8601(3),
153
+ trace_ids: @trace_ids,
154
+ agent_run_ids: @runs.map(&:id),
155
+ agent_context_ids: @contexts.map(&:id),
156
+ truncated: full.values.any? { |lane| lane.size > LANE_LIMIT }
157
+ },
158
+ lanes: lanes,
159
+ recordings: @recordings.map { |recording| recording_summary(recording) }
160
+ )
161
+ end
162
+
163
+ private
164
+
165
+ # --- message lane --------------------------------------------------------
166
+
167
+ def message_lane
168
+ current_trace = nil
169
+ @messages.map do |message|
170
+ stamped = message.provenance&.dig("trace_id")
171
+ current_trace = stamped if message.role == "user" && stamped.present?
172
+ message_entry(message, stamped.presence || current_trace)
173
+ end.sort_by { |entry| entry[:start] }
174
+ end
175
+
176
+ def message_entry(message, trace_id)
177
+ browser_tool = message.role == "tool" ? message.tool_name : nil
178
+ {
179
+ id: "message-#{message.id}",
180
+ lane: "message",
181
+ role: message.role,
182
+ content: clip(BrowserToolRedaction.redact_text(browser_tool, message.content.to_s, message.tool_arguments)),
183
+ tool_name: message.tool_name,
184
+ tool_call_id: message.tool_call_id,
185
+ tool_calls: message.tool_calls_data.map { |call| tool_call_entry(call) },
186
+ start: timestamp(message.created_at),
187
+ duration_ms: 0
188
+ }.compact.merge(trace_id: trace_id)
189
+ end
190
+
191
+ def tool_call_entry(call)
192
+ return call unless call.is_a?(Hash)
193
+
194
+ call = call.stringify_keys
195
+ call.merge("arguments" => BrowserToolRedaction.redact_arguments(call["name"], call["arguments"]))
196
+ end
197
+
198
+ # --- llm and tool lanes --------------------------------------------------
199
+
200
+ def llm_lane
201
+ lane_for("llm") do |trace_id|
202
+ generations_by_trace.fetch(trace_id, []).map { |generation| generation_entry(generation) }
203
+ end
204
+ end
205
+
206
+ def tool_lane
207
+ lane_for("tool") do |trace_id|
208
+ tool_messages_by_trace.fetch(trace_id, []).map { |message| tool_message_entry(message, trace_id) }
209
+ end
210
+ end
211
+
212
+ # The +lane+ entries of every trace in the session: from the stored
213
+ # trace's spans, else the run's events, else what the block derives from
214
+ # the persisted conversation. A stored trace is the whole record of its
215
+ # tool calls, so a trace with no tool spans made none. A trace with no llm
216
+ # spans comes from a reporter that does not trace model calls, and the
217
+ # other sources stand in.
218
+ def lane_for(lane, &fallback)
219
+ @trace_ids.flat_map do |trace_id|
220
+ span_entries = spans_by_trace.key?(trace_id) ? span_entries(lane, trace_id) : []
221
+ next span_entries if span_entries.any? || (lane == "tool" && spans_by_trace.key?(trace_id))
222
+
223
+ run_entries = run_event_entries(lane, trace_id)
224
+ run_entries.any? ? run_entries : fallback.call(trace_id)
225
+ end.sort_by { |entry| entry[:start] }
226
+ end
227
+
228
+ def span_entries(lane, trace_id)
229
+ spans = spans_by_trace[trace_id]
230
+ selected =
231
+ if lane == "llm"
232
+ nested = spans.select { |span| span["type"] == "llm" }
233
+ nested.presence || spans.select { |span| span["parent_span_id"].nil? && span.dig("attributes", "llm.model") }
234
+ else
235
+ spans.select { |span| span["type"] == "tool" }
236
+ end
237
+ selected.filter_map { |span| span_entry(lane, span, trace_id) }
238
+ end
239
+
240
+ def span_entry(lane, span, trace_id)
241
+ started = parse_time(span["start_time"])
242
+ return nil unless started
243
+
244
+ attributes = span["attributes"] || {}
245
+ entry = {
246
+ id: "span-#{span['span_id']}",
247
+ lane: lane,
248
+ name: span["name"],
249
+ start: timestamp(started),
250
+ duration_ms: span_duration(span, started),
251
+ status: span["status"],
252
+ trace_id: trace_id
253
+ }
254
+
255
+ if lane == "llm"
256
+ entry.merge(
257
+ model: attributes["llm.model"],
258
+ provider: attributes["llm.provider"],
259
+ finish_reason: attributes["llm.finish_reason"],
260
+ tokens: span["tokens"]
261
+ ).compact
262
+ else
263
+ name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
264
+ arguments = attributes["tool.input.args"] || attributes["tool.arguments"]
265
+ result = attributes["tool.output.result"] || attributes["tool.result"]
266
+ entry.merge(
267
+ name: name,
268
+ arguments: BrowserToolRedaction.redact_arguments(name, arguments),
269
+ result: clip(BrowserToolRedaction.redact_text(name, result, arguments)),
270
+ error: attributes["tool.error"] || span["status"] == TelemetryTrace::STATUS_ERROR
271
+ ).compact
272
+ end
273
+ end
274
+
275
+ def span_duration(span, started)
276
+ return span["duration_ms"].to_f.round(3) if span["duration_ms"]
277
+
278
+ finished = parse_time(span["end_time"])
279
+ finished ? ((finished - started) * 1000).round(3) : 0
280
+ end
281
+
282
+ # The run's llm, or tool and agent, events: each a started event paired
283
+ # with the done or error event of the same eid.
284
+ def run_event_entries(lane, trace_id)
285
+ run = runs_by_trace[trace_id]
286
+ return [] unless run
287
+
288
+ events = Array(run.logs).select { |event| event.is_a?(Hash) && RUN_LOG_KINDS.fetch(lane).include?(event["kind"]) && event["eid"] }
289
+ events.group_by { |event| event["eid"] }.filter_map do |eid, pair|
290
+ started = pair.find { |event| event["status"] == "started" }
291
+ finished = pair.find { |event| event["status"] != "started" }
292
+ run_event_entry(lane, eid, started, finished, trace_id)
293
+ end
294
+ end
295
+
296
+ def run_event_entry(lane, eid, started, finished, trace_id)
297
+ first = started || finished
298
+ start = parse_time(first["at"])
299
+ return nil unless start
300
+
301
+ finished_at = started && parse_time(finished&.dig("at"))
302
+ duration = finished&.dig("duration_ms") || (finished_at ? ((finished_at - start) * 1000).round : 0)
303
+ name = first["label"].to_s
304
+ arguments = started&.dig("detail")
305
+ {
306
+ id: "log-#{eid}",
307
+ lane: lane,
308
+ name: name,
309
+ start: timestamp(started ? start : start - (duration.to_f / 1000)),
310
+ duration_ms: duration,
311
+ status: finished ? finished["status"] : "started",
312
+ arguments: lane == "tool" ? BrowserToolRedaction.redact_arguments(name, arguments) : nil,
313
+ detail: clip(BrowserToolRedaction.redact_text(name, finished&.dig("detail"), arguments)),
314
+ trace_id: trace_id
315
+ }.compact
316
+ end
317
+
318
+ def generation_entry(generation)
319
+ duration = (generation.duration_seconds.to_f * 1000).round
320
+ {
321
+ id: "generation-#{generation.id}",
322
+ lane: "llm",
323
+ name: generation.model,
324
+ model: generation.model,
325
+ provider: generation.provider,
326
+ finish_reason: generation.finish_reason,
327
+ tokens: { input: generation.input_tokens, output: generation.output_tokens },
328
+ start: timestamp(generation.created_at - (duration / 1000.0)),
329
+ duration_ms: duration,
330
+ trace_id: generation.trace_id
331
+ }.compact
332
+ end
333
+
334
+ def tool_message_entry(message, trace_id)
335
+ duration = message.metadata&.dig("duration_ms").to_f
336
+ {
337
+ id: "tool-message-#{message.id}",
338
+ lane: "tool",
339
+ name: message.tool_name,
340
+ arguments: BrowserToolRedaction.redact_arguments(message.tool_name, message.tool_arguments),
341
+ result: clip(BrowserToolRedaction.redact_text(message.tool_name, message.content.to_s, message.tool_arguments)),
342
+ start: timestamp(message.created_at - (duration / 1000)),
343
+ duration_ms: duration.round(3),
344
+ trace_id: trace_id
345
+ }.compact
346
+ end
347
+
348
+ # --- browser lane --------------------------------------------------------
349
+
350
+ # The recordings' events and legacy actions in time order, read no further
351
+ # than the earliest LANE_LIMIT + 1 of each: enough to fill the lane and to
352
+ # tell whether it was cut short.
353
+ def browser_lane
354
+ return [] if @recordings.empty?
355
+
356
+ (earliest_event_entries + earliest_action_entries).sort_by { |entry| entry[:start] }
357
+ end
358
+
359
+ # Each row holds at least one event, so the earliest LANE_LIMIT + 1 events
360
+ # are in the first LANE_LIMIT + 1 rows in RecordingEvent.chronological
361
+ # order. Those rows are decoded in that order until no unread row can
362
+ # start before the last entry kept.
363
+ def earliest_event_entries
364
+ wanted = LANE_LIMIT + 1
365
+ kept = []
366
+ starts = RecordingEvent.where(session_recording_id: @recordings.map(&:id)).where.not(kind: "rrweb")
367
+ .chronological.limit(wanted).pluck(:id, :occurred_from)
368
+
369
+ starts.each_slice(BROWSER_ROWS_PER_QUERY) do |slice|
370
+ rows = RecordingEvent.where(id: slice.map(&:first)).index_by(&:id)
371
+ slice.each do |id, occurred_from|
372
+ return kept if kept.size == wanted && timestamp(occurred_from) >= kept.last[:start]
373
+ next unless rows[id]
374
+
375
+ recording_event_entries(rows[id]).each { |entry| keep_earliest(kept, entry, wanted) }
376
+ end
377
+ end
378
+ kept
379
+ end
380
+
381
+ # Adds +entry+ to +kept+, which stays in start order and holds at most
382
+ # +limit+ entries.
383
+ def keep_earliest(kept, entry, limit)
384
+ return if kept.size == limit && entry[:start] >= kept.last[:start]
385
+
386
+ index = kept.bsearch_index { |other| other[:start] > entry[:start] } || kept.size
387
+ kept.insert(index, entry)
388
+ kept.pop if kept.size > limit
389
+ end
390
+
391
+ def earliest_action_entries
392
+ @recordings.flat_map do |recording|
393
+ recording.recording_actions.ordered.limit(LANE_LIMIT + 1).map { |action| recording_action_entry(action, recording) }
394
+ end
395
+ end
396
+
397
+ def recording_event_entries(row)
398
+ row.events.each_with_index.map do |event, index|
399
+ data = event["data"].is_a?(Hash) ? event["data"] : { "value" => event["data"] }
400
+ if row.kind == "action"
401
+ data = data.merge("parameters" => BrowserToolRedaction.redact_arguments(data["tool_name"], data["parameters"]))
402
+ end
403
+ {
404
+ id: "event-#{row.id}-#{index}",
405
+ lane: "browser",
406
+ kind: row.kind,
407
+ recording_id: row.session_recording_id,
408
+ start: timestamp(RecordingEvent.time_at(event["at"].to_i)),
409
+ duration_ms: data["duration_ms"] || 0,
410
+ data: data
411
+ }.compact.merge(trace_id: row.kind == "action" ? data["trace_id"] : nil)
412
+ end
413
+ end
414
+
415
+ def recording_action_entry(action, recording)
416
+ {
417
+ id: "recording-action-#{action.id}",
418
+ lane: "browser",
419
+ kind: "action",
420
+ recording_id: action.session_recording_id,
421
+ start: timestamp(recording.created_at + (action.timestamp_ms.to_f / 1000)),
422
+ duration_ms: 0,
423
+ data: {
424
+ "action_type" => action.action_type,
425
+ "selector" => action.selector,
426
+ "value" => action.redacted_value,
427
+ "metadata" => action.safe_metadata
428
+ }.compact,
429
+ trace_id: nil
430
+ }
431
+ end
432
+
433
+ def recording_summary(recording)
434
+ {
435
+ id: recording.id,
436
+ name: recording.name,
437
+ status: recording.status,
438
+ source: recording.source,
439
+ agent_run_id: recording.agent_run_id,
440
+ agent_context_id: recording.agent_context_id,
441
+ event_count: recording.event_count,
442
+ dropped_event_count: recording.dropped_event_count,
443
+ rrweb: {
444
+ event_count: rrweb_totals[:event_count].fetch(recording.id, 0),
445
+ first_at: rrweb_totals[:first_at][recording.id]&.iso8601(3),
446
+ last_at: rrweb_totals[:last_at][recording.id]&.iso8601(3)
447
+ },
448
+ created_at: timestamp(recording.created_at)
449
+ }
450
+ end
451
+
452
+ # The rrweb event count and time range of each recording, by recording id.
453
+ def rrweb_totals
454
+ @rrweb_totals ||= begin
455
+ rows = RecordingEvent.where(session_recording_id: @recordings.map(&:id), kind: "rrweb").group(:session_recording_id)
456
+ { event_count: rows.sum(:event_count), first_at: rows.minimum(:occurred_from), last_at: rows.maximum(:occurred_to) }
457
+ end
458
+ end
459
+
460
+ # --- lookups -------------------------------------------------------------
461
+
462
+ # Spans by trace id, for the session's traces the caller may read.
463
+ def spans_by_trace
464
+ @spans_by_trace ||= @trace_ids.empty? ? {} : @scope.traces.where(trace_id: @trace_ids).pluck(:trace_id, :spans)
465
+ .each_with_object({}) { |(trace_id, spans), by_trace| (by_trace[trace_id] ||= []).concat(Array(spans)) }
466
+ end
467
+
468
+ def runs_by_trace
469
+ @runs_by_trace ||= @runs.index_by(&:trace_id)
470
+ end
471
+
472
+ def generations_by_trace
473
+ @generations_by_trace ||= @generations.group_by(&:trace_id)
474
+ end
475
+
476
+ def tool_messages_by_trace
477
+ @tool_messages_by_trace ||= begin
478
+ current_trace = nil
479
+ @messages.each_with_object({}) do |message, by_trace|
480
+ stamped = message.provenance&.dig("trace_id")
481
+ current_trace = stamped if message.role == "user" && stamped.present?
482
+ (by_trace[current_trace] ||= []) << message if message.role == "tool"
483
+ end
484
+ end
485
+ end
486
+
487
+ # --- helpers -------------------------------------------------------------
488
+
489
+ def clip(text)
490
+ return text unless text.is_a?(String)
491
+
492
+ text.length > TEXT_LIMIT ? "#{text[0, TEXT_LIMIT]}…" : text
493
+ end
494
+
495
+ def timestamp(time)
496
+ time.utc.iso8601(3)
497
+ end
498
+
499
+ def parse_time(value)
500
+ value.present? ? Time.iso8601(value.to_s) : nil
501
+ rescue ArgumentError
502
+ nil
503
+ end
504
+ end
505
+ end
@@ -0,0 +1,55 @@
1
+ <%# The created App's settings and credentials, shown once: nothing stores them. %>
2
+ <% pem = @app["pem"].to_s %>
3
+ <% values = { "GITHUB_APP_ID" => @app["id"], "GITHUB_APP_SLUG" => @app["slug"], "GITHUB_APP_CLIENT_ID" => @app["client_id"], "GITHUB_APP_CLIENT_SECRET" => @app["client_secret"] } %>
4
+ <!doctype html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <meta name="robots" content="noindex">
10
+ <title>GitHub App created</title>
11
+ </head>
12
+ <body style="font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; color: #0f172a; background: #f8fafc; margin: 0;">
13
+ <main style="max-width: 760px; margin: 40px auto; padding: 0 20px;">
14
+ <h1 style="font-size: 22px;">GitHub App created: <%= @app["name"] %></h1>
15
+ <p>
16
+ GitHub created <strong><%= @app["slug"] %></strong><% if @app.dig("owner", "login").present? %> under <strong><%= @app.dig("owner", "login") %></strong><% end %>.
17
+ Its credentials are below and are shown only on this page: the dashboard has not stored them.
18
+ Copy them into the dashboard's configuration now.
19
+ </p>
20
+ <p style="padding: 12px; border: 1px solid #fca5a5; background: #fef2f2; border-radius: 6px;">
21
+ The client secret and the private key give full control of this App. Keep them out of source control.
22
+ </p>
23
+
24
+ <h2 style="font-size: 16px; margin-top: 28px;">As environment variables</h2>
25
+ <pre style="background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 6px; overflow-x: auto; white-space: pre;"><% values.each do |name, value| %><%= name %>=<%= value %>
26
+ <% end %>GITHUB_APP_PRIVATE_KEY="<%= pem.strip.gsub("\n", "\\n") %>"</pre>
27
+
28
+ <h2 style="font-size: 16px; margin-top: 28px;">Or in an initializer</h2>
29
+ <pre style="background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 6px; overflow-x: auto;">ActionAgent.configure do |config|
30
+ config.github_app_id = Rails.application.credentials.dig(:github_app, :id)
31
+ config.github_app_slug = Rails.application.credentials.dig(:github_app, :slug)
32
+ config.github_app_client_id = Rails.application.credentials.dig(:github_app, :client_id)
33
+ config.github_app_client_secret = Rails.application.credentials.dig(:github_app, :client_secret)
34
+ config.github_app_private_key = Rails.application.credentials.dig(:github_app, :private_key)
35
+ end</pre>
36
+ <p>with these credentials (<code>bin/rails credentials:edit</code>):</p>
37
+ <pre style="background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 6px; overflow-x: auto;">github_app:
38
+ id: <%= @app["id"] %>
39
+ slug: <%= @app["slug"] %>
40
+ client_id: <%= @app["client_id"] %>
41
+ client_secret: <%= @app["client_secret"] %>
42
+ private_key: |
43
+ <%= pem.strip.lines.map { |line| " #{line.chomp}" }.join("\n") %></pre>
44
+
45
+ <h2 style="font-size: 16px; margin-top: 28px;">Then</h2>
46
+ <ol>
47
+ <li>Restart the dashboard so it reads the new settings.</li>
48
+ <li>In <a href="<%= @settings_url %>">Settings → Integrations</a>, choose <strong>Install the GitHub App</strong> and pick the repositories checkout sandboxes may use.</li>
49
+ </ol>
50
+ <% if @app["html_url"].to_s.start_with?("https://github.com/") %>
51
+ <p>The App's own settings are on <a href="<%= @app["html_url"] %>">GitHub</a>.</p>
52
+ <% end %>
53
+ </main>
54
+ </body>
55
+ </html>
@@ -0,0 +1,19 @@
1
+ <%#
2
+ The session player's frame. It holds no data: the dashboard posts the
3
+ events to replay into it (see SessionPlayerController).
4
+ %>
5
+ <!DOCTYPE html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <title>Session player</title>
10
+ <style>
11
+ html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
12
+ #session-player { position: relative; margin: 0 auto; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div id="session-player"></div>
17
+ <script src="<%= script_path %>"></script>
18
+ </body>
19
+ </html>