actionagent 1.8.1 → 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 (159) 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 +53 -6
  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 +9 -4
  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 +140 -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 +623 -18
  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 +26 -2
  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 +39 -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 +16 -4
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +84 -0
  66. data/app/models/action_agent/exploration.rb +847 -0
  67. data/app/models/action_agent/github_installation.rb +159 -0
  68. data/app/models/action_agent/input_request.rb +291 -0
  69. data/app/models/action_agent/project.rb +789 -0
  70. data/app/models/action_agent/project_secret.rb +339 -0
  71. data/app/models/action_agent/provider_key.rb +200 -3
  72. data/app/models/action_agent/recording_event.rb +145 -0
  73. data/app/models/action_agent/recording_snapshot.rb +7 -7
  74. data/app/models/action_agent/sandbox_session.rb +297 -14
  75. data/app/models/action_agent/scenario_catalog.rb +172 -0
  76. data/app/models/action_agent/scenario_product.rb +57 -0
  77. data/app/models/action_agent/scenario_set.rb +138 -0
  78. data/app/models/action_agent/session_recording.rb +121 -14
  79. data/app/models/action_agent/telemetry_trace.rb +74 -19
  80. data/app/models/concerns/action_agent/ownable.rb +84 -13
  81. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  82. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  83. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  84. data/app/services/action_agent/agent_execution_service.rb +450 -72
  85. data/app/services/action_agent/agent_sync.rb +5 -2
  86. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  87. data/app/services/action_agent/agent_toolbox.rb +215 -9
  88. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  89. data/app/services/action_agent/browser_sidecar.rb +213 -0
  90. data/app/services/action_agent/browser_sign_in.rb +163 -0
  91. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  92. data/app/services/action_agent/claude_code_auth.rb +70 -4
  93. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  94. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  95. data/app/services/action_agent/evaluation_fix.rb +101 -0
  96. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  97. data/app/services/action_agent/evaluation_runner_service.rb +4 -5
  98. data/app/services/action_agent/evaluation_standing.rb +15 -8
  99. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  100. data/app/services/action_agent/github_client.rb +405 -64
  101. data/app/services/action_agent/live_updates.rb +31 -0
  102. data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
  103. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  104. data/app/services/action_agent/mcp_client.rb +6 -2
  105. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  106. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  107. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  108. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  109. data/app/services/action_agent/project_boot_fix.rb +309 -0
  110. data/app/services/action_agent/project_capabilities.rb +200 -0
  111. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  112. data/app/services/action_agent/project_preflight.rb +178 -0
  113. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  114. data/app/services/action_agent/project_setup.rb +429 -0
  115. data/app/services/action_agent/provider_credentials.rb +102 -0
  116. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  117. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  118. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  119. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  120. data/app/services/action_agent/sandbox_browser.rb +299 -0
  121. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  122. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  123. data/app/services/action_agent/sandbox_mail.rb +103 -0
  124. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  125. data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
  126. data/app/services/action_agent/sandbox_patch.rb +253 -0
  127. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  128. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  129. data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
  130. data/app/services/action_agent/secret_scrubber.rb +33 -4
  131. data/app/services/action_agent/session_index.rb +270 -0
  132. data/app/services/action_agent/session_recording_service.rb +11 -0
  133. data/app/services/action_agent/session_timeline.rb +505 -0
  134. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  135. data/app/views/action_agent/session_player/show.html.erb +19 -0
  136. data/config/routes.rb +152 -1
  137. data/lib/action_agent/claude_login_process.rb +107 -0
  138. data/lib/action_agent/engine.rb +55 -5
  139. data/lib/action_agent/secret_requests.rb +80 -0
  140. data/lib/action_agent/version.rb +1 -1
  141. data/lib/action_agent.rb +507 -22
  142. data/lib/generators/action_agent/install_generator.rb +37 -0
  143. data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
  144. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  145. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  146. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  147. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  148. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  149. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  150. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  151. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  152. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  153. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  154. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  155. data/lib/tasks/action_agent.rake +33 -0
  156. data/lib/tasks/browser.rake +22 -0
  157. data/lib/tasks/sandbox.rake +1 -1
  158. data/lib/tasks/scenario_catalogs.rake +71 -0
  159. metadata +96 -2
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Scenario catalogs: the YAML catalogs of evaluation scenarios an owner
6
+ # keeps, imported from a document, an uploaded file or a connected
7
+ # repository at a ref, exported as YAML, synced to Active Storage, and
8
+ # run one set at a time against an agent or a project.
9
+ #
10
+ # Importing and running change evaluation scenarios, so both need the
11
+ # :replace_scenarios permission, as replacing an evaluation's scenarios
12
+ # does. A run executes the agent, so it is gated as POST
13
+ # /api/evaluations/:id/run is: the execution switch and the owner's
14
+ # execution quota.
15
+ class ScenarioCatalogsController < BaseController
16
+ before_action :require_owner!
17
+ before_action :set_catalog, only: [ :show, :update, :destroy, :export, :sync, :run_set, :materialize_set ]
18
+ before_action :require_execution_enabled!, :enforce_execution_quota!, only: [ :run_set ]
19
+
20
+ rescue_from ScenarioCatalogImport::Invalid, ScenarioSet::NoAgent do |error|
21
+ render json: { errors: [ error.message ] }, status: :unprocessable_entity
22
+ end
23
+
24
+ # GET /api/scenario_catalogs
25
+ def index
26
+ catalogs = owned(ScenarioCatalog).ordered.includes(products: { sets: :scenarios }).to_a
27
+ render json: {
28
+ catalogs: catalogs.map { |catalog| ScenarioCatalogSerializer.summary(catalog) },
29
+ storage_available: ScenarioCatalog.attachments_available?
30
+ }
31
+ end
32
+
33
+ # GET /api/scenario_catalogs/:id
34
+ def show
35
+ render json: { catalog: ScenarioCatalogSerializer.full(@catalog) }
36
+ end
37
+
38
+ # POST /api/scenario_catalogs
39
+ # One of:
40
+ # catalog: { key, name, description } for an empty catalog, built
41
+ # up in the dashboard (see ScenarioCatalogEntriesController)
42
+ # document: the catalog's YAML text
43
+ # file: an uploaded .yml
44
+ # repository: owner/name of a connected repository, with ref (its
45
+ # default branch when omitted) and path (a directory of
46
+ # .yml files, .activeagents/evals by default, or one file)
47
+ # mode is "replace" (the default: the document is the catalog) or
48
+ # "merge" (what the document leaves out is kept). Answers the imported
49
+ # catalogs. A repository the GitHub connection has not selected needs
50
+ # :manage_github, as reading one for a project does.
51
+ def create
52
+ return unless authorize_action!(:replace_scenarios, ScenarioCatalog.new)
53
+ return create_empty if params[:document].blank? && params[:file].blank? && params[:repository].blank? && params[:catalog].present?
54
+
55
+ catalogs =
56
+ if params[:repository].present?
57
+ import_from_repository or return
58
+ else
59
+ [ import_document(document_param, name: params[:name].presence, source_path: params[:source_path].presence) ]
60
+ end
61
+
62
+ render json: { catalogs: catalogs.map { |catalog| ScenarioCatalogSerializer.full(catalog) } }, status: :created
63
+ end
64
+
65
+ # PATCH /api/scenario_catalogs/:id
66
+ # With a document or file, re-imports it into this catalog (its
67
+ # products, sets and scenarios become the document's); otherwise
68
+ # renames or describes the catalog.
69
+ def update
70
+ return unless authorize_action!(:replace_scenarios, @catalog)
71
+
72
+ if params[:document].present? || params[:file].present?
73
+ import_document(document_param, catalog: @catalog, source_path: params[:source_path].presence || @catalog.source_path)
74
+ else
75
+ attributes = params.require(:catalog).permit(:name, :description)
76
+ @catalog.update!(attributes)
77
+ @catalog.refresh_document!
78
+ @catalog.sync_to_storage!
79
+ end
80
+
81
+ render json: { catalog: ScenarioCatalogSerializer.full(@catalog.reload) }
82
+ end
83
+
84
+ # DELETE /api/scenario_catalogs/:id
85
+ # The evaluations the catalog's sets became stay, with their runs.
86
+ def destroy
87
+ return unless authorize_action!(:replace_scenarios, @catalog)
88
+
89
+ @catalog.destroy!
90
+ head :no_content
91
+ end
92
+
93
+ # POST /api/scenario_catalogs/preview
94
+ # What POST /api/scenario_catalogs would change, with the same
95
+ # document, file or repository and mode, without writing: one
96
+ # ScenarioCatalogImport#preview per catalog.
97
+ def preview
98
+ previews =
99
+ if params[:repository].present?
100
+ preview_repository or return
101
+ else
102
+ [ build_import(document_param, name: params[:name].presence).preview ]
103
+ end
104
+
105
+ render json: { previews: previews }
106
+ end
107
+
108
+ # GET /api/scenario_catalogs/:id/export?product_ids=&set_ids=&scenario_ids=
109
+ # The canonical YAML, as a download: the whole catalog, or only the
110
+ # products, sets or scenarios named (comma-separated ids or arrays).
111
+ # disposition=inline answers it as text, for copying.
112
+ def export
113
+ yaml = @catalog.export_yaml(product_ids: params[:product_ids], set_ids: params[:set_ids], scenario_ids: params[:scenario_ids])
114
+ if params[:disposition].to_s == "inline"
115
+ render plain: yaml, content_type: "application/x-yaml"
116
+ else
117
+ suffix = [ params[:product_ids], params[:set_ids], params[:scenario_ids] ].any?(&:present?) ? "-selection" : ""
118
+ send_data yaml, filename: "#{@catalog.key.tr('/', '-')}#{suffix}.yml", type: "application/x-yaml", disposition: "attachment"
119
+ end
120
+ end
121
+
122
+ # POST /api/scenario_catalogs/:id/sync
123
+ # direction=push (the default) writes the document to Active Storage;
124
+ # direction=pull replaces the records with the stored document.
125
+ def sync
126
+ return unless authorize_action!(:replace_scenarios, @catalog)
127
+ unless ScenarioCatalog.attachments_available?
128
+ return render json: { error: "Active Storage is off for this dashboard (ActionAgent.active_storage)", code: "no_storage" },
129
+ status: :unprocessable_entity
130
+ end
131
+
132
+ if params[:direction].to_s == "pull"
133
+ @catalog.restore_from_storage!
134
+ else
135
+ @catalog.sync_to_storage!
136
+ end
137
+
138
+ render json: { catalog: ScenarioCatalogSerializer.full(@catalog.reload) }
139
+ rescue ActiveRecord::RecordNotFound => e
140
+ render json: { error: e.message, code: "no_document" }, status: :not_found
141
+ end
142
+
143
+ # POST /api/scenario_catalogs/:id/sets/:set_id/materialize
144
+ # Creates or refreshes the evaluation the set runs as, on agent_id or
145
+ # the product's target, without running it.
146
+ def materialize_set
147
+ return unless authorize_action!(:replace_scenarios, @catalog)
148
+
149
+ set = find_set
150
+ evaluation = set.materialize!(agent: requested_agent)
151
+ render json: { set: set.reload.summary, evaluation: EvaluationSerializer.summary(evaluation) }
152
+ end
153
+
154
+ # POST /api/scenario_catalogs/:id/sets/:set_id/run
155
+ # Runs the set against agent_id, against project_id (the project's
156
+ # sandbox is booted if it is not running, and every replay reaches it
157
+ # and its browser), or against the product's own target. models and
158
+ # keys narrow the run as they do for an evaluation.
159
+ def run_set
160
+ return unless authorize_action!(:replace_scenarios, @catalog)
161
+
162
+ set = find_set
163
+ project = owned(Project).find(params[:project_id]) if params[:project_id].present?
164
+ agent = requested_agent || project&.target_agent || set.product.target_agent(owner_agents)
165
+ if agent.nil?
166
+ return render json: { error: "Choose the agent to run against", code: "no_target" }, status: :unprocessable_entity
167
+ end
168
+ if agent.observed?
169
+ return render json: { error: "Observed agents are read-only — duplicate this agent to create an executable copy" },
170
+ status: :unprocessable_entity
171
+ end
172
+ if project
173
+ boot_project_sandbox!(project) or return
174
+ end
175
+
176
+ selection = evaluation_run_selection(params).except(:scenario_ids, :group)
177
+ run = set.run!(agent: agent, project: project, mount_url: "#{request.base_url}#{request.script_name}", **selection)
178
+ render json: {
179
+ set: set.reload.summary,
180
+ evaluation: EvaluationSerializer.summary(run.evaluation),
181
+ run: { id: run.id, status: run.status, evaluation_id: run.evaluation_id }
182
+ }, status: :accepted
183
+ end
184
+
185
+ private
186
+
187
+ include EvaluationRunStarting
188
+
189
+ def set_catalog
190
+ @catalog = owned(ScenarioCatalog).find(params[:id])
191
+ end
192
+
193
+ def find_set
194
+ ScenarioSet.where(scenario_product_id: @catalog.products.select(:id)).find(params[:set_id])
195
+ end
196
+
197
+ def requested_agent
198
+ params[:agent_id].present? ? owner_agents.find(params[:agent_id]) : nil
199
+ end
200
+
201
+ # The YAML given as text or as an uploaded file.
202
+ def document_param
203
+ file = params[:file]
204
+ return file.read if file.respond_to?(:read)
205
+
206
+ params[:document].to_s
207
+ end
208
+
209
+ def import_document(document, catalog: nil, name: nil, source_path: nil)
210
+ build_import(document, catalog: catalog, name: name, source_path: source_path).call
211
+ end
212
+
213
+ def build_import(document, catalog: nil, name: nil, source_path: nil)
214
+ ScenarioCatalogImport.new(
215
+ owner: current_owner, document: document, catalog: catalog, name: name,
216
+ source_kind: "upload", source_path: source_path,
217
+ agents: owner_agents, projects: owned(Project), mode: params[:mode].presence || "replace"
218
+ )
219
+ end
220
+
221
+ # An empty catalog from catalog: { key, name, description }; a blank
222
+ # key is made from the name.
223
+ def create_empty
224
+ attributes = params.require(:catalog).permit(:key, :name, :description).to_h.symbolize_keys
225
+ taken = owned(ScenarioCatalog).pluck(:key)
226
+ attributes[:key] = ScenarioCatalog.key_for(attributes[:name], taken) if attributes[:key].blank?
227
+ attributes[:name] = attributes[:key] if attributes[:name].blank?
228
+ catalog = ScenarioCatalog.new(attributes.merge(source_kind: "api"))
229
+ catalog.owner = current_owner
230
+ ScenarioCatalog.transaction do
231
+ catalog.save!
232
+ catalog.refresh_document!
233
+ end
234
+ catalog.sync_to_storage!
235
+
236
+ render json: { catalogs: [ ScenarioCatalogSerializer.full(catalog) ] }, status: :created
237
+ rescue ActiveRecord::RecordInvalid => e
238
+ render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
239
+ end
240
+
241
+ # Imports from the connected repository params name. nil, with the
242
+ # refusal rendered, when there is no connection or the repository is
243
+ # not one the caller may read.
244
+ def import_from_repository
245
+ connection = GithubConnection.for_owner(current_owner).first
246
+ if connection.nil?
247
+ render json: { error: "Connect GitHub in Settings first", code: "no_github_connection" }, status: :unprocessable_entity
248
+ return nil
249
+ end
250
+
251
+ repository = params[:repository].to_s
252
+ if connection.repository(repository).nil? && !ActionAgent.permitted?(current_user, :manage_github, connection)
253
+ permission_denied(:manage_github)
254
+ return nil
255
+ end
256
+
257
+ repository_import(connection, repository).call
258
+ end
259
+
260
+ # Previews what the connected repository params name would import. nil,
261
+ # with the refusal rendered, as #import_from_repository.
262
+ def preview_repository
263
+ connection = GithubConnection.for_owner(current_owner).first
264
+ if connection.nil?
265
+ render json: { error: "Connect GitHub in Settings first", code: "no_github_connection" }, status: :unprocessable_entity
266
+ return nil
267
+ end
268
+
269
+ repository = params[:repository].to_s
270
+ if connection.repository(repository).nil? && !ActionAgent.permitted?(current_user, :manage_github, connection)
271
+ permission_denied(:manage_github)
272
+ return nil
273
+ end
274
+
275
+ repository_import(connection, repository).preview
276
+ end
277
+
278
+ def repository_import(connection, repository)
279
+ ScenarioCatalogRepositoryImport.new(
280
+ owner: current_owner, connection: connection, repository: repository,
281
+ ref: params[:ref].presence, path: params[:path].presence,
282
+ agents: owner_agents, projects: owned(Project), mode: params[:mode].presence || "replace"
283
+ )
284
+ end
285
+
286
+ # Boots the project's sandbox when none is running, as the project's
287
+ # own run does. nil, with the refusal rendered, when it cannot.
288
+ def boot_project_sandbox!(project)
289
+ before = project.current_sandbox_session_id
290
+ sandbox = project.ensure_sandbox!(confirm: ActiveModel::Type::Boolean.new.cast(params[:confirm]) == true, confirmed_by: current_user)
291
+ record_execution_usage if sandbox.id != before
292
+ sandbox
293
+ rescue ActiveRecord::RecordInvalid => e
294
+ raise unless e.record.is_a?(SandboxSession)
295
+
296
+ render json: { error: "The project's sandbox could not start: #{e.record.errors.full_messages.to_sentence}" },
297
+ status: :unprocessable_entity
298
+ nil
299
+ end
300
+ end
301
+ end
302
+ end
@@ -11,21 +11,21 @@ module ActionAgent
11
11
  # own visitors should do so against its own endpoint, not one the
