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
@@ -11,12 +11,22 @@ module ActionAgent
11
11
  # callback that refuses to serve an unauthenticated dashboard outside
12
12
  # development, so it bypassed that safeguard too.
13
13
 
14
- before_action :require_execution_enabled!, only: [ :run, :compare ]
14
+ # What POST /api/sandboxes takes for a checkout's boot (see #create).
15
+ BOOT_OPTIONS = %i[bootstrap start_url keep_on_failure].freeze
16
+ BOOT_LOG_PAGE_BYTES = 64 * 1024
17
+ BOOT_LOG_MAX_PAGE_BYTES = 1024 * 1024
18
+
19
+ # Declared once per method: Rails replaces an earlier callback for the
20
+ # same method whatever its `only:`, so a second declaration would ungate
21
+ # the first one's actions.
22
+ before_action :require_execution_enabled!, only: [ :run, :compare, :resume_boot ]
15
23
  # A checkout runs the owner's code (setup, server): the same gates as
16
24
  # running an agent, like MCPServersController#launch.
17
25
  before_action :gate_checkout!, only: [ :create ]
18
- before_action :enforce_execution_quota!, only: [ :compare ]
19
- before_action :set_sandbox, only: [ :show, :run, :destroy ]
26
+ # A resume runs the checkout's code again, so it answers to the quota,
27
+ # but continues the boot its create already counted and spends nothing.
28
+ before_action :enforce_execution_quota!, only: [ :compare, :resume_boot ]
29
+ before_action :set_sandbox, only: [ :show, :run, :destroy, :boot, :boot_log, :resume_boot ]
20
30
 
21
31
  # POST /api/sandboxes/compare
22
32
  # Run multiple providers in a single sandbox using parallel generation jobs
@@ -88,7 +98,8 @@ module ActionAgent
88
98
  # GET /api/sandboxes
89
99
  # List available sandbox types and sample tasks, and the caller's own
90
100
  # sandboxes (?sandbox_type= narrows them), with what the Settings ->
91
- # Integrations view needs to offer Claude Code sessions in a checkout.
101
+ # Integrations view needs to offer Claude Code sessions and a browser
102
+ # in a checkout.
92
103
  def index
93
104
  render json: {
94
105
  sandbox_types: SandboxSession::SANDBOX_TYPES,
@@ -98,7 +109,9 @@ module ActionAgent
98
109
  sandboxes: listed_sandboxes.map(&:summary),
99
110
  code_sessions_supported: code_sessions_supported?,
100
111
  codex_sessions_supported: codex_sessions_supported?,
112
+ pull_requests_supported: pull_requests_supported?,
101
113
  codex_connected: owned(ProviderKey).where(provider: "codex").any? { |key| key.runtime_environment.present? },
114
+ browser_modes: browser_modes,
102
115
  **claude_code_status
103
116
  }
104
117
  end
@@ -116,7 +129,14 @@ module ActionAgent
116
129
 
117
130
  # POST /api/sandboxes
118
131
  # Create a new sandbox session, owned by whoever opened it.
132
+ #
133
+ # A checkout also takes how it boots (see SandboxBootSpec.request_options):
134
+ # `bootstrap` ("auto", "always" or "never"), `start_url` and
135
+ # `keep_on_failure`.
119
136
  def create
137
+ boot = checkout_requested? ? boot_options : nil
138
+ return if performed?
139
+
120
140
  @sandbox = SandboxSession.new(sandbox_params)
121
141
  # Guarded: the association only exists when the host app configured a
122
142
  # user model, and a single-user install configures none.
@@ -127,7 +147,7 @@ module ActionAgent
127
147
  @sandbox.agent_template = AgentTemplate.find_by(slug: params[:template_slug]) if params[:template_slug]
128
148
 
129
149
  if @sandbox.save
130
- @sandbox.provision!
150
+ @sandbox.provision!(boot: boot)
131
151
  @sandbox.reload # Reload to get updated status after provisioning
132
152
  # Counted once the checkout exists, as MCPServersController#launch
133
153
  # counts a launched server.
@@ -188,6 +208,84 @@ module ActionAgent
188
208
  }, status: :accepted
189
209
  end
190
210
 
