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,512 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ module Api
5
+ # Projects: picking a repository, checking it can boot, creating the
6
+ # project with the secrets its boot needs, following each boot, choosing
7
+ # the agent to evaluate and running the project's evaluation against the
8
+ # booted app. See Project.
9
+ #
10
+ # Everything is scoped to the caller's owner. The project's sandbox and
11
+ # agent carry the project's owner columns, so they are reached through
12
+ # the project here rather than through the sandboxes and agents APIs,
13
+ # which scope to the signed-in user.
14
+ class ProjectsController < BaseController
15
+ include ProjectSecretAuthorization
16
+
17
+ LOG_TAIL_BYTES = 8 * 1024
18
+ LOG_PAGE_BYTES = 64 * 1024
19
+ LOG_MAX_PAGE_BYTES = 1024 * 1024
20
+ UPDATABLE = %i[name default_ref start_url].freeze
21
+
22
+ before_action :require_owner!
23
+ before_action :set_project, except: [ :index, :create, :capabilities, :preflight, :discover_secrets ]
24
+ before_action :require_execution_enabled!, only: [ :boot, :run_evaluation ]
25
+
26
+ # Later handlers win, so subclasses are registered after GithubClient::Error.
27
+ rescue_from GithubClient::Error, with: :github_unavailable
28
+ rescue_from GithubClient::NotFound, with: :github_not_found
29
+ rescue_from GithubClient::Unauthorized, with: :github_unauthorized
30
+ rescue_from Project::ConfirmationRequired, with: :confirmation_required
31
+ rescue_from Project::BootRefused, with: :boot_refused
32
+ rescue_from SandboxOrchestrator::UnsupportedBackendError, with: :boot_refused
33
+
34
+ # GET /api/projects
35
+ def index
36
+ projects = owned(Project).recent.includes(:current_sandbox_session, :target_agent, :evaluation, :secrets)
37
+ render json: { projects: projects.map(&:summary) }
38
+ end
39
+
40
+ # GET /api/projects/:id
41
+ def show
42
+ render json: { project: @project.summary, secrets: secret_summaries(@project) }
43
+ end
44
+
45
+ # GET /api/projects/capabilities
46
+ # The New Project checklist (see ProjectCapabilities).
47
+ def capabilities
48
+ render json: capabilities_check.call
49
+ end
50
+
51
+ # GET /api/projects/preflight?repository=owner/name&ref=
52
+ # Whether the repository can become a project, read through GitHub's
53
+ # contents API before any sandbox exists (see ProjectPreflight). A
54
+ # repository the connection has not selected is looked up by name,
55
+ # which also reaches repositories past the listing's cap, and needs
56
+ # :manage_github.
57
+ def preflight
58
+ connection = github_connection! or return
59
+ repository = reachable_repository!(connection) or return
60
+
61
+ ref = ref_param || repository["default_branch"]
62
+ render json: {
63
+ repository: repository.merge("selected" => connection.repository(repository["full_name"]).present?),
64
+ preflight: ProjectPreflight.call(connection.client, repository: repository["full_name"], ref: ref)
65
+ }
66
+ end
67
+
68
+ # GET /api/projects/discover_secrets?repository=owner/name&ref=
69
+ # The environment variables the repository expects (see
70
+ # ProjectSecretDiscovery), each marked with whether the project named by
71
+ # `project_id`, if any, has it set. A repository the connection has not
72
+ # selected needs :manage_github, as for preflight.
73
+ def discover_secrets
74
+ connection = github_connection! or return
75
+ repository = reachable_repository!(connection) or return
76
+
77
+ discovery = ProjectSecretDiscovery.call(connection.client, repository: repository["full_name"],
78
+ ref: ref_param || repository["default_branch"])
79
+ set = params[:project_id].present? ? owned(Project).find(params[:project_id]).secrets.pluck(:name).to_set : Set.new
80
+ discovery[:variables].each { |variable| variable[:set] = set.include?(variable[:name]) }
81
+ render json: discovery
82
+ end
83
+
84
+ # POST /api/projects
85
+ # { repository:, name:, default_ref:, start_url:, secrets: [{ name:, value: } |
86
+ # { name:, source: "organization_key", consent: true }] }
87
+ #
88
+ # Refused while a blocking capability fails, when the host's quota
89
+ # denies a :project, and for a repository the preflight does not
90
+ # support. Picking a repository the GitHub connection has not selected
91
+ # selects it, which needs :manage_github.
92
+ def create
93
+ failures = capabilities_check.blocking_failures
94
+ if failures.any?
95
+ return render json: { error: "This dashboard cannot create projects yet: #{failures.map { |item| item[:label] }.join(", ")}",
96
+ code: "capabilities", items: failures }, status: :unprocessable_entity
97
+ end
98
+ return if quota_denied?
99
+
100
+ connection = github_connection! or return
101
+ repository = reachable_repository!(connection) or return
102
+ full_name = repository["full_name"]
103
+ ref = ref_param
104
+ report = ProjectPreflight.call(connection.client, repository: full_name, ref: ref || repository["default_branch"])
105
+ if report["status"] == "unsupported"
106
+ return render json: { error: report["summary"], code: "unsupported_repository", preflight: report },
107
+ status: :unprocessable_entity
108
+ end
109
+
110
+ secrets = params[:secrets] || []
111
+ return render json: { error: "secrets must be a list" }, status: :bad_request unless secrets.is_a?(Array)
112
+
113
+ project = build_project(full_name, ref, report)
114
+ return unless secret_sources_allowed!(secrets)
115
+ return unless authorize_secrets!(project, secrets)
116
+
117
+ selecting = connection.repository(full_name).nil?
118
+ Project.transaction do
119
+ # The selection is what a project's sandbox is booted from.
120
+ connection.update!(repositories: connection.repositories + [ repository ]) if selecting
121
+ project.save!
122
+ secrets.each { |attributes| assign_secret_params(project, attributes).save! }
123
+ project.ensure_app_assistant! unless project.engine_installed?
124
+ end
125
+ ActionAgent.record_usage(current_owner, :project)
126
+
127
+ render json: { project: project.reload.summary, secrets: secret_summaries(project) }, status: :created
128
+ end
129
+
130
+ # PATCH /api/projects/:id { name:, default_ref:, start_url: }
131
+ #
132
+ # A new default_ref (empty for the repository's default branch) is
133
+ # preflighted like a new project's repository and refused when not
134
+ # supported. The next boot hands the project's secrets to the code at
135
+ # that ref, so changing it needs what setting each secret needs, and
136
+ # stops the sandbox booted from the old ref (see Project#change_ref!).
137
+ def update
138
+ attributes = params.permit(*UPDATABLE).to_h
139
+ if attributes.key?("default_ref")
140
+ ref, report = new_ref(attributes.delete("default_ref").to_s)
141
+ return if performed?
142
+ end
143
+
144
+ @project.assign_attributes(attributes)
145
+ report ? @project.change_ref!(ref, report) : @project.save!
146
+ render json: { project: @project.reload.summary }
147
+ end
148
+
149
+ # DELETE /api/projects/:id
150
+ # Deletes the project, its secrets, its agent and evaluation, and stops
151
+ # its sandbox. Deleting its secrets needs :manage_project_secrets.
152
+ def destroy
153
+ return unless authorize_secrets_removal!(@project)
154
+
155
+ @project.discard!
156
+ head :no_content
157
+ end
158
+
159
+ # POST /api/projects/:id/boot { confirm: true }
160
+ # Returns the project's sandbox, booting one when there is none live
161
+ # (see Project#ensure_sandbox!). A first boot on the :local backend
162
+ # answers 409 with the question to confirm, and is asked again with
163
+ # `confirm: true`.
164
+ def boot
165
+ return if execution_quota_denied?
166
+
167
+ sandbox, booted = ensure_project_sandbox!
168
+ return if performed?
169
+
170
+ render json: { project: @project.reload.summary, sandbox: sandbox.summary, booted: booted },
171
+ status: booted ? :accepted : :ok
172
+ end
173
+
174
+ # GET /api/projects/:id/boot
175
+ # The current sandbox's boot, step by step (see
176
+ # SandboxOrchestrator#boot_status), with the scrubbed tail of the step
177
+ # that failed or is running, and the modes its browser can start in
178
+ # (none when the backend runs no browsers).
179
+ def boot_status
180
+ sandbox = @project.current_sandbox_session
181
+ orchestrator = SandboxOrchestrator.new
182
+ status = sandbox && orchestrator.supports?(:boot_status) ? orchestrator.boot_status(sandbox) : nil
183
+ status = SecretScrubber.scrub(status.deep_symbolize_keys, @project.scrub_values) if status.is_a?(Hash)
184
+
185
+ render json: {
186
+ project: @project.summary,
187
+ boot: status,
188
+ log_tail: status && log_tail(orchestrator, sandbox, status),
189
+ error: sandbox&.failed? ? SecretScrubber.scrub(sandbox.error_summary, @project.scrub_values) : nil,
190
+ logs: sandbox.present? && orchestrator.supports?(:boot_log),
191
+ confirmation: @project.local_boot_confirmation(orchestrator),
192
+ browser_modes: browser_modes(orchestrator)
193
+ }
194
+ end
195
+
196
+ # GET /api/projects/:id/boot_log?step=NAME&offset=N&limit=N
197
+ # One page of a boot step's log, scrubbed of the project's secrets.
198
+ def boot_log
199
+ sandbox = @project.current_sandbox_session
200
+ orchestrator = SandboxOrchestrator.new
201
+ unless sandbox && orchestrator.supports?(:boot_log)
202
+ return render json: { error: "This project's sandbox keeps no boot logs" }, status: :not_found
203
+ end
204
+
205
+ step = params[:step]
206
+ return render json: { error: "step is required" }, status: :bad_request unless step.is_a?(String) && step.present?
207
+
208
+ page = orchestrator.boot_log(sandbox, step: step, offset: clamped_param(:offset, default: 0, min: 0, max: 2**62),
209
+ limit: clamped_param(:limit, default: LOG_PAGE_BYTES, min: 1, max: LOG_MAX_PAGE_BYTES), secrets: @project.scrub_values)
210
+ return render json: { error: "No log for step #{step}" }, status: :not_found if page.nil?
211
+
212
+ render json: page.merge(text: SecretScrubber.scrub(page[:text], @project.scrub_values))
213
+ end
214
+
215
+ # GET /api/projects/:id/synced_agents
216
+ # The checkout's own agents, as its running sandbox's MCP facade lists
217
+ # them (one run_<slug> tool each).
218
+ def synced_agents
219
+ agents = listed_synced_agents or return
220
+
221
+ render json: { synced_agents: agents, current: @project.settings["target_slug"] }
222
+ end
223
+
224
+ # PATCH /api/projects/:id/target { synced_agent: "slug" } or { app_assistant: true }
225
+ # Chooses the agent the project evaluates: one of the checkout's synced
226
+ # agents, which its running sandbox must list, or the App assistant.
227
+ def target
228
+ slug = params[:synced_agent]
229
+ if slug.present?
230
+ agents = listed_synced_agents or return
231
+ unless slug.is_a?(String) && agents.any? { |agent| agent[:slug] == slug }
232
+ return render json: { error: "#{slug.to_s.truncate(64)} is not an agent the project's sandbox serves",
233
+ synced_agents: agents }, status: :unprocessable_entity
234
+ end
235
+
236
+ @project.target_synced_agent!(slug)
237
+ elsif ActiveModel::Type::Boolean.new.cast(params[:app_assistant])
238
+ @project.ensure_app_assistant!
239
+ else
240
+ return render json: { error: "Name a synced_agent, or ask for the app_assistant" }, status: :bad_request
241
+ end
242
+
243
+ render json: { project: @project.reload.summary }
244
+ end
245
+
246
+ # POST /api/projects/:id/run_evaluation { confirm: true }
247
+ # Runs the project's evaluation against its sandbox and its browser. An
248
+ # expired sandbox is booted again first, and the run waits for it
249
+ # (ProjectEvaluationJob) rather than failing.
250
+ def run_evaluation
251
+ unless @project.target_agent && @project.evaluation
252
+ return render json: { error: "Choose the agent to evaluate first", code: "no_target" }, status: :conflict
253
+ end
254
+ return if execution_quota_denied?
255
+
256
+ sandbox, _booted = ensure_project_sandbox!
257
+ return if performed?
258
+
259
+ run = @project.evaluation.evaluation_runs.create!(status: :pending,
260
+ selection: { "project_id" => @project.id, "sandbox_id" => sandbox.session_id })
261
+ ProjectEvaluationJob.perform_later(@project.id, run.id, "#{request.base_url}#{request.script_name}")
262
+
263
+ render json: { project: @project.reload.summary, run: { id: run.id, status: run.status, evaluation_id: run.evaluation_id } },
264
+ status: :accepted
265
+ end
266
+
267
+ private
268
+
269
+ def set_project
270
+ @project = owned(Project).find(params[:id])
271
+ end
272
+
273
+ def capabilities_check
274
+ @capabilities_check ||= ProjectCapabilities.new(
275
+ owner: current_owner,
276
+ github_connected: owned(GithubConnection).exists?,
277
+ base_url: "#{request.base_url}#{request.script_name}"
278
+ )
279
+ end
280
+
281
+ # Renders a 402 and answers true when the host's quota denies a project.
282
+ def quota_denied?
283
+ denial = ActionAgent.quota_denial(current_owner, :project)
284
+ return false if denial.blank?
285
+
286
+ body = { error: "Plan limit reached", upgrade_required: true }
287
+ body = denial.is_a?(Hash) ? body.merge(denial) : body.merge(message: denial)
288
+ render json: body, status: :payment_required
289
+ true
290
+ end
291
+
292
+ # Renders a 402 and answers true when the host's quota denies an
293
+ # execution: a boot runs the repository's code.
294
+ def execution_quota_denied?
295
+ enforce_execution_quota!
296
+ performed?
297
+ end
298
+
299
+ def github_connection!
300
+ connection = owned(GithubConnection).first
301
+ return connection if connection
302
+
303
+ render json: { error: "Connect GitHub in Settings → Integrations first", code: "github_not_connected" },
304
+ status: :unprocessable_entity
305
+ nil
306
+ end
307
+
308
+ # The repository +name+ names, as the connection reaches it: from its
309
+ # selection, else asked of GitHub by name. Renders a 403 for a
310
+ # repository outside the selection when the caller may not manage the
311
+ # GitHub connection, and a 404 when GitHub finds none it can reach.
312
+ #
313
+ # The connection's token can be one member's, reaching that member's
314
+ # own repositories, so only the selection is shared with every member.
315
+ # The 403 is decided before GitHub is asked, so it says nothing about
316
+ # whether the repository exists.
317
+ def reachable_repository!(connection, name = params[:repository])
318
+ unless name.is_a?(String) && Project::REPOSITORY.match?(name)
319
+ render json: { error: "repository must be owner/name" }, status: :bad_request
320
+ return nil
321
+ end
322
+
323
+ selected = connection.repository(name)
324
+ return selected if selected
325
+
326
+ unless ActionAgent.permitted?(current_user, :manage_github, connection)
327
+ render json: { error: "#{name} is not one of the repositories selected in Settings → Integrations, and only " \
328
+ "someone who may manage the GitHub connection can pick another",
329
+ code: "forbidden", permission: :manage_github }, status: :forbidden
330
+ return nil
331
+ end
332
+
333
+ repository = connection.client.repository(name)
334
+ return repository if repository
335
+
336
+ render json: { error: "GitHub found no repository #{name} that this connection can reach", code: "repository_not_found" },
337
+ status: :not_found
338
+ nil
339
+ end
340
+
341
+ def ref_param
342
+ ref = params[:ref].presence || params[:default_ref].presence
343
+ ref.is_a?(String) ? ref : nil
344
+ end
345
+
346
+ # [ref, preflight report] for the default_ref +requested+ ("" for the
347
+ # repository's default branch), or nil when the project is on it
348
+ # already. Renders why the ref cannot be used, when it cannot.
349
+ def new_ref(requested)
350
+ return nil if requested == @project.default_ref.to_s
351
+ return nil unless authorize_secret_handover!(@project)
352
+
353
+ connection = github_connection! or return
354
+ repository = reachable_repository!(connection, @project.repository) or return
355
+ report = ProjectPreflight.call(connection.client, repository: @project.repository,
356
+ ref: requested.presence || repository["default_branch"])
357
+ if report["status"] == "unsupported"
358
+ render json: { error: report["summary"], code: "unsupported_repository", preflight: report },
359
+ status: :unprocessable_entity
360
+ return nil
361
+ end
362
+
363
+ [ requested.presence, report ]
364
+ end
365
+
366
+ def build_project(full_name, ref, report)
367
+ project = owned(Project).new(
368
+ name: params[:name].presence || full_name,
369
+ repository: full_name,
370
+ default_ref: ref,
371
+ start_url: params[:start_url].presence || "/",
372
+ install_state: report["engine"] ? "installed" : "detected",
373
+ settings: { "preflight" => report }
374
+ )
375
+ # Both columns, whichever owns the project: its sandbox and agent are
376
+ # booted for the user who created it.
377
+ project.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
378
+ project.account_id = current_account.id if current_account
379
+ project
380
+ end
381
+
382
+ # Answers whether the caller may set +secrets+ on the unsaved +project+;
383
+ # renders a 403 when not.
384
+ def authorize_secrets!(project, secrets)
385
+ secrets.all? { |attributes| authorize_secret!(secret_subject(project, attributes)) }
386
+ end
387
+
388
+ # The secret +attributes+ describe, for the permission checker only: it
389
+ # is not added to +project+'s secrets, which saving the project would
390
+ # save with it.
391
+ def secret_subject(project, attributes)
392
+ attributes = attributes.respond_to?(:permit) ? attributes.slice(:name, :source).permit(:name, :source).to_h : {}
393
+ name = attributes["name"].to_s
394
+ organization_key = attributes["source"].to_s == "organization_key"
395
+ ProjectSecret.new(project: project, account_id: project.account_id, user_id: project.user_id, name: name,
396
+ source: organization_key ? "organization_key" : "entered",
397
+ provider: organization_key ? ProjectSecret::ORGANIZATION_KEY_PROVIDERS[name] : nil)
398
+ end
399
+
400
+ def assign_secret_params(project, attributes)
401
+ keys = %i[name value source consent]
402
+ attributes = attributes.respond_to?(:permit) ? attributes.slice(*keys).permit(*keys).to_h : {}
403
+ project.assign_secret(
404
+ name: attributes["name"], value: attributes["value"], source: attributes["source"],
405
+ consent: ActiveModel::Type::Boolean.new.cast(attributes["consent"]) == true, set_by: current_user
406
+ )
407
+ end
408
+
409
+ def secret_summaries(project)
410
+ records = project.secrets.ordered.to_a
411
+ records.map { |secret| secret.as_summary(setters_for(records)) }
412
+ end
413
+
414
+ def setters_for(records)
415
+ @setters_for ||= begin
416
+ ids = records.filter_map(&:set_by_id).uniq
417
+ user_class = ActionAgent.user_class&.safe_constantize
418
+ ids.empty? || user_class.nil? ? {} : user_class.where(id: ids).index_by(&:id)
419
+ end
420
+ end
421
+
422
+ # [sandbox, whether it was booted now]. Counted as one execution when a
423
+ # new sandbox boots, as POST /api/sandboxes counts a checkout.
424
+ def ensure_project_sandbox!
425
+ before = @project.current_sandbox_session_id
426
+ sandbox = @project.ensure_sandbox!(confirm: ActiveModel::Type::Boolean.new.cast(params[:confirm]) == true,
427
+ confirmed_by: current_user)
428
+ booted = sandbox.id != before
429
+ record_execution_usage if booted
430
+ [ sandbox, booted ]
431
+ rescue ActiveRecord::RecordInvalid => e
432
+ raise unless e.record.is_a?(SandboxSession)
433
+
434
+ render json: { error: "The project's sandbox could not start: #{e.record.errors.full_messages.to_sentence}" },
435
+ status: :unprocessable_entity
436
+ nil
437
+ end
438
+
439
+ # The synced agents the project's running sandbox lists, or nil after
440
+ # rendering why none can be listed.
441
+ def listed_synced_agents
442
+ entry = @project.current_sandbox_session&.runtime_server_entry
443
+ if entry.nil?
444
+ render json: { error: "Boot the project first: its running sandbox lists the checkout's agents", code: "sandbox_not_ready" },
445
+ status: :conflict
446
+ return nil
447
+ end
448
+
449
+ tools = MCPClient.new(url: entry[:url], label: entry[:name], headers: entry[:headers] || {}).list_tools
450
+ Project.synced_agents(tools)
451
+ rescue MCPClient::Error => e
452
+ render json: { error: "The project's sandbox did not list its tools: #{SecretScrubber.scrub(e.message, @project.scrub_values)}" },
453
+ status: :bad_gateway
454
+ nil
455
+ end
456
+
457
+ # The modes the project's browser can start in on the configured
458
+ # backend: none when it runs no browsers, or cannot be loaded.
459
+ def browser_modes(orchestrator)
460
+ orchestrator.browser_modes.map(&:to_s)
461
+ rescue StandardError, LoadError
462
+ []
463
+ end
464
+
465
+ # The last LOG_TAIL_BYTES of the step that failed, else of the one
466
+ # running, else of the last that ran: { step:, text:, truncated: }.
467
+ def log_tail(orchestrator, sandbox, status)
468
+ return nil unless orchestrator.supports?(:boot_log)
469
+
470
+ steps = Array(status[:steps])
471
+ step = status[:failed_step].presence ||
472
+ steps.find { |entry| entry[:status] == "running" }&.dig(:name) ||
473
+ steps.reverse.find { |entry| %w[succeeded failed].include?(entry[:status]) }&.dig(:name)
474
+ return nil if step.blank?
475
+
476
+ secrets = @project.scrub_values
477
+ first = orchestrator.boot_log(sandbox, step: step, offset: 0, limit: 1, secrets: secrets) or return nil
478
+ offset = [ first[:size].to_i - LOG_TAIL_BYTES, 0 ].max
479
+ page = orchestrator.boot_log(sandbox, step: step, offset: offset, limit: LOG_TAIL_BYTES, secrets: secrets) or return nil
480
+ text = page[:text].to_s
481
+ # A tail that starts mid-file starts mid-line: drop the partial line.
482
+ text = text.split("\n", 2).last.to_s if offset.positive?
483
+ { step: step, text: SecretScrubber.scrub(text, secrets), truncated: offset.positive? }
484
+ rescue StandardError => e
485
+ Rails.logger.warn("[ActionAgent] project #{@project.id}: could not read the boot log: #{e.class}")
486
+ nil
487
+ end
488
+
489
+ def confirmation_required(exception)
490
+ render json: { error: exception.message, code: "confirmation_required", confirmation: exception.message },
491
+ status: :conflict
492
+ end
493
+
494
+ def boot_refused(exception)
495
+ render json: { error: exception.message, code: "boot_refused" }, status: :unprocessable_entity
496
+ end
497
+
498
+ def github_unauthorized
499
+ render json: { error: "GitHub rejected the stored token. Reconnect GitHub.", reconnect_required: true },
500
+ status: :unprocessable_entity
501
+ end
502
+
503
+ def github_not_found(exception)
504
+ render json: { error: exception.message, code: "not_found" }, status: :not_found
505
+ end
506
+
507
+ def github_unavailable(exception)
508
+ render json: { error: exception.message }, status: :bad_gateway
509
+ end
510
+ end
511
+ end
512
+ end