12
12
  # engine exposes on every host that mounts it.
13
13
 
14
- before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff ]
14
+ before_action :require_dashboard_capture!, only: [ :create, :create_events ]
15
+ before_action :require_owner!, only: :create
16
+ before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff, :timeline, :events, :create_events ]
15
17
 
16
- # Browser state that must never leave the server in a read response:
17
- # the handoff state a recording carries is a copy of the visitor's
18
- # cookies and web storage. Only #handoff returns it, to the owner, when
19
- # they continue the session.
20
- SENSITIVE_STATE_KEYS = %w[cookies session_storage local_storage].freeze
18
+ SENSITIVE_STATE_KEYS = SessionRecording::SENSITIVE_STATE_KEYS
19
+
20
+ # Recording event rows one page of #events returns at most, and the
21
+ # event bytes after which a page ends early.
22
+ EVENTS_PAGE_LIMIT = 100
23
+ EVENTS_PAGE_BYTES = 4.megabytes
21
24
 
22
25
  # GET /api/session_recordings
23
26
  # List recordings with optional filters
24
27
  def index
25
- # Recordings the caller can reach, plus the shared demo. Ownership is
26
- # a real column rather than a JSON metadata key, so this works on
27
- # every adapter.
28
- recordings = reachable_recordings.or(SessionRecording.where(name: "lander_demo")).recent
28
+ recordings = reachable_recordings.recent
29
29
 