211
+ # GET /api/sandboxes/:session_id/boot
212
+ # How a checkout's boot went, step by step (see
213
+ # SandboxOrchestrator#boot_status). `boot` is null when the backend
214
+ # reports no steps, or holds nothing for the sandbox.
215
+ def boot
216
+ orchestrator = SandboxOrchestrator.new
217
+ status = @sandbox.app_runtime? && orchestrator.supports?(:boot_status) ? orchestrator.boot_status(@sandbox) : nil
218
+ # A project's checkout booted with its secrets, which the backend may
219
+ # not know to mask.
220
+ status = SecretScrubber.scrub(status, @sandbox.project_scrub_values) if status
221
+ render json: {
222
+ boot: status,
223
+ resumable: !!(status&.dig(:kept) && @sandbox.failed? && !@sandbox.past_expiry? && orchestrator.supports?(:resume_boot)),
224
+ logs: @sandbox.app_runtime? && orchestrator.supports?(:boot_log)
225
+ }
226
+ end
227
+
228
+ # GET /api/sandboxes/:session_id/boot_log?step=NAME&offset=N&limit=N
229
+ # One page of a boot step's log, scrubbed (see
230
+ # SandboxOrchestrator#boot_log). Read on from `next_offset` until
231
+ # `eof`.
232
+ def boot_log
233
+ orchestrator = SandboxOrchestrator.new
234
+ unless @sandbox.app_runtime? && orchestrator.supports?(:boot_log)
235
+ return render json: { error: "This sandbox backend keeps no boot logs" }, status: :not_found
236
+ end
237
+
238
+ step = params[:step]
239
+ return render json: { error: "step is required" }, status: :bad_request unless step.is_a?(String) && step.present?
240
+
241
+ project_secrets = @sandbox.project_scrub_values
242
+ page = orchestrator.boot_log(@sandbox, step: step, offset: clamped_param(:offset, default: 0, min: 0, max: 2**62),
243
+ limit: clamped_param(:limit, default: BOOT_LOG_PAGE_BYTES, min: 1, max: BOOT_LOG_MAX_PAGE_BYTES), secrets: project_secrets)
244
+ return render json: { error: "No log for step #{step}" }, status: :not_found if page.nil?
245
+
246
+ render json: project_secrets.any? ? page.merge(text: SecretScrubber.scrub(page[:text], project_secrets)) : page
247
+ end
248
+
249
+ # POST /api/sandboxes/:session_id/resume_boot
250
+ # Continues a checkout whose boot failed and was kept (`keep_on_failure`),
251
+ # from the step named `from`, or from the one that failed. The sandbox
252
+ # is provisioning again, and is polled until ready or failed as after
253
+ # #create.
254
+ def resume_boot
255
+ orchestrator = SandboxOrchestrator.new
256
+ unless @sandbox.app_runtime? && orchestrator.supports?(:resume_boot)
257
+ return render json: { error: "This sandbox backend cannot resume a boot" }, status: :unprocessable_entity
258
+ end
259
+
260
+ from = params[:from]
261
+ unless from.nil? || from.is_a?(String)
262
+ return render json: { error: "from must be a step name" }, status: :bad_request
263
+ end
264
+
265
+ status = orchestrator.boot_status(@sandbox) if orchestrator.supports?(:boot_status)
266
+ if orchestrator.supports?(:boot_status) && !status&.dig(:kept)
267
+ return render json: { error: "This sandbox kept no failed boot to resume: start it again", sandbox: @sandbox.summary },
268
+ status: :unprocessable_entity
269
+ end
270
+
271
+ # Refused here rather than by the backend, whose refusal would fail
272
+ # the sandbox again and replace the error the failed boot left.
273
+ steps = status&.dig(:resumable_steps)
274
+ if from.present? && steps.is_a?(Array) && !steps.include?(from)
275
+ return render json: { error: "#{from.inspect} is not a step this boot can resume from (#{steps.join(", ")})" },
276
+ status: :unprocessable_entity
277
+ end
278
+
279
+ unless @sandbox.resume_boot!(from: from)
280
+ return render json: {
281
+ error: "Only a failed checkout that has not expired can be resumed: start it again",
282
+ sandbox: @sandbox.summary
283
+ }, status: :unprocessable_entity
284
+ end
285
+
286
+ render json: { sandbox: @sandbox.reload.summary }, status: :accepted
287
+ end
288
+
191
289
  # DELETE /api/sandboxes/:session_id
