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