30
30
  # Filter by status
31
31
  recordings = recordings.where(status: params[:status]) if params[:status].present?
@@ -79,25 +79,108 @@ module ActionAgent
79
79
  end
80
80
 
81
81
  # GET /api/session_recordings/:id/actions
82
- # Get the action timeline for playback
82
+ # One page of the action timeline, in sequence order. Pass the last
83
+ # sequence received as after_sequence to get the next page; has_more
84
+ # says whether one exists. limit is 1..500, default 100.
83
85
  def actions
84
86
  actions = @recording.recording_actions.ordered
85
87
 
86
- # Support pagination for large recordings
87
- if params[:after_sequence].present?
88
- actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
89
- end
88
+ after_sequence = integer_param(:after_sequence)
89
+ actions = actions.where("sequence > ?", after_sequence) if after_sequence
90
90
 
91
- limit = [ integer_param(:limit, default: 100), 500 ].min
92
- actions = actions.limit(limit)
91
+ limit = clamped_param(:limit, default: 100, min: 1, max: 500)
92
+ # One row past the page tells whether another page follows.
93
+ page = actions.limit(limit + 1).to_a
94
+ has_more = page.size > limit
93
95
 
94
96
  render json: {
95
- actions: actions.map(&:as_json_for_api),
96
- has_more: actions.count == limit,
97
+ actions: page.first(limit).map(&:as_json_for_api),
98
+ has_more: has_more,
97
99
  total_actions: @recording.action_count
98
100
  }
