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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +152 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- 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
|