192
290
  # End sandbox session. Expiring it enqueues SandboxCleanupJob, which
193
291
  # terminates whatever the backend runs for it (a checkout's processes
@@ -208,6 +306,22 @@ module ActionAgent
208
306
  params[:sandbox_type].to_s == "app_runtime"
209
307
  end
210
308
 
309
+ # A checkout's boot options, or a rendered 422 when they are malformed
310
+ # or ask for a bootstrap the backend cannot do.
311
+ def boot_options
312
+ raw = params.slice(*BOOT_OPTIONS).permit(*BOOT_OPTIONS).to_h.symbolize_keys
313
+ options = SandboxBootSpec.request_options(**raw)
314
+ if options["bootstrap"] == "always" && !SandboxOrchestrator.new.accepts_boot_config?
315
+ render json: { errors: [ "This sandbox backend cannot bootstrap a checkout" ] }, status: :unprocessable_entity
316
+ return
317
+ end
318
+
319
+ options
320
+ rescue SandboxBootSpec::Invalid => e
321
+ render json: { errors: [ e.message ] }, status: :unprocessable_entity
322
+ nil
323
+ end
324
+
211
325
  # A checkout runs on its account's GitHub token and Claude Code
212
326
  # credential, so in a multi-tenant install it is listed, shown and
213
327
  # stopped only within the caller's current account, as
@@ -236,12 +350,29 @@ module ActionAgent
236
350
  false
237
351
  end
238
352
 
353
+ # The modes a checkout's browser can start in on the configured
354
+ # backend (SandboxBrowser): none when it runs no browsers, or cannot be
355
+ # loaded.
356
+ def browser_modes
357
+ SandboxOrchestrator.new.browser_modes.map(&:to_s)
358
+ rescue StandardError, LoadError
359
+ []
360
+ end
361
+
239
362
  def codex_sessions_supported?
240
363
  SandboxOrchestrator.new.supports_code_runner?("codex")
241
364
  rescue StandardError, LoadError
242
365
  false
243
366
  end
244
367
 
368
+ # Whether the configured backend can read a checkout's changes, which
369
+ # opening a pull request and downloading a patch both need.
370
+ def pull_requests_supported?
371
+ SandboxOrchestrator.new.reads_checkouts?
372
+ rescue StandardError, LoadError
373
+ false
374
+ end
375
+
245
376
  # Whether the caller's Claude Code can run sessions, by
246
377
  # ClaudeCodeAuth's rule: an API key they connected, or this machine's
247
378
  # own login. Never a credential.
@@ -254,11 +385,12 @@ module ActionAgent
254
385
  # SandboxSession#runtime_environment finds the credential it hands a
255
386
  # checkout: a provider key is account-owned before user-owned.
256
387
  def claude_code_status
257
- status = ClaudeCodeAuth.status(owned(ProviderKey))
388
+ status = ClaudeCodeAuth.status(owned(ProviderKey), sandboxes: listed_sandboxes, user_id: current_user&.id)
258
389
  {
259
390
  claude_code_auth: status[:mode],
260
391
  claude_code_connected: status[:connected],
261
- claude_code_login: status[:login]
392
+ claude_code_login: status[:login],
393
+ claude_code_sandboxes: status[:sandboxes]
262
394
  }.compact
263
395
  end
264
396
 
@@ -274,7 +406,7 @@ module ActionAgent
274
406
  # An app_runtime sandbox also names the checkout: one of the owner's
275
407
  # selected GitHub repositories and, optionally, a ref.
276
408
  def sandbox_params
277
- params.permit(:sandbox_type, :repository, :repository_ref)
409
+ params.slice(:sandbox_type, :repository, :repository_ref).permit(:sandbox_type, :repository, :repository_ref)
278
410
  end
279
411
 