99
101
  end
100
102
 
103
+ # GET /api/session_recordings/:id/timeline
104
+ # The recording's browser lane with the message, llm and tool lanes of
105
+ # its conversation or run (SessionTimeline).
106
+ def timeline
107
+ render json: RecordingEvent.generate_json(timeline: SessionTimeline.for_recording(@recording, timeline_scope).as_json)
108
+ end
109
+
110
+ # GET /api/session_recordings/:id/events
111
+ # The recording's events in time order, a page of rows at a time, with
112
+ # their payloads. rrweb only unless +kind+ names others (comma
113
+ # separated). +after+ is the id of the last row already read.
114
+ def events
115
+ kinds = params[:kind].to_s.split(",").map(&:strip) & RecordingEvent::KINDS
116
+ rows = @recording.recording_events.where(kind: kinds.presence || "rrweb").chronological
117
+ if params[:after].present?
118
+ cursor = @recording.recording_events.find_by(id: integer_param(:after))
119
+ return render(json: { error: "Unknown cursor" }, status: :unprocessable_entity) unless cursor
120
+
121
+ rows = rows.where(after_row(cursor))
122
+ end
123
+ limit = clamped_param(:limit, default: 20, min: 1, max: EVENTS_PAGE_LIMIT)
124
+ fetched = rows.limit(limit + 1).to_a
125
+ page = page_of_rows(fetched, limit)
126
+
127
+ render json: RecordingEvent.generate_json(
128
+ events: page.map { |row| event_row_json(row) },
129
+ has_more: fetched.size > page.size,
130
+ next_after: page.last&.id
131
+ )
132
+ end
133
+
134
+ # POST /api/session_recordings
135
+ # Starts the caller's recording of one visit to conversation
136
+ # +agent_context_id+ in the Run Agent workbench (`source: "dashboard"`),
137
+ # which the dashboard posts its rrweb batches to (201).
138
+ #
139
+ # Each visit gets a recording of its own. An rrweb stream replays only
140
+ # against the full snapshot it began with, so two tabs on one
141
+ # conversation need two recordings. The recording caps
142
+ # (ActionAgent.recording_limits) then bound one visit.
143
+ #
144
+ # 400 without a conversation id, 404 for a conversation of an agent the
145
+ # caller cannot reach. 401 when recordings have owners and the caller
146
+ # resolves to none, since the caller could not reach the recording
147
+ # afterwards.
148
+ def create
149
+ context_id = integer_param(:agent_context_id)
150
+ return render(json: { error: "agent_context_id is required" }, status: :bad_request) if context_id.nil?
151
+
152
+ context = AgentContext.for_agents(owner_agents).find(context_id)
153
+ owner = recording_owner
154
+ if owner.nil? && SessionRecording.owner_association
155
+ return render(json: { error: "Sign in to record this conversation" }, status: :unauthorized)
156
+ end
157
+
158
+ recording = SessionRecording.start!(agent_context: context, source: "dashboard", owner: owner)
159
+ render json: { recording: dashboard_recording_json(recording) }, status: :created
160
+ end
161
+
162
+ # POST /api/session_recordings/:id/events
163
+ # A batch of browser events (RecordingEventIngest) from a dashboard
164
+ # session, with the owner's credentials masked in it. A recorder holding
165
+ # the recording's ingest token posts to the same path, and is answered
166
+ # by RecordingEventIngestController.
167
+ #
168
+ # 503 when the credentials cannot be read: the batch is not stored
169
+ # without them masked.
170
+ def create_events
171
+ secrets =
172
+ begin
173
+ owner_credentials
174
+ rescue StandardError => e
175
+ Rails.logger.warn("[ActionAgent] credential lookup for a recording batch failed: #{e.class}: #{e.message}")
176
+ return render(json: { error: "The batch could not be checked for credentials", code: "credential_check_failed" },
177
+ status: :service_unavailable)
178
+ end
179
+
180
+ result = RecordingEventIngest.call(@recording, request.raw_post, secrets: secrets)
181
+ render json: result.body, status: result.status
182
+ end
183
+
101
184
  # GET /api/session_recordings/:id/snapshot/:action_id
