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,789 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module ActionAgent
6
+ # A repository the dashboard boots in a checkout sandbox and evaluates an
7
+ # agent against, without the repository having to install the engine first.
8
+ #
9
+ # A project keeps what every boot of the repository needs: the ref, the
10
+ # start URL the second readiness probe requests, and its ProjectSecrets.
11
+ # Each boot is a SandboxSession recorded as the project's current sandbox,
12
+ # booted from #boot_spec (a bootstrap when the checkout lacks the engine).
13
+ #
14
+ # The project's target agent is a dashboard Agent the project owns, and the
15
+ # project's evaluation belongs to it:
16
+ #
17
+ # installed repository a proxy for one of the checkout's own agents,
18
+ # which answers through that agent's run_<slug>
19
+ # tool on the sandbox's MCP facade
20
+ # any other repository the "App assistant", whose tools are everything
21
+ # the sandbox's facade serves
22
+ #
23
+ # Either way the agent's mcp_servers name the current sandbox, in place of
24
+ # any earlier sandbox, and keep the servers added in the agent editor.
25
+ #
26
+ # A failed boot can start the project's setup assistant (ProjectSetup),
27
+ # and the App assistant can be given schema tools over the models a boot
28
+ # lists (#choose_schema_tools!). The install pull request (see
29
+ # ProjectInstallPullRequest) publishes what the bootstrap wrote; while it
30
+ # is open every boot checks out its branch, and once it merges the
31
+ # repository counts as installed.
32
+ #
33
+ # The sandbox, the agent and the evaluation carry the project's own owner
34
+ # columns, whoever starts a boot, so the agent's runs always reach the
35
+ # project's sandbox (SandboxSession.runtime_server_entry looks among the
36
+ # agent owner's sessions).
37
+ class Project < ApplicationRecord
38
+ include Ownable
39
+ owned_by :account, :user
40
+
41
+ # Raised by #ensure_sandbox! for a first boot on the :local backend that
42
+ # nobody confirmed. Its message names the repository and the user the
43
+ # code would run as.
44
+ class ConfirmationRequired < StandardError; end
45
+ # Raised by #ensure_sandbox! when no sandbox can be booted.
46
+ class BootRefused < StandardError; end
47
+
48
+ INSTALL_STATES = %w[detected bootstrapped installed].freeze
49
+ REPOSITORY = %r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}
50
+ APP_ASSISTANT_NAME = "App assistant"
51
+ EXPLORER_NAME = "Explorer"
52
+ # The secrets the test account step keeps: the credentials the
53
+ # explorer's sign_in tool fills the login form with, and a browser's
54
+ # saved sign-in, which every browser of the project starts with.
55
+ SIGN_IN_SECRET = "APP_SIGN_IN"
56
+ STORAGE_STATE_SECRET = "APP_STORAGE_STATE"
57
+ # The provider the App assistant runs on, first configured wins.
58
+ ASSISTANT_PROVIDER_ORDER = %w[anthropic openai openrouter ollama].freeze
59
+ # A run_<slug> tool on a checkout's MCP facade: one synced agent. Its
60
+ # actions' tools are run_<slug>__<action>.
61
+ SYNCED_AGENT_TOOL = /\Arun_(?<slug>(?:(?!__)[a-z0-9_-])+)\z/
62
+
63
+ enum :status, { draft: "draft", booting: "booting", ready: "ready", failed: "failed" }
64
+
65
+ belongs_to :current_sandbox_session, class_name: "ActionAgent::SandboxSession", optional: true
66
+ belongs_to :target_agent, class_name: "ActionAgent::Agent", optional: true
67
+ belongs_to :evaluation, class_name: "ActionAgent::Evaluation", optional: true
68
+ has_many :secrets, class_name: "ActionAgent::ProjectSecret", dependent: :destroy
69
+ has_many :sandbox_sessions, class_name: "ActionAgent::SandboxSession", dependent: :nullify
70
+
71
+ validates :name, presence: true, length: { maximum: 100 }
72
+ validates :repository, presence: true, format: { with: REPOSITORY, message: "must be owner/name" }
73
+ validates :default_ref, length: { maximum: 255 }, format: { without: /\A-|\s|\.\./, message: "is not a valid git ref" },
74
+ allow_blank: true
75
+ validates :install_state, inclusion: { in: INSTALL_STATES }
76
+ validate :start_url_is_a_path
77
+ validate :name_unique_within_owner
78
+
79
+ scope :recent, -> { order(updated_at: :desc, id: :desc) }
80
+
81
+ # MySQL cannot give a JSON column a default, so an unset column reads nil.
82
+ def settings
83
+ super || {}
84
+ end
85
+
86
+ # The checkout's Gemfile.lock already bundles the engine.
87
+ def engine_installed?
88
+ install_state == "installed"
89
+ end
90
+
91
+ # How +sandbox+ boots:
92
+ #
93
+ # install branch a checkout of the open install pull request's branch
94
+ # runs the bootstrap's steps without installing the
95
+ # engine again (SandboxBootSpec.installed)
96
+ # otherwise a bootstrap for a checkout whose Gemfile.lock lacks
97
+ # the engine, and the checkout's own sandbox.yml boot
98
+ # otherwise (the spec applies "without_engine")
99
+ #
100
+ # Every boot writes the schema tools chosen for the App assistant
101
+ # (#schema_tools), a checkout booting from its own sandbox.yml after
102
+ # that file's setup. Either way the project's secrets reach the steps
103
+ # that run the repository's code, and a failed spec boot keeps its
104
+ # workspace for SandboxOrchestrator#resume_boot.
105
+ #
106
+ # Built in memory each time: the secrets' values are read here, from the
107
+ # encrypted column, and never stored with the boot.
108
+ #
109
+ # @raise [SandboxBootSpec::Invalid]
110
+ # @raise [ActiveRecord::RecordNotFound] when a secret uses an
111
+ # organization key that is no longer stored
112
+ def boot_spec(sandbox = current_sandbox_session)
113
+ steps = SandboxBootSpec.schema_tools_steps(settings.key?("schema_tools") ? schema_tools : nil)
114
+ options = { start_url: start_url, keep_on_failure: true, env: plain_environment, secrets: secret_environment, steps: steps }
115
+ return SandboxBootSpec.installed(**options) if sandbox&.repository_ref.present? && sandbox.repository_ref == install_branch
116
+
117
+ SandboxBootSpec.bootstrap(apply: "without_engine", **options)
118
+ end
119
+
120
+ # The ref a new sandbox checks out: the branch of the open install pull
121
+ # request, else default_ref (nil for the repository's default branch).
122
+ def checkout_ref
123
+ install_branch || default_ref.presence
124
+ end
125
+
126
+ # The branch of the install pull request while it is open, or nil.
127
+ def install_branch
128
+ record = install_pull_request
129
+ return nil unless record&.head_commit.present? && record.state.in?([ nil, "open" ])
130
+
131
+ record.branch
132
+ end
133
+
134
+ # The DraftPullRequest that installs the engine in the repository, once
135
+ # one was published.
136
+ def install_pull_request
137
+ id = settings["install_pull_request_id"]
138
+ id && DraftPullRequest.find_by(id: id)
139
+ end
140
+
141
+ # Called once the install pull request is merged: the branch it merged
142
+ # into bundles the engine now, so later boots check that out and install
143
+ # nothing.
144
+ def install_merged!
145
+ changed = with_lock do
146
+ next false if engine_installed?
147
+
148
+ update!(install_state: "installed", settings: settings.merge("install_merged_at" => Time.current.iso8601))
149
+ true
150
+ end
151
+ LiveUpdates.broadcast(stream_name, type: "project", id: id, status: status) if changed
152
+ changed
153
+ end
154
+
155
+ # The models and columns the App assistant may read, as chosen with
156
+ # #choose_schema_tools!: [{ "model" =>, "filterable" =>, "returns" => }].
157
+ def schema_tools
158
+ Array(settings["schema_tools"]).select { |choice| choice.is_a?(Hash) }
159
+ end
160
+
161
+ # Every model #choose_schema_tools! was ever given, the ones chosen now
162
+ # among them: their tools files are the ones a boot may have written or
163
+ # removed.
164
+ def schema_tools_models
165
+ Array(settings["schema_tools_models"]).map(&:to_s) | schema_tools.map { |choice| choice["model"].to_s }
166
+ end
167
+
168
+ # The app's models as the last boot's manifest listed them
169
+ # (SandboxManifest.app_models), or nil before a boot listed any.
170
+ def app_models
171
+ settings["app_models"].is_a?(Array) ? settings["app_models"] : nil
172
+ end
173
+
174
+ # Stores which models and columns the App assistant may read. Each boot
175
+ # after this writes app/agent_tools/<model>_tools.rb for them, exposing
176
+ # only those columns, and removes the files it wrote for models no
177
+ # longer chosen (SandboxBootSpec.schema_tools_steps). The assistant's
178
+ # sandbox server then lists their tools. A model with no column chosen
179
+ # is dropped, and an empty list removes every choice.
180
+ #
181
+ # @param choices [Array<Hash>] [{ model:, filterable: [], returns: [] }]
182
+ # @raise [SandboxBootSpec::Invalid] for a model or column a boot did not
183
+ # list, or choices SandboxBootSpec.schema_tools_steps refuses
184
+ def choose_schema_tools!(choices)
185
+ known = app_models or raise SandboxBootSpec::Invalid, "Boot the project first: its models are listed by a boot"
186
+
187
+ columns_of = known.to_h { |model| [ model["name"], Array(model["columns"]).map { |column| column["name"] } ] }
188
+ normalized = Array(choices).map do |choice|
189
+ choice = choice.to_h.stringify_keys
190
+ model = choice["model"].to_s
191
+ raise SandboxBootSpec::Invalid, "#{model.truncate(60).inspect} is not one of the app's models" unless columns_of.key?(model)
192
+
193
+ entry = { "model" => model }
194
+ %w[filterable returns].each do |option|
195
+ picked = Array(choice[option]).map(&:to_s).uniq
196
+ unknown = picked - columns_of[model]
197
+ raise SandboxBootSpec::Invalid, "#{model} has no column #{unknown.first.truncate(60)} a tool may read" if unknown.any?
198
+
199
+ entry[option] = picked
200
+ end
201
+ entry
202
+ end
203
+ normalized.uniq! { |entry| entry["model"] }
204
+ normalized.reject! { |entry| entry["filterable"].empty? && entry["returns"].empty? }
205
+ SandboxBootSpec.schema_tools_steps(normalized)
206
+ models = (schema_tools_models | normalized.map { |entry| entry["model"] }).last(SandboxManifest::MAX_MODELS)
207
+ update!(settings: settings.merge("schema_tools" => normalized, "schema_tools_models" => models))
208
+ end
209
+
210
+ # The setup assistant's state (see ProjectSetup):
211
+ #
212
+ # agent_id its dashboard Agent
213
+ # run_ids the setup runs it started, newest last
214
+ # last_run_id the latest of them
215
+ # retried_run_ids the runs that retried the boot
216
+ # attempts automatic runs (and Claude Code boot fixes) since
217
+ # the last good boot
218
+ # auto false when a failed boot starts no run on its own
219
+ # claude_code the Claude Code boot fixes (see ProjectBootFix)
220
+ def setup_settings
221
+ settings["setup"].is_a?(Hash) ? settings["setup"] : {}
222
+ end
223
+
224
+ def update_setup_settings!(values)
225
+ update!(settings: settings.merge("setup" => setup_settings.merge(values.stringify_keys)))
226
+ end
227
+
228
+ # Whether a failed boot starts the setup assistant on its own.
229
+ def auto_setup?
230
+ setup_settings["auto"] != false
231
+ end
232
+
233
+ def setup_agent
234
+ id = setup_settings["agent_id"]
235
+ id && Agent.find_by(id: id)
236
+ end
237
+
238
+ # The requests for input of runs of the project's setup assistant and of
239
+ # the agent it evaluates.
240
+ #
241
+ # @return [ActiveRecord::Relation<InputRequest>]
242
+ def input_requests
243
+ agent_ids = [ setup_settings["agent_id"], target_agent_id ].compact
244
+ return InputRequest.none if agent_ids.empty?
245
+
246
+ InputRequest.where(subject_type: AgentRun.polymorphic_name, subject_id: AgentRun.where(agent_id: agent_ids).select(:id))
247
+ end
248
+
249
+ # #input_requests still waiting for a person and not past their expiry.
250
+ def pending_input_requests
251
+ expires_at = InputRequest.arel_table[:expires_at]
252
+ input_requests.pending.where(expires_at.eq(nil).or(expires_at.gt(Time.current)))
253
+ end
254
+
255
+ # { name => value } for every env secret, organization keys resolved, but
256
+ # the values the setup assistant set, which are not secret. Sign-in
257
+ # secrets never reach the sandbox's environment.
258
+ def secret_environment
259
+ secrets.env.ordered.reject(&:plain?).to_h { |secret| [ secret.name, secret.resolved_value.to_s ] }
260
+ end
261
+
262
+ # { name => value } for the values the setup assistant set (see
263
+ # ProjectSecret), which are not secret, so a boot leaves them unmasked.
264
+ def plain_environment
265
+ secrets.env.ordered.select(&:plain?).to_h { |secret| [ secret.name, secret.value.to_s ] }
266
+ end
267
+
268
+ # The project's secret +name+ set as asked, unsaved: a new secret, or the
269
+ # existing one with its value or source replaced. +set_by+ is recorded
270
+ # as who set it.
271
+ #
272
+ # @param source [String] "entered" (with +value+), or "organization_key"
273
+ # (with +consent+, see ProjectSecret)
274
+ # @return [ProjectSecret]
275
+ def assign_secret(name:, value: nil, source: nil, consent: false, set_by: nil)
276
+ secret = secrets.find_or_initialize_by(name: name.to_s)
277
+ secret.kind = "env"
278
+ secret.account_id = account_id
279
+ secret.user_id = user_id
280
+ secret.set_by_id = set_by.try(:id)
281
+ if source.to_s == "organization_key"
282
+ secret.assign_attributes(source: "organization_key", provider: ProjectSecret::ORGANIZATION_KEY_PROVIDERS[name.to_s],
283
+ value: nil, consented_at: consent ? Time.current : nil)
284
+ else
285
+ text = value.is_a?(String) || value.is_a?(Numeric) ? value.to_s : nil
286
+ secret.assign_attributes(source: source.presence&.to_s || "entered", provider: nil, consented_at: nil, value: text)
287
+ end
288
+ secret
289
+ end
290
+
291
+ # The project's SIGN_IN_SECRET set to +credentials+ (see
292
+ # ProjectSecret#sign_in_credentials), unsaved. Blank fields are left
293
+ # out, and every field but the password is stripped. A blank password
294
+ # keeps the one already saved.
295
+ #
296
+ # @return [ProjectSecret]
297
+ def assign_sign_in(credentials, set_by: nil)
298
+ fields = credentials.to_h.stringify_keys.slice(*ProjectSecret::SIGN_IN_FIELDS)
299
+ .to_h { |name, field| [ name, field.is_a?(String) && name != "password" ? field.strip : field ] }.compact_blank
300
+ saved = secrets.sign_in.find_by(name: SIGN_IN_SECRET)&.sign_in_credentials&.dig("password")
301
+ fields["password"] = saved if fields["password"].blank? && saved.present?
302
+ assign_engine_secret(SIGN_IN_SECRET, "sign_in", fields.to_json, set_by)
303
+ end
304
+
305
+ # The project's STORAGE_STATE_SECRET set to +state+, a Playwright storage
306
+ # state, unsaved.
307
+ #
308
+ # @return [ProjectSecret]
309
+ def assign_storage_state(state, set_by: nil)
310
+ assign_engine_secret(STORAGE_STATE_SECRET, "storage_state", state.to_json, set_by)
311
+ end
312
+
313
+ # The names of the project's sign_in secrets, which the explorer's
314
+ # sign_in tool takes as its secret_ref.
315
+ def sign_in_secret_names
316
+ secrets.sign_in.ordered.pluck(:name)
317
+ end
318
+
319
+ # The saved sign-in the project's browsers start with, or nil.
320
+ #
321
+ # @return [Hash, nil]
322
+ def saved_storage_state
323
+ secrets.storage_state.ordered.first&.storage_state_value
324
+ end
325
+
326
+ # What the project's sandboxes' output is scrubbed of: each secret's
327
+ # value and its URL-encoded and Base64 forms, and the parts of a sign-in
328
+ # secret apart (ProjectSecret#scrub_parts). A secret whose organization
329
+ # key is gone has no value to mask.
330
+ #
331
+ # @return [Array<String>]
332
+ def scrub_values
333
+ SecretScrubber.with_encodings(secrets.reject(&:plain?).flat_map(&:scrub_parts))
334
+ end
335
+
336
+ # The sandbox to run against: the current one while it is booting or
337
+ # serving, the current one resumed when its failed boot was kept, or a
338
+ # new one booted from #boot_spec.
339
+ #
340
+ # On the :local backend the first boot runs the repository's code as the
341
+ # dashboard's own user, so it needs +confirm+ once (see
342
+ # #local_boot_confirmation).
343
+ #
344
+ # @param confirm [Boolean] whether the caller confirmed a first :local boot
345
+ # @param confirmed_by [Object, nil] the user who confirmed it
346
+ # @param resume_from [String, nil] the step a kept boot resumes from,
347
+ # instead of the one that failed
348
+ # @return [SandboxSession]
349
+ # @raise [ConfirmationRequired]
350
+ # @raise [BootRefused]
351
+ # @raise [ActiveRecord::RecordInvalid] when the repository is no longer
352
+ # available to the owner's GitHub connection
353
+ def ensure_sandbox!(confirm: false, confirmed_by: nil, orchestrator: SandboxOrchestrator.new, resume_from: nil)
354
+ # Decided under the project's row lock, so two requests at once agree
355
+ # on one sandbox. The boot is enqueued after the lock is released.
356
+ sandbox, previous, action = with_lock do
357
+ current = current_sandbox_session
358
+ next [ current, nil, :live ] if current&.active?
359
+
360
+ if current && resumable?(current, orchestrator)
361
+ update!(status: "booting")
362
+ next [ current, nil, :resume ]
363
+ end
364
+
365
+ unless orchestrator.accepts_boot_config?
366
+ raise BootRefused, "The #{orchestrator.backend_name} sandbox backend cannot boot a project: it takes no boot spec"
367
+ end
368
+ confirm_local_boot!(orchestrator, confirm: confirm, confirmed_by: confirmed_by)
369
+ [ create_sandbox!, current, :boot ]
370
+ end
371
+
372
+ case action
373
+ when :live then sandbox
374
+ when :resume
375
+ return ensure_sandbox!(confirm: confirm, confirmed_by: confirmed_by, orchestrator: orchestrator) unless sandbox.resume_boot!(from: resume_from)
376
+
377
+ LiveUpdates.broadcast(stream_name, type: "project", id: id, status: status)
378
+ sandbox
379
+ else
380
+ previous.expire! if previous && !previous.expired?
381
+ sandbox.provision!
382
+ LiveUpdates.broadcast(stream_name, type: "project", id: id, status: status)
383
+ sandbox
384
+ end
385
+ end
386
+
387
+ # What a person has to confirm before the first boot on +orchestrator+,
388
+ # or nil when nothing is asked: "This runs <owner/repo>'s code on this
389
+ # machine as <user>."
390
+ def local_boot_confirmation(orchestrator = SandboxOrchestrator.new)
391
+ return nil unless orchestrator.local?
392
+ return nil if settings["local_boot_confirmed_at"].present?
393
+
394
+ "This runs #{repository}'s code on this machine as #{self.class.machine_user}."
395
+ end
396
+
397
+ def self.machine_user
398
+ Etc.getpwuid(Process.uid)&.name || ENV["USER"].presence || "the dashboard's user"
399
+ rescue StandardError
400
+ ENV["USER"].presence || "the dashboard's user"
401
+ end
402
+
403
+ # Deletes the project with its target agent (and so its evaluation), its
404
+ # explorer agent, its explorations and its setup assistant, and stops its
405
+ # current sandbox.
406
+ def discard!
407
+ sandbox = current_sandbox_session
408
+ transaction do
409
+ agents = [ target_agent, setup_agent ].compact
410
+ update_columns(target_agent_id: nil, evaluation_id: nil)
411
+ agents.each(&:destroy!)
412
+ explorer_agent&.destroy!
413
+ # A host that upgraded the engine before migrating has no table yet.
414
+ Exploration.where(project_id: id).delete_all if Exploration.table_exists?
415
+ destroy!
416
+ end
417
+ sandbox.expire! if sandbox && !sandbox.expired?
418
+ end
419
+
420
+ # Moves the project to +ref+ (nil for the repository's default branch),
421
+ # which +preflight+ is ProjectPreflight's report on, saving any other
422
+ # attribute assigned with it. The sandbox booted from the old ref is
423
+ # stopped, so the next boot checks the new one out, and a ref whose lock
424
+ # lacks the engine is evaluated with the App assistant.
425
+ def change_ref!(ref, preflight)
426
+ sandbox = current_sandbox_session
427
+ transaction do
428
+ update!(default_ref: ref, status: "draft", install_state: preflight["engine"] ? "installed" : "detected",
429
+ settings: settings.merge("preflight" => preflight))
430
+ ensure_app_assistant! unless engine_installed? || app_assistant?
431
+ end
432
+ sandbox.expire! if sandbox && !sandbox.expired?
433
+ LiveUpdates.broadcast(stream_name, type: "project", id: id, status: status)
434
+ end
435
+
436
+ # Called by SandboxProvisionJob once +sandbox+ is serving, with the
437
+ # models its manifest listed when the backend reports them. Ignored for
438
+ # a sandbox that is no longer the current one.
439
+ def sandbox_ready!(sandbox, app_models: nil)
440
+ settle!(sandbox) do
441
+ attributes = { status: "ready" }
442
+ attributes[:install_state] = "bootstrapped" if install_state == "detected"
443
+ next_settings = settings.merge("setup" => setup_settings.merge("attempts" => 0))
444
+ next_settings["app_models"] = app_models if app_models.is_a?(Array)
445
+ attributes.merge(settings: next_settings)
446
+ end
447
+ end
448
+
449
+ # Called by SandboxProvisionJob when +sandbox+'s boot failed. Starts
450
+ # Claude Code or the setup assistant on it in the background, unless that
451
+ # is switched off (see ProjectSetupJob).
452
+ def sandbox_failed!(sandbox)
453
+ changed = settle!(sandbox) { { status: "failed" } }
454
+ ProjectSetupJob.perform_later(id, sandbox.id) if changed && auto_setup?
455
+ changed
456
+ end
457
+
458
+ # The project's evaluation on +agent+: the one it has, or a new one when
459
+ # the target agent changed or the evaluation was deleted.
460
+ #
461
+ # @return [Evaluation]
462
+ def scenario_evaluation!(agent = target_agent)
463
+ ensure_evaluation!(agent)
464
+ end
465
+
466
+ # The project whose target agent +evaluation+ belongs to, or nil. The
467
+ # agent carries the project's owner columns, so they must match too, and
468
+ # another owner's project is never returned.
469
+ #
470
+ # @return [Project, nil]
471
+ def self.for_evaluation(evaluation)
472
+ agent = evaluation&.agent or return nil
473
+
474
+ find_by(target_agent_id: agent.id, account_id: agent.account_id, user_id: agent.user_id)
475
+ end
476
+
477
+ # Whether the target agent is the App assistant, as opposed to a proxy
478
+ # for a synced agent.
479
+ def app_assistant?
480
+ target_agent.present? && settings["target_slug"].blank?
481
+ end
482
+
483
+ # Makes the App assistant the project's target agent, creating it and the
484
+ # project's evaluation when missing.
485
+ #
486
+ # @return [Agent]
487
+ def ensure_app_assistant!
488
+ provider, model = self.class.assistant_model(owner)
489
+ assign_target!(
490
+ name: "#{APP_ASSISTANT_NAME} for #{repository}".truncate(100),
491
+ description: "Answers questions about #{repository} with the tools its sandbox serves.",
492
+ instructions: <<~TEXT,
493
+ You are the assistant for #{repository}, a Rails application running in a sandbox.
494
+ Answer by calling the application's tools. Never guess at data a tool can look up, and
495
+ say so when no tool can answer the question.
496
+ TEXT
497
+ provider: provider, model: model, slug: nil, tools: nil
498
+ )
499
+ end
500
+
501
+ # The agent the engine's explorer runs as (ExplorerExecutionService), or
502
+ # nil before the project's first exploration.
503
+ #
504
+ # @return [Agent, nil]
505
+ def explorer_agent
506
+ id = settings["explorer_agent_id"]
507
+ id && Agent.find_by(id: id, account_id: account_id, user_id: user_id)
508
+ end
509
+
510
+ # The explorer agent, created on the provider and model the project's
511
+ # agents run on when the project has none. It carries the project's
512
+ # owner columns, so its runs reach the project's sandbox browser. Its
513
+ # instructions are composed for each exploration, so the stored ones
514
+ # only describe it.
515
+ #
516
+ # @return [Agent]
517
+ def explorer_agent!
518
+ with_lock do
519
+ existing = explorer_agent
520
+ next existing if existing
521
+
522
+ provider, model = self.class.assistant_model(owner)
523
+ agent = Agent.create!(
524
+ name: "#{EXPLORER_NAME} for #{repository}".truncate(100), user_id: user_id, account_id: account_id, status: :active,
525
+ description: "Walks #{repository}'s running app in its sandbox browser and proposes evaluation scenarios.",
526
+ instructions: "Explores #{repository} for its explorations. Its instructions are composed for each exploration.",
527
+ provider: provider, model: model, mcp_servers: []
528
+ )
529
+ update!(settings: settings.merge("explorer_agent_id" => agent.id))
530
+ agent
531
+ end
532
+ end
533
+
534
+ # Makes a proxy for the checkout's agent +slug+ the project's target
535
+ # agent: it answers by calling run_<slug> and nothing else.
536
+ #
537
+ # @return [Agent]
538
+ def target_synced_agent!(slug)
539
+ provider, model = self.class.assistant_model(owner)
540
+ assign_target!(
541
+ name: "#{repository} #{slug} (sandbox)".truncate(100),
542
+ description: "Evaluates #{repository}'s #{slug} agent through its sandbox.",
543
+ instructions: <<~TEXT,
544
+ Answer every request by calling the `run_#{slug}` tool with the request as the message,
545
+ then reply with what it returned. Do not answer from your own knowledge.
546
+ TEXT
547
+ provider: provider, model: model, slug: slug, tools: [ "run_#{slug}" ]
548
+ )
549
+ end
550
+
551
+ # The synced agents +tools+ (a facade's tools/list) offer, one per
552
+ # run_<slug> tool: [{ slug:, tool:, description: }].
553
+ def self.synced_agents(tools)
554
+ Array(tools).filter_map do |tool|
555
+ name = (tool[:name] || tool["name"]).to_s
556
+ match = SYNCED_AGENT_TOOL.match(name)
557
+ next unless match
558
+
559
+ { slug: match[:slug], tool: name, description: (tool[:description] || tool["description"]).to_s.truncate(300) }
560
+ end
561
+ end
562
+
563
+ # The provider and model the project's agents run on: the first provider
564
+ # in ASSISTANT_PROVIDER_ORDER with credentials for +owner+, and its
565
+ # default model. With none configured, the first whose client gem is
566
+ # installed, so the agent can be saved and run once a key is added.
567
+ #
568
+ # @return [Array(String, String)]
569
+ def self.assistant_model(owner)
570
+ providers = begin
571
+ DashboardAssistantService.new(owner: owner).configuration[:providers]
572
+ rescue StandardError
573
+ []
574
+ end
575
+ chosen = ASSISTANT_PROVIDER_ORDER.find { |id| providers.any? { |entry| entry[:id] == id && entry[:configured] } }
576
+ chosen ||= ASSISTANT_PROVIDER_ORDER.find { |id| provider_client_installed?(id) } || ASSISTANT_PROVIDER_ORDER.first
577
+ [ chosen, DashboardAssistantService::DEFAULT_MODELS.fetch(chosen) ]
578
+ end
579
+
580
+ # Whether +provider+'s client gem loads, as Agent requires before saving
581
+ # an agent on it.
582
+ def self.provider_client_installed?(provider)
583
+ service = ActiveAgent::Base.provider_config_load(provider)[:service] || provider.camelize
584
+ ActiveAgent::Base.provider_load(service)
585
+ true
586
+ rescue LoadError, StandardError
587
+ false
588
+ end
589
+
590
+ # The sandbox's state as the project page shows it: "none", "booting",
591
+ # "ready", "failed" or "expired".
592
+ def sandbox_state
593
+ sandbox = current_sandbox_session
594
+ return "none" if sandbox.nil?
595
+ return "expired" if sandbox.expired? || sandbox.completed? || sandbox.past_expiry?
596
+ return "failed" if sandbox.failed?
597
+ return "ready" if sandbox.ready? || sandbox.running?
598
+
599
+ "booting"
600
+ end
601
+
602
+ def summary
603
+ sandbox = current_sandbox_session
604
+ {
605
+ id: id,
606
+ name: name,
607
+ repository: repository,
608
+ default_ref: default_ref,
609
+ start_url: start_url,
610
+ status: status,
611
+ install_state: install_state,
612
+ sandbox_state: sandbox_state,
613
+ sandbox: sandbox && sandbox_summary(sandbox),
614
+ target_agent: target_agent && { id: target_agent.id, name: target_agent.name, slug: target_agent.slug,
615
+ kind: app_assistant? ? "app_assistant" : "synced_agent",
616
+ synced_agent: settings["target_slug"] },
617
+ evaluation: evaluation && { id: evaluation.id, name: evaluation.name },
618
+ preflight: settings["preflight"],
619
+ local_boot_confirmed_at: settings["local_boot_confirmed_at"],
620
+ secret_count: secrets.count(&:env?),
621
+ checkout_ref: checkout_ref,
622
+ setup: setup_summary,
623
+ pending_input_requests: pending_input_requests.count,
624
+ schema_tools: schema_tools,
625
+ app_models_listed: !app_models.nil?,
626
+ install_pull_request: install_pull_request&.summary&.slice(:id, :branch, :base_branch, :number, :url, :state, :draft, :status,
627
+ :error_code, :error_message, :compare_url, :updated_at),
628
+ created_at: created_at&.iso8601,
629
+ updated_at: updated_at&.iso8601
630
+ }
631
+ end
632
+
633
+ private
634
+
635
+ def setup_summary
636
+ availability = ProjectSetup.availability(self)
637
+ last_run = setup_settings["last_run_id"] && AgentRun.find_by(id: setup_settings["last_run_id"])
638
+ {
639
+ available: availability[:available],
640
+ reason: availability[:reason],
641
+ auto: auto_setup?,
642
+ agent_id: setup_settings["agent_id"],
643
+ attempts: setup_settings["attempts"].to_i,
644
+ last_run: last_run && { id: last_run.id, status: last_run.status, created_at: last_run.created_at&.iso8601 },
645
+ claude_code: ProjectBootFix.summary(self)
646
+ }
647
+ end
648
+
649
+ # The sandbox's summary. Its error was scrubbed when it was stored; it is
650
+ # scrubbed again here, against the secrets as they are now.
651
+ def sandbox_summary(sandbox)
652
+ summary = sandbox.summary
653
+ summary[:error_message].present? ? SecretScrubber.scrub(summary, scrub_values) : summary
654
+ end
655
+
656
+ def resumable?(sandbox, orchestrator)
657
+ return false unless sandbox.failed? && !sandbox.past_expiry?
658
+ return false unless orchestrator.supports?(:resume_boot) && orchestrator.supports?(:boot_status)
659
+
660
+ orchestrator.boot_status(sandbox)&.dig(:kept) == true
661
+ rescue StandardError => e
662
+ Rails.logger.warn("[ActionAgent] project #{id}: could not read sandbox #{sandbox.session_id}'s boot: #{e.message}")
663
+ false
664
+ end
665
+
666
+ def confirm_local_boot!(orchestrator, confirm:, confirmed_by:)
667
+ question = local_boot_confirmation(orchestrator)
668
+ return if question.nil?
669
+ raise ConfirmationRequired, question unless confirm
670
+
671
+ update!(settings: settings.merge("local_boot_confirmed_at" => Time.current.iso8601,
672
+ "local_boot_confirmed_by" => confirmed_by&.id))
673
+ end
674
+
675
+ # A new sandbox for the project, made current and named in the target
676
+ # agent's mcp_servers. Not booted yet: #ensure_sandbox! provisions it.
677
+ def create_sandbox!
678
+ sandbox = SandboxSession.new(sandbox_type: "app_runtime", repository: repository, repository_ref: checkout_ref)
679
+ sandbox.project_id = id
680
+ sandbox.user_id = user_id if sandbox.has_attribute?(:user_id)
681
+ sandbox.account_id = account_id if sandbox.has_attribute?(:account_id)
682
+ sandbox.save!
683
+ update!(current_sandbox_session: sandbox, status: "booting")
684
+ point_target_at!(sandbox)
685
+ sandbox
686
+ end
687
+
688
+ def stream_name
689
+ "project_#{id}"
690
+ end
691
+
692
+ def assign_engine_secret(name, kind, value, set_by)
693
+ secret = secrets.find_or_initialize_by(name: name)
694
+ secret.assign_attributes(kind: kind, source: "entered", provider: nil, consented_at: nil, value: value,
695
+ account_id: account_id, user_id: user_id, set_by_id: set_by.try(:id))
696
+ secret
697
+ end
698
+
699
+ def settle!(sandbox)
700
+ changed = with_lock do
701
+ next false unless current_sandbox_session_id == sandbox.id
702
+
703
+ update!(yield)
704
+ true
705
+ end
706
+ LiveUpdates.broadcast(stream_name, type: "project", id: id, status: status) if changed
707
+ changed
708
+ end
709
+
710
+ # Creates or updates the project's one agent, and creates the project's
711
+ # evaluation on it when there is none. Choosing the target the agent
712
+ # already has keeps its name, description and instructions as edited.
713
+ def assign_target!(name:, description:, instructions:, provider:, model:, slug:, tools:)
714
+ transaction do
715
+ agent = target_agent || Agent.new(user_id: user_id, account_id: account_id, status: :active)
716
+ if agent.new_record? || settings["target_slug"] != slug
717
+ agent.assign_attributes(name: name, description: description, instructions: instructions)
718
+ end
719
+ agent.mcp_servers = servers_with_sandbox(agent, current_sandbox_session, tools: tools)
720
+ if agent.new_record?
721
+ agent.provider = provider
722
+ agent.model = model
723
+ end
724
+ agent.save!
725
+
726
+ next_settings = settings.merge("target_slug" => slug)
727
+ update!(target_agent: agent, settings: next_settings)
728
+ ensure_evaluation!(agent)
729
+ agent
730
+ end
731
+ end
732
+
733
+ def ensure_evaluation!(agent)
734
+ return evaluation if evaluation && evaluation.agent_id == agent.id
735
+
736
+ created = agent.evaluations.create!(
737
+ name: unique_evaluation_name(agent),
738
+ judge_kind: "rules",
739
+ sample_size: 20,
740
+ criteria: [ { "key" => "answered", "type" => "response_present", "config" => {} } ],
741
+ config: { "project_id" => id }
742
+ )
743
+ update!(evaluation: created)
744
+ created
745
+ end
746
+
747
+ def unique_evaluation_name(agent)
748
+ base = "#{name} evaluation".truncate(90)
749
+ taken = agent.evaluations.where("name LIKE ?", "#{base}%").pluck(:name).to_set
750
+ return base unless taken.include?(base)
751
+
752
+ (2..).lazy.map { |n| "#{base} #{n}" }.find { |candidate| !taken.include?(candidate) }
753
+ end
754
+
755
+ # Points the target agent's sandbox entry at +sandbox+, keeping a synced
756
+ # agent's one-tool allow-list.
757
+ def point_target_at!(sandbox)
758
+ return if target_agent.nil?
759
+
760
+ slug = settings["target_slug"]
761
+ target_agent.update!(mcp_servers: servers_with_sandbox(target_agent, sandbox, tools: slug.present? ? [ "run_#{slug}" ] : nil))
762
+ end
763
+
764
+ # +agent+'s mcp_servers with every checkout sandbox's entry replaced by
765
+ # +sandbox+'s, allowing only +tools+ when given. Other servers are kept.
766
+ def servers_with_sandbox(agent, sandbox, tools:)
767
+ kept = Array(agent.mcp_servers).reject do |entry|
768
+ SandboxSession.runtime_server_key?(entry.is_a?(Hash) ? entry["key"] || entry[:key] : entry)
769
+ end
770
+ return kept if sandbox.nil?
771
+
772
+ entry = { "key" => sandbox.runtime_server_key, "name" => "#{repository} (sandbox)" }
773
+ entry["tools"] = tools if tools
774
+ [ entry, *kept ]
775
+ end
776
+
777
+ def start_url_is_a_path
778
+ errors.add(:start_url, "must be a path on the app, such as /") unless SandboxBootSpec.start_url_path?(start_url.to_s)
779
+ end
780
+
781
+ def name_unique_within_owner
782
+ return if name.blank?
783
+
784
+ siblings = self.class.for_owner(owner)
785
+ siblings = siblings.where.not(id: id) if persisted?
786
+ errors.add(:name, "has already been taken") if siblings.exists?(name: name)
787
+ end
788
+ end
789
+ end