280
412
  def free_tier_templates
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Editing a scenario catalog in the dashboard, one entry at a time: its
6
+ # products, their sets, and the sets' scenarios. Each change is checked
7
+ # as the YAML importer checks a document (the records' validations, then
8
+ # the catalog's document rebuilt and parsed), rewrites the catalog's
9
+ # document and digest, and writes it to Active Storage when the host has
10
+ # it, so an edit and an import leave the same thing behind.
11
+ #
12
+ # Every change needs :replace_scenarios on the catalog, as an import does.
13
+ # A key left blank is made from the name (or a scenario's prompt).
14
+ class ScenarioCatalogEntriesController < BaseController
15
+ before_action :require_owner!
16
+ before_action :set_catalog
17
+ before_action -> { authorize_action!(:replace_scenarios, @catalog) }
18
+
19
+ rescue_from ActiveRecord::RecordInvalid do |error|
20
+ render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity
21
+ end
22
+ rescue_from ActiveAgent::Evals::Catalog::InvalidDocument do |error|
23
+ render json: { errors: [ error.message ] }, status: :unprocessable_entity
24
+ end
25
+
26
+ # POST /api/scenario_catalogs/:id/products
27
+ # { product: { key, name, description, agent_id, agent_name, project_id } }
28
+ def create_product
29
+ attributes = product_attributes
30
+ attributes[:key] = ScenarioCatalog.key_for(attributes[:name], @catalog.products.pluck(:key)) if attributes[:key].blank?
31
+ attributes[:name] = attributes[:key] if attributes[:name].blank?
32
+ product = change { @catalog.products.create!(attributes.merge(position: next_position(@catalog.products))) }
33
+ render json: { product: product.summary, catalog: full }, status: :created
34
+ end
35
+
36
+ # PATCH /api/scenario_catalogs/:id/products/:product_id
37
+ def update_product
38
+ product = find_product
39
+ change { product.update!(product_attributes.compact_blank.merge(product_links)) }
40
+ render json: { product: product.reload.summary, catalog: full }
41
+ end
42
+
43
+ # DELETE /api/scenario_catalogs/:id/products/:product_id
44
+ # The evaluations its sets became stay, with their runs.
45
+ def destroy_product
46
+ product = find_product
47
+ change { product.destroy! }
48
+ render json: { catalog: full }
49
+ end
50
+
51
+ # POST /api/scenario_catalogs/:id/products/:product_id/sets
52
+ # { set: { key, name, description, judge: { kind, model } } }
53
+ def create_set
54
+ product = find_product
55
+ attributes = set_attributes
56
+ attributes[:key] = ScenarioCatalog.key_for(attributes[:name], product.sets.pluck(:key)) if attributes[:key].blank?
57
+ attributes[:name] = attributes[:key] if attributes[:name].blank?
58
+ set = change { product.sets.create!(attributes.merge(position: next_position(product.sets))) }
59
+ render json: { set: set.summary, catalog: full }, status: :created
60
+ end
61
+
62
+ # PATCH /api/scenario_catalogs/:id/sets/:set_id
63
+ def update_set
64
+ set = find_set
65
+ change { set.update!(set_attributes.compact_blank) }
66
+ render json: { set: set.reload.summary, catalog: full }
67
+ end
68
+
69
+ # DELETE /api/scenario_catalogs/:id/sets/:set_id
70
+ def destroy_set
71
+ set = find_set
72
+ change { set.destroy! }
73
+ render json: { catalog: full }
74
+ end
75
+
76
+ # POST /api/scenario_catalogs/:id/sets/:set_id/scenarios
77
+ # { scenario: { key, prompt, notes, expect: { tools, contains, not_contains }, tags, production_only, enabled } }
78
+ def create_scenario
79
+ set = find_set
80
+ attributes = scenario_attributes
81
+ attributes[:key] = ScenarioCatalog.key_for(attributes[:prompt].to_s.split.first(6).join(" "), set.scenarios.pluck(:key)) if attributes[:key].blank?
82
+ if @catalog.scenario_count >= ScenarioCatalog::MAX_SCENARIOS
83
+ return render json: { errors: [ "A catalog holds at most #{ScenarioCatalog::MAX_SCENARIOS} scenarios" ] }, status: :unprocessable_entity
84
+ end
85
+
86
+ scenario = change { set.scenarios.create!(attributes.merge(position: next_position(set.scenarios))) }
87
+ render json: { scenario: scenario.summary, catalog: full }, status: :created
88
+ end
89
+
90
+ # PATCH /api/scenario_catalogs/:id/sets/:set_id/scenarios/:scenario_id
91
+ def update_scenario
92
+ scenario = find_set.scenarios.find(params[:scenario_id])
93
+ change { scenario.update!(scenario_attributes.except(*blank_identity(scenario_attributes))) }
94
+ render json: { scenario: scenario.reload.summary, catalog: full }
95
+ end
96
+
97
+ # DELETE /api/scenario_catalogs/:id/sets/:set_id/scenarios/:scenario_id
98
+ def destroy_scenario
99
+ scenario = find_set.scenarios.find(params[:scenario_id])
100
+ change { scenario.destroy! }
101
+ render json: { catalog: full }
102
+ end
103
+
104
+ private
105
+
106
+ def set_catalog
107
+ @catalog = owned(ScenarioCatalog).find(params[:id])
108
+ end
109
+
110
+ def find_product
111
+ @catalog.products.find(params[:product_id])
112
+ end
113
+
114
+ def find_set
115
+ ScenarioSet.where(scenario_product_id: @catalog.products.select(:id)).find(params[:set_id])
116
+ end
117
+
118
+ # Runs +block+ and rebuilds the catalog's document in one transaction,
119
+ # so a change the document cannot hold is rolled back; then writes the
120
+ # document to storage. Returns the block's value.
121
+ def change
122
+ result = ScenarioCatalog.transaction do
123
+ value = yield
124
+ @catalog.reload.refresh_document!
125
+ value
126
+ end
127
+ @catalog.sync_to_storage!
128
+ result
129
+ end
130
+
131
+ def full
132
+ ScenarioCatalogSerializer.full(@catalog.reload)
133
+ end
134
+
135
+ def next_position(relation)
136
+ (relation.maximum(:position) || -1) + 1
137
+ end
138
+
139
+ def product_attributes
140
+ params.require(:product).permit(:key, :name, :description, :agent_name).to_h.symbolize_keys.merge(product_links)
141
+ end
142
+
143
+ # The agent and project a product names by id, among the owner's own.
144
+ # An empty id clears it.
145
+ def product_links
146
+ source = params[:product] || {}
147
+ links = {}
148
+ if source.key?(:agent_id)
149
+ links[:agent] = source[:agent_id].present? ? owner_agents.find(source[:agent_id]) : nil
150
+ end
151
+ if source.key?(:project_id)
152
+ links[:project] = source[:project_id].present? ? owned(Project).find(source[:project_id]) : nil
153
+ end
154
+ links
155
+ end
156
+
157
+ def set_attributes
158
+ source = params.require(:set)
159
+ attributes = source.permit(:key, :name, :description).to_h.symbolize_keys
160
+ if source.key?(:judge)
161
+ judge = source[:judge].respond_to?(:permit) ? source[:judge].permit(:kind, :model).to_h.compact_blank : {}
162
+ attributes[:judge] = judge
163
+ end
164
+ attributes
165
+ end
166
+
167
+ def scenario_attributes
168
+ source = params.require(:scenario)
169
+ attributes = source.permit(:key, :prompt, :notes, :production_only, :enabled).to_h.symbolize_keys
170
+ expect = source[:expect] || source[:expectations]
171
+ if expect.respond_to?(:permit)
172
+ attributes[:expectations] = Exploration::EXPECTATION_KEYS.each_with_object({}) do |name, hash|
173
+ values = Array(expect[name]).flat_map { |value| value.to_s.split(",") }.map(&:strip).reject(&:blank?)
174
+ hash[name] = values if values.any?
175
+ end
176
+ end
177
+ attributes[:tags] = Array(source[:tags]).flat_map { |tag| tag.to_s.split(",") }.map(&:strip).reject(&:blank?) if source.key?(:tags)
178
+ attributes
179
+ end
180
+
181
+ # An update leaves the key and prompt alone when they arrive blank,
182
+ # rather than failing presence.
183
+ def blank_identity(attributes)
184
+ %i[key prompt].select { |name| attributes.key?(name) && attributes[name].blank? }
185
+ end
186
+ end
187
+ end
188
+ end