102
185
  # Get a specific snapshot (screenshot or DOM)
103
186
  def snapshot
@@ -270,6 +353,7 @@ module ActionAgent
270
353
  render json: { error: "Not authorized" }, status: :forbidden
271
354
  return
272
355
  end
356
+ return unless authorize_action!(:manage_recordings, @recording)
273
357
 
274
358
  @recording.destroy!
275
359
  render json: { message: "Recording deleted" }
@@ -277,10 +361,9 @@ module ActionAgent
277
361
 
278
362
  private
279
363
 
280
- # Scoped through can_manage_recording? rather than owned(): nothing in
281
- # the engine writes user_id/account_id onto a recording, so an
282
- # ownership scope would hide it from the person who made it. 404 rather
283
- # than 403 so ids stay unenumerable.
364
+ # Opens a recording only when can_manage_recording? allows it, the
365
+ # reachability reachable_recordings lists by. 404 rather than 403 so
366
+ # ids stay unenumerable.
284
367
  def set_recording
285
368
  @recording = SessionRecording.find(params[:id])
286
369
  return if can_manage_recording?(@recording)
@@ -288,17 +371,30 @@ module ActionAgent
288
371
  not_found
289
372
  end
290
373
 
291
- # What the list shows: recordings the caller owns, plus recordings made
292
- # inside a sandbox the caller owns — the same reachability
293
- # can_manage_recording? grants to a direct read, so a recording never
294
- # opens by id while missing from the list. The second clause is what
295
- # keeps recordings created before the owner column was written
296
- # reachable.
297
- def reachable_recordings
298
- scope = owned(SessionRecording)
299
- return scope if SessionRecording.owner_association.nil?
300
-
301
- scope.or(SessionRecording.where(sandbox_session_id: owned(SandboxSession).select(:id)))
374
+ # Refuses the workbench's recordings and every batch a dashboard session
375
+ # posts when the host turned capture off.
376
+ def require_dashboard_capture!
377
+ return if ActionAgent.capture_dashboard_sessions?
378
+
379
+ render json: { error: "Session capture is turned off on this dashboard", code: "capture_disabled" }, status: :forbidden
380
+ end
381
+
382
+ # Whom a recording the caller starts belongs to: the record #owned
383
+ # scopes SessionRecording by, so the caller finds it again.
384
+ def recording_owner
385
+ case SessionRecording.owner_association
386
+ when :user then current_user
387
+ when :account then current_account
388
+ end
389
+ end
390
+
391
+ def dashboard_recording_json(recording)
392
+ {
393
+ id: recording.id,
394
+ agent_context_id: recording.agent_context_id,
395
+ source: recording.source,
396
+ status: recording.status
397
+ }
302
398
  end
