actionagent 1.8.0 → 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 +71 -12
- 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 +58 -7
- 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 +148 -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 +627 -20
- 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 +39 -10
- 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 +72 -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 +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- 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/model_pricing.rb +214 -34
- 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 +207 -4
- 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 +300 -16
- 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/evaluation_serializer.rb +41 -10
- 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_scorecard.rb +50 -21
- 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/codex_session_events.rb +37 -0
- 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_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- 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 +1263 -102
- 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 +290 -23
- 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 +121 -17
- 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 +153 -2
- 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 +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -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 +100 -2
|
@@ -7,6 +7,7 @@ module ActionAgent
|
|
|
7
7
|
|
|
8
8
|
# GET /api/runs/:id
|
|
9
9
|
def show
|
|
10
|
+
@run.expire_overdue_input_requests!
|
|
10
11
|
render json: {
|
|
11
12
|
run: run_json(@run),
|
|
12
13
|
messages: interaction_messages(@run),
|
|
@@ -28,7 +29,7 @@ module ActionAgent
|
|
|
28
29
|
def index
|
|
29
30
|
# Scoped to the caller's own agents: this listed (and counted) every
|
|
30
31
|
# run in the database regardless of who owned it.
|
|
31
|
-
scope = AgentRun.includes(:agent).with_attachments.where(agent: owner_agents).recent
|
|
32
|
+
scope = AgentRun.includes(:agent, :pending_input_requests).with_attachments.where(agent: owner_agents).recent
|
|
32
33
|
|
|
33
34
|
scope = scope.where(agent_id: params[:agent_id]) if params[:agent_id].present?
|
|
34
35
|
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
@@ -124,6 +125,7 @@ module ActionAgent
|
|
|
124
125
|
output_tokens: run.output_tokens,
|
|
125
126
|
total_tokens: run.total_tokens,
|
|
126
127
|
error_message: run.error_message,
|
|
128
|
+
input_requests: run.pending_input_requests.map { |request| InputRequestSerializer.call(request) },
|
|
127
129
|
trace_id: run.trace_id,
|
|
128
130
|
attachments: run.attachment_manifest,
|
|
129
131
|
context_id: run.context_id,
|
|
@@ -15,8 +15,15 @@ module ActionAgent
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# POST /api/api_keys
|
|
18
|
+
#
|
|
19
|
+
# The key records the user who created it, so a call made with an
|
|
20
|
+
# account's key acts as that user rather than as the whole account.
|
|
18
21
|
def create
|
|
19
|
-
api_key = owned(ApiKey).
|
|
22
|
+
api_key = owned(ApiKey).new(name: params.require(:name))
|
|
23
|
+
api_key.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
|
|
24
|
+
return unless authorize_action!(:manage_api_keys, api_key)
|
|
25
|
+
|
|
26
|
+
api_key.save!
|
|
20
27
|
|
|
21
28
|
render json: {
|
|
22
29
|
api_key: serialize(api_key).merge(token: api_key.token)
|
|
@@ -25,7 +32,10 @@ module ActionAgent
|
|
|
25
32
|
|
|
26
33
|
# DELETE /api/api_keys/:id
|
|
27
34
|
def destroy
|
|
28
|
-
owned(ApiKey).find(params[:id])
|
|
35
|
+
api_key = owned(ApiKey).find(params[:id])
|
|
36
|
+
return unless authorize_action!(:manage_api_keys, api_key)
|
|
37
|
+
|
|
38
|
+
api_key.destroy!
|
|
29
39
|
head :no_content
|
|
30
40
|
end
|
|
31
41
|
|
|
@@ -17,7 +17,8 @@ module ActionAgent
|
|
|
17
17
|
# the page's CSRF token with every mutating request (frontend
|
|
18
18
|
# utils/apiFetch.mjs). Endpoints that authenticate with a bearer token
|
|
19
19
|
# instead — the telemetry ingest endpoint (Api::TracesController), the
|
|
20
|
-
# evaluation report collector (Api::EvaluationReportsController)
|
|
20
|
+
# evaluation report collector (Api::EvaluationReportsController), a
|
|
21
|
+
# recording's event ingest (Api::RecordingEventIngestController) and the
|
|
21
22
|
# MCP facade (Api::MCPController) — are exempt.
|
|
22
23
|
class BaseController < ActionAgent::ApplicationController
|
|
23
24
|
# Rails 8.2 verifies forgery protection from the browser's Sec-Fetch-Site
|
|
@@ -60,9 +61,11 @@ module ActionAgent
|
|
|
60
61
|
# nothing owns) come back unfiltered.
|
|
61
62
|
# An unresolved owner scopes to nothing rather than to
|
|
62
63
|
# `where(id: nil)`, which would match every unowned row and leak them
|
|
63
|
-
# across tenants.
|
|
64
|
+
# across tenants. Only the rows the model's .owned_rows admits are
|
|
65
|
+
# returned, so a personal provider key never comes back from here.
|
|
64
66
|
def owned(relation)
|
|
65
67
|
klass = relation.respond_to?(:klass) ? relation.klass : relation
|
|
68
|
+
relation = relation.merge(klass.owned_rows) if klass.respond_to?(:owned_rows)
|
|
66
69
|
|
|
67
70
|
case klass.owner_association
|
|
68
71
|
when :account then current_account ? relation.where(account_id: current_account.id) : relation.none
|
|
@@ -78,12 +81,54 @@ module ActionAgent
|
|
|
78
81
|
ActionAgent.agents_for(current_owner)
|
|
79
82
|
end
|
|
80
83
|
|
|
84
|
+
# The requests for input the caller can see: those of the runs of
|
|
85
|
+
# owner_agents, the runs `GET /api/runs/:id` shows.
|
|
86
|
+
def owner_input_requests
|
|
87
|
+
InputRequest.where(subject_type: AgentRun.polymorphic_name, subject_id: AgentRun.where(agent: owner_agents).select(:id))
|
|
88
|
+
end
|
|
89
|
+
|
|
81
90
|
# Reported traces visible to the caller. Scoped to the tenant in a
|
|
82
91
|
# multi-tenant install; every trace otherwise.
|
|
83
92
|
def owned_traces
|
|
84
93
|
ActionAgent.trace_model.for_account(current_account)
|
|
85
94
|
end
|
|
86
95
|
|
|
96
|
+
# The recordings the caller may open: those the caller owns, plus those
|
|
97
|
+
# made inside a sandbox the caller owns. The second clause is what
|
|
98
|
+
# keeps recordings created before the owner column was written
|
|
99
|
+
# reachable.
|
|
100
|
+
def reachable_recordings
|
|
101
|
+
scope = owned(SessionRecording)
|
|
102
|
+
return scope if SessionRecording.owner_association.nil?
|
|
103
|
+
|
|
104
|
+
scope.or(SessionRecording.where(sandbox_session_id: owned(SandboxSession).select(:id)))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What a session timeline may reach on the caller's behalf.
|
|
108
|
+
def timeline_scope
|
|
109
|
+
SessionTimeline::Scope.new(agents: owner_agents, traces: owned_traces, recordings: reachable_recordings)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Rows read per kind of credential by #owner_credentials.
|
|
113
|
+
OWNER_CREDENTIAL_LIMIT = 100
|
|
114
|
+
|
|
115
|
+
# Returns the credentials the caller owns, for masking with
|
|
116
|
+
# SecretScrubber: provider keys, GitHub tokens, checkout sandboxes'
|
|
117
|
+
# runtime tokens, dashboard API keys (newest first) and the owner's
|
|
118
|
+
# telemetry key. Raises what a lookup raises, such as a credential that
|
|
119
|
+
# cannot be decrypted, so each caller decides whether to go on without
|
|
120
|
+
# the list.
|
|
121
|
+
# @return [Array<String>]
|
|
122
|
+
def owner_credentials
|
|
123
|
+
@owner_credentials ||= [
|
|
124
|
+
*owned(ProviderKey).limit(OWNER_CREDENTIAL_LIMIT).pluck(:credential, :api_key).flatten,
|
|
125
|
+
*owned(GithubConnection).limit(OWNER_CREDENTIAL_LIMIT).pluck(:access_token),
|
|
126
|
+
*owned(SandboxSession).where.not(runtime_mcp_token: nil).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:runtime_mcp_token),
|
|
127
|
+
*owned(ApiKey).order(id: :desc).limit(OWNER_CREDENTIAL_LIMIT).pluck(:token),
|
|
128
|
+
current_owner.try(:telemetry_api_key)
|
|
129
|
+
].compact
|
|
130
|
+
end
|
|
131
|
+
|
|
87
132
|
# The caller an agent run executes on behalf of.
|
|
88
133
|
#
|
|
89
134
|
# The host's seam first (ActionAgent.agent_actor_resolver), then the
|
|
@@ -116,6 +161,22 @@ module ActionAgent
|
|
|
116
161
|
render json: { error: "No account" }, status: :unauthorized
|
|
117
162
|
end
|
|
118
163
|
|
|
164
|
+
# Returns whether the signed-in user may perform +action+ (one of
|
|
165
|
+
# ActionAgent::PERMISSION_ACTIONS) on +subject+, per
|
|
166
|
+
# ActionAgent.permission_checker. When not, renders the refusal first,
|
|
167
|
+
# so a caller returns unless this is true.
|
|
168
|
+
def authorize_action!(action, subject)
|
|
169
|
+
return true if ActionAgent.permitted?(current_user, action, subject)
|
|
170
|
+
|
|
171
|
+
permission_denied(action)
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def permission_denied(action)
|
|
176
|
+
render json: { error: "You do not have permission to do this", code: "forbidden", permission: action },
|
|
177
|
+
status: :forbidden
|
|
178
|
+
end
|
|
179
|
+
|
|
119
180
|
# Asks the host app whether this owner may do +kind+ (:execution or
|
|
120
181
|
# :trace_ingest). Unlimited unless the app said otherwise.
|
|
121
182
|
def enforce_quota!(kind)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
class ClaudeLoginsController < BaseController
|
|
6
|
+
DISCONNECTED = { status: "disconnected", logged_in: false, auth_method: nil }.freeze
|
|
7
|
+
# Where a sign-in stops without a login and without leaving the CLI's
|
|
8
|
+
# config behind. "completed" is not one: a CLI that completed with
|
|
9
|
+
# another kind of account still holds that credential until logout.
|
|
10
|
+
ENDED = %w[expired failed cancelled disconnected].freeze
|
|
11
|
+
|
|
12
|
+
before_action :require_owner!
|
|
13
|
+
before_action :require_execution_enabled!
|
|
14
|
+
before_action :set_sandbox
|
|
15
|
+
|
|
16
|
+
# Polled while the sign-in dialog is open, so it takes no lock: the
|
|
17
|
+
# fix verification holds the sandbox's row while the runtime restarts.
|
|
18
|
+
def show
|
|
19
|
+
return render json: { login: DISCONNECTED } unless mine?
|
|
20
|
+
|
|
21
|
+
login = orchestrator.claude_login_status(@sandbox)
|
|
22
|
+
# A sign-in that ended without a login leaves no credential behind
|
|
23
|
+
# (the supervisor removes the config), so it stops holding the sandbox.
|
|
24
|
+
@sandbox.update_columns(claude_login_user_id: nil) if ended?(login)
|
|
25
|
+
render json: { login: login }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create
|
|
29
|
+
login = nil
|
|
30
|
+
@sandbox.with_lock do
|
|
31
|
+
unless @sandbox.claude_login_user_id.nil? || mine? || ended?(orchestrator.claude_login_status(@sandbox))
|
|
32
|
+
return render json: { error: "This sandbox has another member's login. Start your own sandbox or use the account API key." }, status: :conflict
|
|
33
|
+
end
|
|
34
|
+
return busy if @sandbox.code_sessions.where(status: [ :queued, :running ]).exists?
|
|
35
|
+
login = orchestrator.start_claude_login(@sandbox)
|
|
36
|
+
@sandbox.update!(claude_login_user_id: current_user.id)
|
|
37
|
+
end
|
|
38
|
+
render json: { login: login }, status: :accepted
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def code
|
|
42
|
+
# Filtered by engine.filter_parameters. Never interpolate this value
|
|
43
|
+
# into an error, an event, a model, or a log line.
|
|
44
|
+
login = nil
|
|
45
|
+
@sandbox.with_lock do
|
|
46
|
+
return forbidden unless mine?
|
|
47
|
+
login = orchestrator.submit_claude_login_code(@sandbox, params[:claude_login_code])
|
|
48
|
+
end
|
|
49
|
+
render json: { login: login }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def destroy
|
|
53
|
+
@sandbox.with_lock do
|
|
54
|
+
return forbidden unless mine?
|
|
55
|
+
return busy if @sandbox.code_sessions.where(status: [ :queued, :running ]).exists?
|
|
56
|
+
orchestrator.claude_logout(@sandbox)
|
|
57
|
+
@sandbox.update!(claude_login_user_id: nil)
|
|
58
|
+
end
|
|
59
|
+
render json: { login: DISCONNECTED }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
rescue_from LocalSandboxBackend::Error, SandboxOrchestrator::UnsupportedBackendError, SandboxOrchestrator::BackendError do
|
|
63
|
+
# Backend errors may include command output; login endpoints expose
|
|
64
|
+
# a fixed message, never raw exception text.
|
|
65
|
+
render json: { error: "Claude sign-in could not complete. Check that Claude Code is installed, then start again." }, status: :unprocessable_entity
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def set_sandbox
|
|
71
|
+
scope = owned(SandboxSession)
|
|
72
|
+
scope = scope.where(account_id: current_account.id) if current_account
|
|
73
|
+
@sandbox = scope.find_by!(session_id: params[:sandbox_id])
|
|
74
|
+
unless ClaudeCodeAuth.sandbox_login? && current_user.respond_to?(:id) && current_user.id.present?
|
|
75
|
+
return render json: { error: "Sandbox subscription login requires a signed-in user and :sandbox_login mode" }, status: :unprocessable_entity
|
|
76
|
+
end
|
|
77
|
+
if (refusal = ClaudeCodeAuth.backend_refusal(orchestrator))
|
|
78
|
+
return render json: { error: refusal }, status: :unprocessable_entity
|
|
79
|
+
end
|
|
80
|
+
unless @sandbox.app_runtime? && @sandbox.ready? && @sandbox.active?
|
|
81
|
+
render json: { error: "Sign in after this checkout sandbox is ready" }, status: :unprocessable_entity
|
|
82
|
+
end
|
|
83
|
+
response.headers["Cache-Control"] = "no-store"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mine? = @sandbox.claude_login_user_id == current_user.id
|
|
87
|
+
def ended?(login) = !login[:logged_in] && ENDED.include?(login[:status].to_s)
|
|
88
|
+
def orchestrator = @orchestrator ||= SandboxOrchestrator.new
|
|
89
|
+
def forbidden = render(json: { error: "This login belongs to another user" }, status: :forbidden)
|
|
90
|
+
def busy = render(json: { error: "Wait for the running code session before changing its login" }, status: :conflict)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -12,6 +12,11 @@ module ActionAgent
|
|
|
12
12
|
# is execution — gated, and counted against the host app's quota, like
|
|
13
13
|
# running an agent.
|
|
14
14
|
class CodeSessionsController < BaseController
|
|
15
|
+
include EvaluationRunStarting
|
|
16
|
+
|
|
17
|
+
# How long an unverified fix holds its checkout (see #busy_session).
|
|
18
|
+
VERIFICATION_HOLD = 1.hour
|
|
19
|
+
|
|
15
20
|
before_action :require_owner!
|
|
16
21
|
before_action :require_execution_enabled!, only: [ :create ]
|
|
17
22
|
before_action :set_sandbox
|
|
@@ -29,7 +34,11 @@ module ActionAgent
|
|
|
29
34
|
|
|
30
35
|
# POST /api/sandboxes/:sandbox_id/code_sessions
|
|
31
36
|
def create
|
|
32
|
-
|
|
37
|
+
runner = params[:runner].nil? ? "claude_code" : params[:runner]
|
|
38
|
+
unless CodeSession::RUNNERS.include?(runner)
|
|
39
|
+
return render json: { error: "runner must be claude_code or codex" }, status: :unprocessable_entity
|
|
40
|
+
end
|
|
41
|
+
if (refusal = refusal_for(@sandbox, runner: runner))
|
|
33
42
|
return render json: { error: refusal }, status: :unprocessable_entity
|
|
34
43
|
end
|
|
35
44
|
|
|
@@ -41,10 +50,13 @@ module ActionAgent
|
|
|
41
50
|
sandbox_session: @sandbox,
|
|
42
51
|
prompt: string_param(:prompt),
|
|
43
52
|
model: string_param(:model).presence,
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
runner: runner,
|
|
54
|
+
# Subscription use always belongs to the person who clicked Run.
|
|
55
|
+
user_id: current_user.respond_to?(:id) ? current_user.id : @sandbox.try(:user_id),
|
|
46
56
|
account_id: @sandbox.try(:account_id)
|
|
47
57
|
)
|
|
58
|
+
code_session.credential_mode = runner == "claude_code" ? ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) : "api_key"
|
|
59
|
+
attach_fix!(code_session) if params[:evaluation_run_id].present?
|
|
48
60
|
if (error = invalid_request(code_session))
|
|
49
61
|
return render json: { error: error }, status: :unprocessable_entity
|
|
50
62
|
end
|
|
@@ -57,6 +69,10 @@ module ActionAgent
|
|
|
57
69
|
current = nil
|
|
58
70
|
@sandbox.with_lock do
|
|
59
71
|
current = busy_session
|
|
72
|
+
if !current && (refusal = refusal_for(@sandbox, runner: runner))
|
|
73
|
+
return render json: { error: refusal }, status: :unprocessable_entity
|
|
74
|
+
end
|
|
75
|
+
code_session.credential_mode = ClaudeCodeAuth.credential_mode(@sandbox, user_id: current_user&.id) if runner == "claude_code"
|
|
60
76
|
code_session.save! unless current
|
|
61
77
|
end
|
|
62
78
|
return render json: busy_body(current), status: :conflict if current
|
|
@@ -111,31 +127,74 @@ module ActionAgent
|
|
|
111
127
|
end
|
|
112
128
|
|
|
113
129
|
# Why +sandbox+ cannot take a Claude Code session now, or nil.
|
|
114
|
-
def refusal_for(sandbox)
|
|
115
|
-
|
|
130
|
+
def refusal_for(sandbox, runner: "claude_code")
|
|
131
|
+
label = runner == "codex" ? "Codex" : "Claude Code"
|
|
132
|
+
return "#{label} sessions run only in a checkout (app_runtime) sandbox" unless sandbox.app_runtime?
|
|
116
133
|
return "The sandbox has expired; start a new one" if sandbox.ready? && !sandbox.active?
|
|
117
134
|
return "The sandbox is #{sandbox.status}; wait until it is ready" unless sandbox.ready?
|
|
118
135
|
|
|
119
136
|
orchestrator = SandboxOrchestrator.new
|
|
120
|
-
unless orchestrator.
|
|
121
|
-
return "The #{orchestrator.backend_name} sandbox backend cannot run
|
|
137
|
+
unless orchestrator.supports_code_runner?(runner)
|
|
138
|
+
return "The #{orchestrator.backend_name} sandbox backend cannot run #{label} sessions"
|
|
122
139
|
end
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
if (refusal = ClaudeCodeAuth.login_owner_refusal(sandbox, user_id: current_user&.id))
|
|
142
|
+
return refusal
|
|
143
|
+
end
|
|
144
|
+
if runner == "codex"
|
|
145
|
+
return "Codex is not connected: connect an OpenAI API key in Settings -> Integrations first" if sandbox.runtime_environment(runner: runner).blank?
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
ClaudeCodeAuth.backend_refusal(orchestrator) || ClaudeCodeAuth.credential_refusal(sandbox, user_id: current_user&.id)
|
|
125
149
|
end
|
|
126
150
|
|
|
127
151
|
# The session of this sandbox that is queued or running, if any: one
|
|
128
|
-
# checkout, one Claude Code at a time.
|
|
152
|
+
# checkout, one Claude Code at a time. A fix whose verification has not
|
|
153
|
+
# finished holds the checkout too, so no edit lands under the run that
|
|
154
|
+
# is meant to verify it, but only for VERIFICATION_HOLD: a worker lost
|
|
155
|
+
# mid-run must not lock the sandbox until it expires.
|
|
129
156
|
def busy_session
|
|
130
|
-
@sandbox.code_sessions.where(status: [ :queued, :running ]).first
|
|
157
|
+
@sandbox.code_sessions.where(status: [ :queued, :running ]).first ||
|
|
158
|
+
@sandbox.code_sessions.where.not(evaluation_run_id: nil).where(finished_at: VERIFICATION_HOLD.ago..).recent.detect do |session|
|
|
159
|
+
session.succeeded? && session.diff.present? && session.verification_error.blank? &&
|
|
160
|
+
(session.verification_run_id.nil? || session.verification_run&.status.in?(%w[pending running]))
|
|
161
|
+
end
|
|
131
162
|
end
|
|
132
163
|
|
|
133
164
|
def busy_body(current)
|
|
134
|
-
|
|
165
|
+
label = current.runner == "codex" ? "Codex" : "Claude Code"
|
|
166
|
+
state = current.succeeded? ? "being verified" : current.status
|
|
167
|
+
{ error: "A #{label} session is already #{state} in this sandbox", code_session: current.summary }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def attach_fix!(session)
|
|
171
|
+
raise EvaluationFix::Invalid, "Evaluation fixes use Claude Code" unless session.runner == "claude_code"
|
|
172
|
+
scope = EvaluationRun.joins(:evaluation).where(evaluation: Evaluation.where(agent: owner_agents))
|
|
173
|
+
run = scope.find(params[:evaluation_run_id])
|
|
174
|
+
project = Project.for_evaluation(run.evaluation)
|
|
175
|
+
unless project && @sandbox.project_id == project.id
|
|
176
|
+
raise EvaluationFix::Invalid, "Choose a checkout of this evaluation's project"
|
|
177
|
+
end
|
|
178
|
+
evaluation_run_sandbox(run.evaluation, @sandbox.session_id)
|
|
179
|
+
raise EvaluationFix::Invalid, "The sandbox backend cannot refresh the app runtime for verification" unless SandboxOrchestrator.new.supports?(:refresh_runtime)
|
|
180
|
+
request_item = params[:fix_item]
|
|
181
|
+
raise EvaluationFix::Invalid, "Choose a fix card" unless request_item.respond_to?(:permit)
|
|
182
|
+
fix = EvaluationFix.new(run, request_item.permit(:kind, :fault, :quote, scenario_keys: [], models: []).to_h)
|
|
183
|
+
previous = @sandbox.code_sessions.find(params[:previous_code_session_id]) if params[:previous_code_session_id].present?
|
|
184
|
+
if previous && (previous.evaluation_run_id != run.id || previous.fix_item != fix.item || !previous.finished?)
|
|
185
|
+
raise EvaluationFix::Invalid, "The previous session must implement this same fix"
|
|
186
|
+
end
|
|
187
|
+
session.assign_attributes(evaluation_run: run, fix_item: fix.item, previous_code_session: previous,
|
|
188
|
+
prompt: fix.prompt(previous: previous))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
rescue_from EvaluationFix::Invalid do |error|
|
|
192
|
+
render json: { error: error.message }, status: :unprocessable_entity
|
|
135
193
|
end
|
|
136
194
|
|
|
137
195
|
def invalid_request(code_session)
|
|
138
|
-
|
|
196
|
+
label = code_session.runner == "codex" ? "Codex" : "Claude Code"
|
|
197
|
+
return "model is not a #{label} model name" if code_session.model && !code_session.model.match?(MODEL_NAME)
|
|
139
198
|
|
|
140
199
|
code_session.errors.full_messages.to_sentence unless code_session.valid?
|
|
141
200
|
end
|
|
@@ -13,12 +13,12 @@ module ActionAgent
|
|
|
13
13
|
rescue_from DashboardAssistantService::SetupRequired, with: :setup_required
|
|
14
14
|
|
|
15
15
|
def show
|
|
16
|
-
render json: DashboardAssistantService.new(owner: current_owner).configuration
|
|
16
|
+
render json: DashboardAssistantService.new(owner: current_owner, actor: current_user).configuration
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def create
|
|
20
20
|
input = params.to_unsafe_h.symbolize_keys.slice(:message, :history, :provider, :model, :allow_provider_processing)
|
|
21
|
-
assistant = DashboardAssistantService.new(owner: current_owner, **input)
|
|
21
|
+
assistant = DashboardAssistantService.new(owner: current_owner, actor: current_user, **input)
|
|
22
22
|
assistant.validate!
|
|
23
23
|
record_execution_usage
|
|
24
24
|
render json: assistant.call
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Opening a pull request from one of the caller's checkout sandboxes
|
|
6
|
+
# (Settings -> Integrations -> Open draft PR): the preview of what would
|
|
7
|
+
# be published, the publish itself, the pull request it opened, and the
|
|
8
|
+
# patch to download where publishing is not available. See
|
|
9
|
+
# DraftPullRequestPublisher for how changes are read, filtered and
|
|
10
|
+
# written.
|
|
11
|
+
#
|
|
12
|
+
# Publishing is an explicit request from a signed-in user: it asks
|
|
13
|
+
# ActionAgent.permission_checker about :publish_pull_request, and
|
|
14
|
+
# DraftPullRequestJob asks again before it writes. Preview, status and
|
|
15
|
+
# patch find the sandbox the way its other endpoints do.
|
|
16
|
+
class DraftPullRequestsController < BaseController
|
|
17
|
+
before_action :require_owner!
|
|
18
|
+
before_action :set_sandbox
|
|
19
|
+
|
|
20
|
+
rescue_from DraftPullRequestPublisher::Refused, with: :refused
|
|
21
|
+
|
|
22
|
+
# GET /api/sandboxes/:sandbox_id/pull_request — the latest pull request
|
|
23
|
+
# opened from this sandbox (its state read again from GitHub at most
|
|
24
|
+
# once a minute), and whether publishing is available.
|
|
25
|
+
def show
|
|
26
|
+
@sandbox.draft_pull_requests.fail_stalled!
|
|
27
|
+
record = @sandbox.draft_pull_requests.recent.first
|
|
28
|
+
DraftPullRequestPublisher.refresh_status!(record) if record && !record.in_progress?
|
|
29
|
+
|
|
30
|
+
render json: { pull_request: record&.summary, publishing: publishing_status }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# POST /api/sandboxes/:sandbox_id/pull_request/preview { allowlist: [patterns] }
|
|
34
|
+
def preview
|
|
35
|
+
render json: { preview: publisher.preview(allowlist: allowlist_param), publishing: publishing_status }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# POST /api/sandboxes/:sandbox_id/pull_request
|
|
39
|
+
#
|
|
40
|
+
# { title:, body:, branch:, files: [{ path:, digest: }], allowlist: }
|
|
41
|
+
# publishes the files as a new branch and opens a draft pull
|
|
42
|
+
# request; each digest is the one the preview reported
|
|
43
|
+
# { update: true, files:, message:, allowlist: }
|
|
44
|
+
# publishes the files as a new commit on the branch of the latest
|
|
45
|
+
# pull request, with +message+ as the commit's message; the pull
|
|
46
|
+
# request's title and description stay as they are
|
|
47
|
+
# { open: true }
|
|
48
|
+
# opens a draft pull request for the latest branch that was
|
|
49
|
+
# published without one
|
|
50
|
+
# { regular: true }
|
|
51
|
+
# opens a regular pull request for that branch instead
|
|
52
|
+
#
|
|
53
|
+
# Answers 202 with the queued record, which GET reports on.
|
|
54
|
+
def create
|
|
55
|
+
return open_branch(regular: true) if boolean_param(:regular)
|
|
56
|
+
return open_branch(regular: false) if boolean_param(:open)
|
|
57
|
+
return update_latest if boolean_param(:update)
|
|
58
|
+
|
|
59
|
+
record = @sandbox.draft_pull_requests.new(
|
|
60
|
+
operation: "create", repository: @sandbox.repository, branch: string_param(:branch).to_s.strip,
|
|
61
|
+
title: string_param(:title).to_s.strip, body: string_param(:body)
|
|
62
|
+
)
|
|
63
|
+
if params[:code_session_id].present?
|
|
64
|
+
session = @sandbox.code_sessions.find(params[:code_session_id])
|
|
65
|
+
unless session.evaluation_run_id && session.verification_run&.complete?
|
|
66
|
+
raise DraftPullRequestPublisher::Refused.new("Wait for this fix's verification before publishing", code: "not_live")
|
|
67
|
+
end
|
|
68
|
+
room = DraftPullRequest::MAX_BODY_CHARACTERS - (record.body.present? ? record.body.length + 2 : 0)
|
|
69
|
+
section = EvaluationFixComparison.new(session).pull_request_body(mount: "#{request.base_url}#{request.script_name}", limit: room)
|
|
70
|
+
record.body = [ record.body.presence, section.presence ].compact.join("\n\n")
|
|
71
|
+
end
|
|
72
|
+
assign_owner(record)
|
|
73
|
+
return unless authorize_action!(:publish_pull_request, record)
|
|
74
|
+
|
|
75
|
+
branch_refusal = DraftPullRequestPublisher.branch_refusal(record.branch)
|
|
76
|
+
raise DraftPullRequestPublisher::Refused.new(branch_refusal, code: "invalid_branch") if branch_refusal
|
|
77
|
+
|
|
78
|
+
queue(record, allowlist: allowlist_param)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# GET /api/sandboxes/:sandbox_id/pull_request/patch?paths[]=…&title=…
|
|
82
|
+
# The chosen files (every publishable one when none are named) as a
|
|
83
|
+
# patch for `git am` or `git apply`.
|
|
84
|
+
def patch
|
|
85
|
+
paths = params[:paths]
|
|
86
|
+
paths = nil unless paths.is_a?(Array) && paths.all? { |path| path.is_a?(String) }
|
|
87
|
+
data = publisher.patch(paths: paths, title: string_param(:title), body: string_param(:body))
|
|
88
|
+
|
|
89
|
+
send_data data, type: "text/x-diff", disposition: "attachment",
|
|
90
|
+
filename: "#{@sandbox.repository.tr('/', '-')}-#{@sandbox.session_id.first(8)}.patch"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# A checkout runs on its account's GitHub access, so in a multi-tenant
|
|
96
|
+
# install it is found within the caller's current account, as
|
|
97
|
+
# CodeSessionsController finds it.
|
|
98
|
+
def set_sandbox
|
|
99
|
+
scope = owned(SandboxSession)
|
|
100
|
+
scope = scope.where(account_id: current_account.id) if current_account
|
|
101
|
+
@sandbox = scope.find_by!(session_id: params[:sandbox_id])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def publisher
|
|
105
|
+
@publisher ||= DraftPullRequestPublisher.new(@sandbox, user: current_user)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A new commit goes only onto the branch of a pull request that is open
|
|
109
|
+
# on GitHub: a branch without one is opened first (see #open_branch).
|
|
110
|
+
def update_latest
|
|
111
|
+
record = @sandbox.draft_pull_requests.recent.where.not(head_commit: nil).first
|
|
112
|
+
if record.nil?
|
|
113
|
+
return render json: { error: "Nothing was published from this sandbox yet", code: "nothing_published" }, status: :unprocessable_entity
|
|
114
|
+
end
|
|
115
|
+
unless record.opened?
|
|
116
|
+
return render json: { error: "The branch #{record.branch} has no pull request yet. Open one for it first", code: "not_opened" },
|
|
117
|
+
status: :unprocessable_entity
|
|
118
|
+
end
|
|
119
|
+
if record.state.in?(%w[closed merged])
|
|
120
|
+
return render json: { error: "The pull request is #{record.state}. Open a new one instead", code: "pull_request_closed" },
|
|
121
|
+
status: :unprocessable_entity
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
record.assign_attributes(operation: "update",
|
|
125
|
+
commit_message: string_param(:message).to_s.strip.presence || DraftPullRequest::DEFAULT_UPDATE_MESSAGE)
|
|
126
|
+
assign_owner(record)
|
|
127
|
+
return unless authorize_action!(:publish_pull_request, record)
|
|
128
|
+
|
|
129
|
+
queue(record, allowlist: allowlist_param)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Opens a pull request for the branch of the latest publish, which was
|
|
133
|
+
# pushed without one: a draft, or with +regular+ a regular one.
|
|
134
|
+
def open_branch(regular:)
|
|
135
|
+
record = @sandbox.draft_pull_requests.recent.first
|
|
136
|
+
unless record&.branch_only?
|
|
137
|
+
return render json: { error: "No branch of this sandbox is waiting to be opened as a pull request", code: "nothing_published" },
|
|
138
|
+
status: :unprocessable_entity
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
record.assign_attributes(operation: regular ? "open_regular" : "open_draft")
|
|
142
|
+
assign_owner(record)
|
|
143
|
+
return unless authorize_action!(:publish_pull_request, record)
|
|
144
|
+
|
|
145
|
+
queue(record, files: false)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Checks the request against the sandbox as it is now, then queues the
|
|
149
|
+
# publish: one at a time per sandbox, under its row lock. A record that
|
|
150
|
+
# is itself queued or publishing is busy too.
|
|
151
|
+
def queue(record, allowlist: nil, files: true)
|
|
152
|
+
refusal = publisher.read_refusal
|
|
153
|
+
raise refusal if refusal
|
|
154
|
+
|
|
155
|
+
credential = publisher.credential
|
|
156
|
+
unless credential.available?
|
|
157
|
+
return render json: { error: credential.refusal, code: "no_credential", patch_available: true }, status: :unprocessable_entity
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if files
|
|
161
|
+
selection = selection_param
|
|
162
|
+
changes = publisher.changes(allowlist: allowlist, paths: selection.keys)
|
|
163
|
+
chosen = publisher.select!(changes, selection)
|
|
164
|
+
record.base_commit = changes.base_commit
|
|
165
|
+
record.files = chosen.map { |file| { "path" => file.path, "status" => file.status, "mode" => file.mode, "digest" => file.digest } }
|
|
166
|
+
end
|
|
167
|
+
record.assign_attributes(status: "queued", credential_kind: credential.kind, error_code: nil, error_message: nil)
|
|
168
|
+
return render json: { error: record.errors.full_messages.to_sentence }, status: :unprocessable_entity unless record.valid?
|
|
169
|
+
|
|
170
|
+
busy = nil
|
|
171
|
+
@sandbox.with_lock do
|
|
172
|
+
@sandbox.draft_pull_requests.fail_stalled!
|
|
173
|
+
busy = @sandbox.draft_pull_requests.where(status: %w[queued publishing]).first
|
|
174
|
+
record.save! unless busy
|
|
175
|
+
end
|
|
176
|
+
if busy
|
|
177
|
+
return render json: { error: "A publish from this sandbox is already #{busy.status}", pull_request: busy.summary },
|
|
178
|
+
status: :conflict
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
DraftPullRequestJob.perform_later(record.id)
|
|
182
|
+
render json: { pull_request: record.summary }, status: :accepted
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The user who publishes, and the owner: the sandbox's account where
|
|
186
|
+
# the account owns records.
|
|
187
|
+
def assign_owner(record)
|
|
188
|
+
record.user_id = current_user.id if ActionAgent.user_class.present? && current_user.respond_to?(:id)
|
|
189
|
+
record.account_id = @sandbox.account_id if @sandbox.has_attribute?(:account_id) && @sandbox.account_id
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def publishing_status
|
|
193
|
+
refusal = publisher.read_refusal
|
|
194
|
+
credential = refusal ? nil : publisher.credential
|
|
195
|
+
{
|
|
196
|
+
supported: refusal&.code != "unsupported",
|
|
197
|
+
available: refusal.nil? && credential.available?,
|
|
198
|
+
refusal: refusal&.message || credential&.refusal,
|
|
199
|
+
refusal_code: refusal&.code || (credential&.available? == false ? "no_credential" : nil),
|
|
200
|
+
credential: credential&.kind,
|
|
201
|
+
# A patch needs only the live checkout.
|
|
202
|
+
patch_available: refusal.nil?
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# [{ path:, digest: }] as { path => digest }.
|
|
207
|
+
def selection_param
|
|
208
|
+
files = params[:files]
|
|
209
|
+
files = files.map { |file| file.respond_to?(:permit) ? file.permit(:path, :digest).to_h : file } if files.is_a?(Array)
|
|
210
|
+
unless files.is_a?(Array) && files.all? { |file| file.is_a?(Hash) && file["path"].is_a?(String) && file["digest"].is_a?(String) }
|
|
211
|
+
raise DraftPullRequestPublisher::Refused.new("files must be a list of { path, digest } from the preview", code: "nothing_selected")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
files.to_h { |file| [ file["path"], file["digest"] ] }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def allowlist_param
|
|
218
|
+
allowlist = params[:allowlist]
|
|
219
|
+
return nil if allowlist.nil?
|
|
220
|
+
unless allowlist.is_a?(Array) && allowlist.all? { |pattern| pattern.is_a?(String) && pattern.present? }
|
|
221
|
+
raise DraftPullRequestPublisher::Refused.new("allowlist must be a list of path patterns", code: "not_publishable")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
allowlist
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def string_param(name)
|
|
228
|
+
value = params[name]
|
|
229
|
+
value.is_a?(String) ? value : nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def boolean_param(name)
|
|
233
|
+
ActiveModel::Type::Boolean.new.cast(params[name]) == true
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def refused(error)
|
|
237
|
+
status = error.code == "changed_since_preview" ? :conflict : :unprocessable_entity
|
|
238
|
+
render json: { error: error.message, code: error.code, patch_available: patch_offered?(error) }, status: status
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A patch of the same files fails the same way when they were too
|
|
242
|
+
# many or too large.
|
|
243
|
+
def patch_offered?(error)
|
|
244
|
+
!error.code.in?(%w[unsupported not_live unreadable too_large]) && publisher.read_refusal.nil?
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|