303
399
 
304
400
  def can_manage_recording?(recording)
@@ -313,6 +409,41 @@ module ActionAgent
313
409
  false
314
410
  end
315
411
 
412
+ # The rows that sort after +row+ in RecordingEvent.chronological order.
413
+ def after_row(row)
414
+ table = RecordingEvent.arel_table
415
+ later_in_batch = table[:batch_index].gt(row.batch_index)
416
+ .or(table[:batch_index].eq(row.batch_index).and(table[:id].gt(row.id)))
417
+ table[:occurred_from].gt(row.occurred_from)
418
+ .or(table[:occurred_from].eq(row.occurred_from).and(later_in_batch))
419
+ end
420
+
421
+ # The first +limit+ of +rows+, ending early once EVENTS_PAGE_BYTES of
422
+ # events are in the page. Always at least one row.
423
+ def page_of_rows(rows, limit)
424
+ bytes = 0
425
+ rows.first(limit).take_while do |row|
426
+ fits = bytes.zero? || bytes + row.byte_size <= EVENTS_PAGE_BYTES
427
+ bytes += row.byte_size
428
+ fits
429
+ end
430
+ end
431
+
432
+ # rrweb events are returned as recorded, since a DOM attribute may carry
433
+ # any name and a stripped snapshot would not replay. Other kinds lose
434
+ # SessionRecording::SENSITIVE_STATE_KEYS, as they do in a timeline.
435
+ def event_row_json(row)
436
+ {
437
+ id: row.id,
438
+ kind: row.kind,
439
+ occurred_from: row.occurred_from.utc.iso8601(3),
440
+ occurred_to: row.occurred_to.utc.iso8601(3),
441
+ clock_offset_ms: row.clock_offset_ms,
442
+ event_count: row.event_count,
443
+ events: row.kind == "rrweb" ? row.events : SessionRecording.without_sensitive_state(row.events)
444
+ }
445
+ end
446
+
316
447
  def recording_summary(recording)
317
448
  {
318
449
  id: recording.id,
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # The sessions a caller can replay (SessionIndex), and the timelines of
6
+ # sessions that need no recording: a conversation, a run, or the run that
7
+ # replayed an evaluation scenario. Each timeline adds the browser lane of
8
+ # any recording linked to it. See SessionTimeline.
9
+ class SessionsController < BaseController
10
+ # A filter value the index cannot read.
11
+ class InvalidFilter < ArgumentError; end
12
+
13
+ before_action :require_owner!, only: :index
14
+
15
+ # GET /api/sessions
16
+ #
17
+ # Parameters, each optional:
18
+ # agent_id one of the caller's agents
19
+ # user "me": sessions whose runs ran on behalf of the caller
20
+ # (#agent_actor), and the caller's own browser recordings
21
+ # source dashboard, evaluation or agent
22
+ # outcome failed or passed
23
+ # from, to last activity in [from, to), ISO 8601 times or dates
24
+ # before the next_before of the previous page
25
+ # per_page 25 by default, at most 100
26
+ def index
27
+ index = SessionIndex.new(
28
+ agents: owner_agents, recordings: reachable_recordings, filters: index_filters,
29
+ per_page: clamped_param(:per_page, default: SessionIndex::DEFAULT_PER_PAGE, min: 1, max: SessionIndex::MAX_PER_PAGE),
30
+ before: params[:before].is_a?(String) ? params[:before] : nil
31
+ )
32
+ render json: index.as_json
33
+ rescue InvalidFilter, SessionIndex::InvalidCursor => e
34
+ render json: { error: e.message }, status: :unprocessable_entity
35
+ end
36
+
37
+ # GET /api/sessions/:kind/:id/timeline, where +kind+ is context, run or
38
+ # scenario_result.
39
+ def timeline
40
+ timeline =
41
+ case params[:kind]
42
+ when "context" then SessionTimeline.for_context(owned_context, timeline_scope)
43
+ when "run" then SessionTimeline.for_run(owned_run(params[:id]), timeline_scope)
44
+ when "scenario_result" then SessionTimeline.for_run(scenario_result_run, timeline_scope)
45
+ end
46
+
47
+ render json: RecordingEvent.generate_json(timeline: timeline.retitle(params[:kind], integer_param(:id)).as_json)
48
+ end
49
+
50
+ private
51
+
52
+ # "Mine" compares the identity runs record (#agent_actor), which a host
53
+ # may resolve to a record other than its signed-in user.
54
+ def index_filters
55
+ mine = choice_param(:user, %w[me]).present?
56
+ SessionIndex::Filters.new(
57
+ agent_id: integer_param(:agent_id),
58
+ actor: mine ? agent_actor || SessionIndex::NO_ACTOR : nil,
59
+ user: mine ? current_user : nil,
60
+ source: choice_param(:source, SessionIndex::SOURCES),
61
+ outcome: choice_param(:outcome, SessionIndex::OUTCOMES),
62
+ from: time_param(:from),
63
+ to: time_param(:to)
64
+ )
65
+ end
66
+
67
+ # +name+'s value when it is one of +choices+, nil when absent.
68
+ # @raise [InvalidFilter] for any other value
69
+ def choice_param(name, choices)
70
+ value = params[name]
71
+ return nil if value.blank?
72
+ return value if value.is_a?(String) && choices.include?(value)
73
+
74
+ raise InvalidFilter, "#{name} must be one of: #{choices.join(', ')}"
75
+ end
76
+
77
+ # +name+ as a Time: an ISO 8601 time, or a date read as its first moment
78
+ # in the app's time zone. Nil when absent.
79
+ # @raise [InvalidFilter] for anything else
80
+ def time_param(name)
81
+ value = params[name]
82
+ return nil if value.blank?
83
+ raise ArgumentError unless value.is_a?(String)
84
+
85
+ value.match?(/\A\d{4}-\d{2}-\d{2}\z/) ? Date.iso8601(value).in_time_zone : Time.iso8601(value)
86
+ rescue ArgumentError
87
+ raise InvalidFilter, "#{name} must be an ISO 8601 time or date"
88
+ end
89
+
90
+ def owned_context
91
+ AgentContext.for_agents(owner_agents).find(params[:id])
92
+ end
93
+
94
+ def owned_run(id)
95
+ AgentRun.where(agent: owner_agents).find(id)
96
+ end
97
+
98
+ # The run a scenario result was replayed by. A result belongs to the
99
+ # caller through its evaluation's agent.
100
+ def scenario_result_run
101
+ evaluations = Evaluation.where(agent: owner_agents)
102
+ result = EvaluationScenarioResult
103
+ .where(evaluation_run: EvaluationRun.where(evaluation: evaluations))
104
+ .find(params[:id])
105
+ raise ActiveRecord::RecordNotFound unless result.agent_run_id
106
+
107
+ owned_run(result.agent_run_id)
108
+ end
109
+ end
110
+ end
111
+ end