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,558 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module ActionAgent
|
|
6
|
+
# How a sandbox backend boots one checkout, handed to it as data
|
|
7
|
+
# (SandboxOrchestrator#create_sandbox(session, boot_config:)). For that
|
|
8
|
+
# boot it replaces the checkout's .activeagents/sandbox.yml. #to_h is plain
|
|
9
|
+
# JSON, so a backend that boots somewhere else (a container's own boot
|
|
10
|
+
# script) reads the same thing:
|
|
11
|
+
#
|
|
12
|
+
# kind "bootstrap" (built by .bootstrap) or "custom"
|
|
13
|
+
# apply "always", or "without_engine": only when the checkout
|
|
14
|
+
# has a Gemfile.lock that locks no actionagent and its
|
|
15
|
+
# sandbox.yml names no manifest. Otherwise the checkout
|
|
16
|
+
# boots as it would without a spec, with the spec's env
|
|
17
|
+
# and secrets added to its sandbox.yml env and the
|
|
18
|
+
# spec's `always` steps run after that file's setup.
|
|
19
|
+
# preflight refuse, before any repository command runs, a
|
|
20
|
+
# checkout without a Gemfile.lock or a
|
|
21
|
+
# config/application.rb at its root, or one locking Ruby
|
|
22
|
+
# or railties older than the engine supports
|
|
23
|
+
# steps [{ name, command, timeout, unless_locked?, if_task?,
|
|
24
|
+
# always? }], run in order, each with a log of its own.
|
|
25
|
+
# A step with unless_locked is skipped when the
|
|
26
|
+
# checkout's Gemfile.lock, as checked out, locks that
|
|
27
|
+
# gem; one with if_task when the app defines no such
|
|
28
|
+
# Rake task. A step with always runs whether the spec
|
|
29
|
+
# applies or not, so it cannot have unless_locked.
|
|
30
|
+
# env environment for the steps, manifest and start
|
|
31
|
+
# secrets environment too, masked in every log and message the
|
|
32
|
+
# backend produces and never written down
|
|
33
|
+
# manifest, start { command, timeout }, as sandbox.yml's keys of the
|
|
34
|
+
# same name
|
|
35
|
+
# start_url a path on the booted app that must not answer 5xx once
|
|
36
|
+
# its MCP facade answers
|
|
37
|
+
# keep_on_failure keep a failed boot's workspace and databases, so it
|
|
38
|
+
# can be resumed from the step that failed
|
|
39
|
+
# timeout seconds for the whole boot, checkout included; a
|
|
40
|
+
# step's own timeout bounds it within that
|
|
41
|
+
# engine where the gems were taken from (see .engine_gems)
|
|
42
|
+
#
|
|
43
|
+
# Secrets travel in memory only. #redacted is what may be written down: the
|
|
44
|
+
# secrets' names without their values.
|
|
45
|
+
class SandboxBootSpec
|
|
46
|
+
class Invalid < ArgumentError; end
|
|
47
|
+
|
|
48
|
+
KINDS = %w[bootstrap custom].freeze
|
|
49
|
+
APPLY_MODES = %w[always without_engine].freeze
|
|
50
|
+
STEP_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
|
|
51
|
+
# Names the backend gives the parts of a boot that are not spec steps.
|
|
52
|
+
RESERVED_STEP_NAMES = %w[checkout preflight setup manifest start server].freeze
|
|
53
|
+
ENV_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
54
|
+
GEM_NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,99}\z/
|
|
55
|
+
TASK_NAME = /\A[A-Za-z0-9_][A-Za-z0-9_:.-]{0,99}\z/
|
|
56
|
+
# Set by the backend, or changing how Ruby, Bundler, Node or git load
|
|
57
|
+
# code: a secret is handed to the checkout's code, never to the loader.
|
|
58
|
+
REFUSED_SECRET_NAME = /
|
|
59
|
+
\A(?:PORT|DATABASE_URL|RUBYOPT|RUBYLIB|LD_PRELOAD|PATH|NODE_OPTIONS)\z |
|
|
60
|
+
_DATABASE_URL\z | \AACTION_AGENT_SANDBOX_ | \ADYLD_ | \ABUNDLE_ | \AGIT_
|
|
61
|
+
/x
|
|
62
|
+
MAX_STEPS = 30
|
|
63
|
+
MAX_COMMAND_LENGTH = 4096
|
|
64
|
+
MAX_START_URL_LENGTH = 2048
|
|
65
|
+
MAX_TIMEOUT = 6 * 3600
|
|
66
|
+
DEFAULT_STEP_TIMEOUT = 600
|
|
67
|
+
# A bootstrap installs a bundle, runs two generators and every engine
|
|
68
|
+
# migration before the app's own setup: more than a configured app needs.
|
|
69
|
+
BOOTSTRAP_TIMEOUT = 1800
|
|
70
|
+
MINIMUM_RUBY = Gem::Version.new("3.2")
|
|
71
|
+
MINIMUM_RAILTIES = Gem::Version.new("7.2")
|
|
72
|
+
ASSET_TASKS = %w[javascript:build css:build tailwindcss:build].freeze
|
|
73
|
+
CREDENTIALED_URL = %r{[a-z][a-z0-9+.-]*://[^/\s@]+@}i
|
|
74
|
+
# What a sandbox request may ask of a checkout's boot (see
|
|
75
|
+
# .request_options): bootstrap one whose Gemfile.lock lacks the engine,
|
|
76
|
+
# bootstrap it whatever its lock says, or boot it as its sandbox.yml says.
|
|
77
|
+
BOOTSTRAP_MODES = %w[auto always never].freeze
|
|
78
|
+
# The steps that install the engine, which a checkout that bundles it
|
|
79
|
+
# already (a project's install pull request branch) does not run.
|
|
80
|
+
INSTALL_STEPS = %w[bundle_config add_framework add_engine install_framework install_engine].freeze
|
|
81
|
+
# A schema tools choice (see .schema_tools_steps).
|
|
82
|
+
MODEL_NAME = /\A[A-Z][A-Za-z0-9]{0,99}(?:::[A-Z][A-Za-z0-9]{0,99}){0,4}\z/
|
|
83
|
+
COLUMN_NAME = /\A[a-z_][a-z0-9_]{0,62}\z/
|
|
84
|
+
MAX_SCHEMA_TOOL_MODELS = 50
|
|
85
|
+
MAX_SCHEMA_TOOL_COLUMNS = 100
|
|
86
|
+
# Leaves room within MAX_STEPS for every step of a bootstrap's own.
|
|
87
|
+
MAX_SCHEMA_TOOL_STEPS = 10
|
|
88
|
+
SCHEMA_TOOLS_STEP = /\Aschema_tools(?:_\d+)?\z/
|
|
89
|
+
SCHEMA_TOOLS_DIR = "app/agent_tools"
|
|
90
|
+
# ActiveAgent::SchemaTools::MANAGED_MARKER, spelled out because the
|
|
91
|
+
# checkout's framework writes it and this dashboard's own framework can
|
|
92
|
+
# predate the constant.
|
|
93
|
+
SCHEMA_TOOLS_MARKER = "Managed by the ActiveAgent dashboard"
|
|
94
|
+
|
|
95
|
+
attr_reader :kind, :apply, :steps, :env, :secrets, :manifest, :start, :start_url, :timeout, :engine,
|
|
96
|
+
:secret_names
|
|
97
|
+
|
|
98
|
+
class << self
|
|
99
|
+
# A spec from +value+: a spec, a Hash in #to_h's shape (string or
|
|
100
|
+
# symbol keys), or nil for none.
|
|
101
|
+
#
|
|
102
|
+
# @raise [Invalid] when +value+ is not a valid spec
|
|
103
|
+
def wrap(value)
|
|
104
|
+
case value
|
|
105
|
+
when nil then nil
|
|
106
|
+
when self then value
|
|
107
|
+
when Hash then new(value)
|
|
108
|
+
else raise Invalid, "a boot spec is a Hash, not #{value.class}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The spec that installs the engine into a checkout that does not
|
|
113
|
+
# bundle it: the same version as this dashboard's, or the git revision
|
|
114
|
+
# or path this dashboard bundles it from (see .engine_gems).
|
|
115
|
+
#
|
|
116
|
+
# @param apply [String] "always", or "without_engine" for only a
|
|
117
|
+
# checkout without the engine
|
|
118
|
+
# @raise [Invalid] for options no spec can hold, and when the engine's
|
|
119
|
+
# gems come from a git URL that carries credentials
|
|
120
|
+
#
|
|
121
|
+
# @param steps [Array<Hash>] more steps, run after db_prepare
|
|
122
|
+
def bootstrap(apply: "always", start_url: "/", keep_on_failure: false, env: {}, secrets: {}, timeout: BOOTSTRAP_TIMEOUT,
|
|
123
|
+
engine: engine_gems, steps: [])
|
|
124
|
+
new(
|
|
125
|
+
"kind" => "bootstrap",
|
|
126
|
+
"apply" => apply,
|
|
127
|
+
"preflight" => true,
|
|
128
|
+
"steps" => bootstrap_steps(engine) + steps,
|
|
129
|
+
"env" => env,
|
|
130
|
+
"secrets" => secrets,
|
|
131
|
+
"manifest" => { "command" => "bin/rails action_agent:sandbox:manifest", "timeout" => 300 },
|
|
132
|
+
"start" => { "command" => "bin/rails server -b 127.0.0.1 -p $PORT", "timeout" => 300 },
|
|
133
|
+
"start_url" => start_url,
|
|
134
|
+
"keep_on_failure" => keep_on_failure,
|
|
135
|
+
"timeout" => timeout,
|
|
136
|
+
"engine" => engine
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The spec for a checkout that bundles the engine already because a
|
|
141
|
+
# bootstrap's changes were published to its branch: the bootstrap's
|
|
142
|
+
# steps without the INSTALL_STEPS, whatever the checkout's
|
|
143
|
+
# sandbox.yml says.
|
|
144
|
+
#
|
|
145
|
+
# @param steps [Array<Hash>] more steps, run after db_prepare
|
|
146
|
+
def installed(start_url: "/", keep_on_failure: false, env: {}, secrets: {}, steps: [])
|
|
147
|
+
new(
|
|
148
|
+
"kind" => "custom",
|
|
149
|
+
"apply" => "always",
|
|
150
|
+
"steps" => [ step("bundle_install", "bundle install", 900), *asset_steps, step("db_prepare", "bin/rails db:prepare", 900),
|
|
151
|
+
*steps ],
|
|
152
|
+
"env" => env,
|
|
153
|
+
"secrets" => secrets,
|
|
154
|
+
"manifest" => { "command" => "bin/rails action_agent:sandbox:manifest", "timeout" => 300 },
|
|
155
|
+
"start" => { "command" => "bin/rails server -b 127.0.0.1 -p $PORT", "timeout" => 300 },
|
|
156
|
+
"start_url" => start_url,
|
|
157
|
+
"keep_on_failure" => keep_on_failure,
|
|
158
|
+
"timeout" => BOOTSTRAP_TIMEOUT
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The steps that write the schema tools in +choices+ after db_prepare,
|
|
163
|
+
# which a checkout booting from its own sandbox.yml runs too (they are
|
|
164
|
+
# `always` steps):
|
|
165
|
+
#
|
|
166
|
+
# [{ "model" => "Reservation", "filterable" => ["status"], "returns" => ["status", "starts_at"] }]
|
|
167
|
+
#
|
|
168
|
+
# The first command removes every app/agent_tools file headed by
|
|
169
|
+
# SCHEMA_TOOLS_MARKER, so a model that is no longer chosen loses its
|
|
170
|
+
# tools. Each chosen model's file is then written by
|
|
171
|
+
# `active_agent:schema_tools --managed`, exposing only the columns
|
|
172
|
+
# chosen, except where a file the dashboard did not write is already
|
|
173
|
+
# there: that file is left as it is. The commands are packed into as
|
|
174
|
+
# few steps as MAX_COMMAND_LENGTH allows, named schema_tools,
|
|
175
|
+
# schema_tools_2 and so on.
|
|
176
|
+
#
|
|
177
|
+
# nil gives no steps. An empty list gives the step that only removes.
|
|
178
|
+
#
|
|
179
|
+
# @raise [Invalid] for a model or column name that is not one, a column
|
|
180
|
+
# that looks like a secret, more than MAX_SCHEMA_TOOL_MODELS models,
|
|
181
|
+
# or choices that need more than MAX_SCHEMA_TOOL_STEPS steps
|
|
182
|
+
def schema_tools_steps(choices)
|
|
183
|
+
return [] if choices.nil?
|
|
184
|
+
|
|
185
|
+
choices = Array(choices)
|
|
186
|
+
raise Invalid, "at most #{MAX_SCHEMA_TOOL_MODELS} models can have schema tools" if choices.size > MAX_SCHEMA_TOOL_MODELS
|
|
187
|
+
|
|
188
|
+
commands = [ remove_managed_tools_command, *choices.map { |choice| schema_tools_command(choice.to_h.stringify_keys) } ]
|
|
189
|
+
packed = commands.each_with_object([]) do |command, chunks|
|
|
190
|
+
if chunks.last && chunks.last.length + command.length + 4 <= MAX_COMMAND_LENGTH
|
|
191
|
+
chunks.last << " && " << command
|
|
192
|
+
else
|
|
193
|
+
chunks << command.dup
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
if packed.size > MAX_SCHEMA_TOOL_STEPS
|
|
197
|
+
raise Invalid, "the chosen models and columns need more than #{MAX_SCHEMA_TOOL_STEPS} boot steps: choose fewer"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
packed.each_with_index.map do |command, index|
|
|
201
|
+
step(index.zero? ? "schema_tools" : "schema_tools_#{index + 1}", command, 900, always: true)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Whether +name+ is one of the steps .schema_tools_steps names.
|
|
206
|
+
def schema_tools_step?(name)
|
|
207
|
+
SCHEMA_TOOLS_STEP.match?(name.to_s)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A sandbox request's boot options, checked and in the shape a job
|
|
211
|
+
# argument carries them. They hold no secrets.
|
|
212
|
+
#
|
|
213
|
+
# @param bootstrap [String, Boolean, nil] "auto" (the default), "always"
|
|
214
|
+
# or "never"; true and false read as "always" and "never"
|
|
215
|
+
# @param start_url [String, nil] a path on the app, "/" when blank
|
|
216
|
+
# @param keep_on_failure [Boolean, String, nil]
|
|
217
|
+
# @return [Hash] { "bootstrap" =>, "start_url" =>, "keep_on_failure" => }
|
|
218
|
+
# @raise [Invalid]
|
|
219
|
+
def request_options(bootstrap: nil, start_url: nil, keep_on_failure: nil)
|
|
220
|
+
mode =
|
|
221
|
+
case bootstrap
|
|
222
|
+
when nil, "" then "auto"
|
|
223
|
+
when true, "true" then "always"
|
|
224
|
+
when false, "false" then "never"
|
|
225
|
+
else bootstrap.to_s
|
|
226
|
+
end
|
|
227
|
+
raise Invalid, "`bootstrap` must be one of #{BOOTSTRAP_MODES.join(", ")}" unless BOOTSTRAP_MODES.include?(mode)
|
|
228
|
+
|
|
229
|
+
url = start_url.presence || "/"
|
|
230
|
+
raise Invalid, "`start_url` must be a path on the app, such as /" unless url.is_a?(String) && start_url_path?(url)
|
|
231
|
+
|
|
232
|
+
{ "bootstrap" => mode, "start_url" => url, "keep_on_failure" => ActiveModel::Type::Boolean.new.cast(keep_on_failure) == true }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The spec .request_options ask for: nil for "never", and for "auto" a
|
|
236
|
+
# bootstrap that applies only to a checkout without the engine.
|
|
237
|
+
#
|
|
238
|
+
# @raise [Invalid] as .bootstrap does
|
|
239
|
+
def for_request(options)
|
|
240
|
+
options = request_options(**options.to_h.symbolize_keys.slice(:bootstrap, :start_url, :keep_on_failure))
|
|
241
|
+
return nil if options["bootstrap"] == "never"
|
|
242
|
+
|
|
243
|
+
bootstrap(apply: options["bootstrap"] == "always" ? "always" : "without_engine",
|
|
244
|
+
start_url: options["start_url"], keep_on_failure: options["keep_on_failure"])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def start_url_path?(url)
|
|
248
|
+
url.start_with?("/") && !url.start_with?("//") && url.length <= MAX_START_URL_LENGTH && url.match?(/\A[[:graph:]]+\z/)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Where a bootstrap takes activeagent and actionagent from: wherever
|
|
252
|
+
# this dashboard's own bundle has them. A gem from rubygems.org is
|
|
253
|
+
# added at this process's version (`~>`), one from git at the same
|
|
254
|
+
# revision, and one from a path at the same path, which only a backend
|
|
255
|
+
# on this machine can reach.
|
|
256
|
+
#
|
|
257
|
+
# { "activeagent" => { "source" => "rubygems", "version" => "1.9.0" },
|
|
258
|
+
# "actionagent" => { "source" => "path", "path" => "/src/activeagent/actionagent" } }
|
|
259
|
+
#
|
|
260
|
+
# @param locked_gems [Bundler::LockfileParser, nil] the dashboard's lock;
|
|
261
|
+
# nil when it runs without Bundler
|
|
262
|
+
# @raise [Invalid] for a git URL with credentials in it, which would end
|
|
263
|
+
# up in the checkout's Gemfile and logs
|
|
264
|
+
def engine_gems(locked_gems = default_locked_gems)
|
|
265
|
+
specs = locked_gems.respond_to?(:specs) ? locked_gems.specs.to_a : []
|
|
266
|
+
{ "activeagent" => ActiveAgent::VERSION, "actionagent" => ActionAgent::VERSION }.to_h do |name, version|
|
|
267
|
+
[ name, gem_source(name, specs.find { |spec| spec.name == name }&.source, version) ]
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
def default_locked_gems
|
|
274
|
+
defined?(::Bundler) ? ::Bundler.locked_gems : nil
|
|
275
|
+
rescue StandardError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def gem_source(name, source, version)
|
|
280
|
+
if defined?(::Bundler::Source::Git) && source.is_a?(::Bundler::Source::Git)
|
|
281
|
+
uri = source.uri.to_s
|
|
282
|
+
if CREDENTIALED_URL.match?(uri)
|
|
283
|
+
raise Invalid, "the dashboard bundles #{name} from a git URL with credentials in it, which a sandbox " \
|
|
284
|
+
"would write into the checkout's Gemfile"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
{ "source" => "git", "uri" => uri, "ref" => source.revision.to_s }
|
|
288
|
+
elsif defined?(::Bundler::Source::Path) && source.is_a?(::Bundler::Source::Path)
|
|
289
|
+
{ "source" => "path", "path" => source.expanded_original_path.to_s }
|
|
290
|
+
else
|
|
291
|
+
{ "source" => "rubygems", "version" => version }
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def bootstrap_steps(engine)
|
|
296
|
+
framework = engine.fetch("activeagent")
|
|
297
|
+
steps = [
|
|
298
|
+
step("bundle_config", "bundle config set --local frozen false", 60),
|
|
299
|
+
step("bundle_install", "bundle install", 900)
|
|
300
|
+
]
|
|
301
|
+
# From rubygems.org the engine brings the framework with it. From git
|
|
302
|
+
# or a path the framework must come from the same place.
|
|
303
|
+
if framework["source"] != "rubygems"
|
|
304
|
+
steps << step("add_framework", add_gem("activeagent", framework, skip_install: true), 300, unless_locked: "activeagent")
|
|
305
|
+
end
|
|
306
|
+
steps << step("add_engine", add_gem("actionagent", engine.fetch("actionagent")), 900, unless_locked: "actionagent")
|
|
307
|
+
# --skip: run with no terminal, Thor would take end of input as
|
|
308
|
+
# "overwrite" for every file that already exists.
|
|
309
|
+
steps << step("install_framework", "bin/rails generate active_agent:install --skip", 300, unless_locked: "activeagent")
|
|
310
|
+
steps << step("install_engine", "bin/rails generate action_agent:install --skip", 300, unless_locked: "actionagent")
|
|
311
|
+
steps.concat(asset_steps)
|
|
312
|
+
steps << step("db_prepare", "bin/rails db:prepare", 900)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def asset_steps
|
|
316
|
+
ASSET_TASKS.map { |task| step(task.tr(":", "_"), "bin/rails #{task}", 600, if_task: task) }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def remove_managed_tools_command
|
|
320
|
+
find = [ "find", SCHEMA_TOOLS_DIR, "-type", "f", "-name", "*_tools.rb", "-exec", "grep", "-qF", SCHEMA_TOOLS_MARKER, "{}", ";",
|
|
321
|
+
"-exec", "rm", "-f", "{}", ";" ]
|
|
322
|
+
"[ ! -d #{SCHEMA_TOOLS_DIR} ] || #{find.shelljoin}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Writes +choice+'s file unless one is there: after
|
|
326
|
+
# remove_managed_tools_command, only a file the dashboard did not
|
|
327
|
+
# write can be.
|
|
328
|
+
def schema_tools_command(choice)
|
|
329
|
+
model = choice["model"].to_s
|
|
330
|
+
raise Invalid, "#{model.truncate(60).inspect} is not a model name" unless MODEL_NAME.match?(model)
|
|
331
|
+
|
|
332
|
+
path = "#{SCHEMA_TOOLS_DIR}/#{model.underscore}_tools.rb"
|
|
333
|
+
words = [ "bin/rails", "generate", "active_agent:schema_tools", model, "--force", "--managed" ]
|
|
334
|
+
%w[filterable returns].each do |option|
|
|
335
|
+
columns = schema_tool_columns(choice[option], model, option)
|
|
336
|
+
words.push("--#{option}", *columns) if columns.any?
|
|
337
|
+
end
|
|
338
|
+
skip = [ "echo", "#{path} was not written by the dashboard, so it is left as it is" ].shelljoin
|
|
339
|
+
command = "if [ -e #{path} ]; then #{skip}; else #{words.shelljoin}; fi"
|
|
340
|
+
if command.length > MAX_COMMAND_LENGTH
|
|
341
|
+
raise Invalid, "#{model} has more columns chosen than one boot step can hold: choose fewer"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
command
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def schema_tool_columns(value, model, option)
|
|
348
|
+
columns = Array(value).map(&:to_s).uniq
|
|
349
|
+
raise Invalid, "#{model} has more than #{MAX_SCHEMA_TOOL_COLUMNS} #{option} columns" if columns.size > MAX_SCHEMA_TOOL_COLUMNS
|
|
350
|
+
|
|
351
|
+
columns.each do |column|
|
|
352
|
+
raise Invalid, "#{column.truncate(60).inspect} is not a column name" unless COLUMN_NAME.match?(column)
|
|
353
|
+
if SandboxManifest::SECRET_COLUMNS.match?(column)
|
|
354
|
+
raise Invalid, "#{model}.#{column} looks like it holds a secret, so no tool may read it"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
columns
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def step(name, command, timeout, **conditions)
|
|
361
|
+
{ "name" => name, "command" => command, "timeout" => timeout, **conditions.transform_keys(&:to_s) }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def add_gem(name, source, skip_install: false)
|
|
365
|
+
words = [ "bundle add #{name}" ]
|
|
366
|
+
words <<
|
|
367
|
+
case source["source"]
|
|
368
|
+
when "git" then "--git #{Shellwords.escape(source.fetch("uri"))} --ref #{Shellwords.escape(source.fetch("ref"))}"
|
|
369
|
+
when "path" then "--path #{Shellwords.escape(source.fetch("path"))}"
|
|
370
|
+
else %(--version "~> #{source.fetch("version")}")
|
|
371
|
+
end
|
|
372
|
+
words << "--skip-install" if skip_install
|
|
373
|
+
words.join(" ")
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# @param data [Hash] #to_h's shape
|
|
378
|
+
# @raise [Invalid]
|
|
379
|
+
def initialize(data)
|
|
380
|
+
raise Invalid, "a boot spec is a Hash, not #{data.class}" unless data.is_a?(Hash)
|
|
381
|
+
|
|
382
|
+
data = data.deep_stringify_keys
|
|
383
|
+
@kind = one_of(data.fetch("kind", "custom"), KINDS, "kind")
|
|
384
|
+
@apply = one_of(data.fetch("apply", "always"), APPLY_MODES, "apply")
|
|
385
|
+
@preflight = data["preflight"] == true
|
|
386
|
+
@steps = parse_steps(data["steps"])
|
|
387
|
+
@env = parse_env(data["env"], "env")
|
|
388
|
+
@secrets = parse_env(data["secrets"], "secrets")
|
|
389
|
+
refused = @secrets.keys.grep(REFUSED_SECRET_NAME)
|
|
390
|
+
raise Invalid, "secrets may not set #{refused.join(", ")}" if refused.any?
|
|
391
|
+
|
|
392
|
+
@secret_names = (Array(data["secret_names"]).map(&:to_s) | @secrets.keys).freeze
|
|
393
|
+
@manifest = parse_command(data["manifest"], "manifest", "bin/rails action_agent:sandbox:manifest")
|
|
394
|
+
@start = parse_command(data["start"], "start", "bin/rails server -b 127.0.0.1 -p $PORT")
|
|
395
|
+
@start_url = parse_start_url(data.fetch("start_url", "/"))
|
|
396
|
+
@keep_on_failure = data["keep_on_failure"] == true
|
|
397
|
+
@timeout = parse_timeout(data.fetch("timeout", @kind == "bootstrap" ? BOOTSTRAP_TIMEOUT : DEFAULT_STEP_TIMEOUT), "timeout")
|
|
398
|
+
if @kind == "bootstrap" && @timeout < BOOTSTRAP_TIMEOUT
|
|
399
|
+
raise Invalid, "a bootstrap boot needs a timeout of at least #{BOOTSTRAP_TIMEOUT}s, not #{@timeout}s"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
@engine = data["engine"].is_a?(Hash) ? data["engine"] : nil
|
|
403
|
+
freeze
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def preflight?
|
|
407
|
+
@preflight
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def keep_on_failure?
|
|
411
|
+
@keep_on_failure
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def bootstrap?
|
|
415
|
+
@kind == "bootstrap"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Whether the spec applies only to a checkout without the engine.
|
|
419
|
+
def without_engine_only?
|
|
420
|
+
@apply == "without_engine"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# The steps a checkout the spec does not apply to runs after its
|
|
424
|
+
# sandbox.yml setup.
|
|
425
|
+
def always_steps
|
|
426
|
+
@steps.select { |step| step["always"] }
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Secret names the spec names without carrying their values: a
|
|
430
|
+
# #redacted spec read back.
|
|
431
|
+
def missing_secrets
|
|
432
|
+
@secret_names - @secrets.keys
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# The environment the steps, manifest and start add.
|
|
436
|
+
def step_environment
|
|
437
|
+
@env.merge(@secrets)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# What scrubbing masks.
|
|
441
|
+
def secret_values
|
|
442
|
+
@secrets.values
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# The whole spec, secrets included: for a backend, in memory.
|
|
446
|
+
def to_h
|
|
447
|
+
{
|
|
448
|
+
"kind" => @kind, "apply" => @apply, "preflight" => @preflight,
|
|
449
|
+
"steps" => @steps.map(&:dup), "env" => @env.dup, "secrets" => @secrets.dup,
|
|
450
|
+
"manifest" => @manifest.dup, "start" => @start.dup, "start_url" => @start_url,
|
|
451
|
+
"keep_on_failure" => @keep_on_failure, "timeout" => @timeout, "engine" => @engine
|
|
452
|
+
}.compact
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# #to_h without the secrets' values: what may be stored or logged.
|
|
456
|
+
def redacted
|
|
457
|
+
to_h.except("secrets").merge("secret_names" => @secret_names.dup)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private
|
|
461
|
+
|
|
462
|
+
def one_of(value, allowed, key)
|
|
463
|
+
value = value.to_s
|
|
464
|
+
raise Invalid, "`#{key}` must be one of #{allowed.join(", ")}, not #{value.inspect}" unless allowed.include?(value)
|
|
465
|
+
|
|
466
|
+
value
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def parse_steps(value)
|
|
470
|
+
return [].freeze if value.nil?
|
|
471
|
+
raise Invalid, "`steps` must be a list" unless value.is_a?(Array)
|
|
472
|
+
raise Invalid, "`steps` may hold at most #{MAX_STEPS} steps" if value.size > MAX_STEPS
|
|
473
|
+
|
|
474
|
+
steps = value.map { |entry| parse_step(entry) }
|
|
475
|
+
duplicate = steps.map { |step| step["name"] }.tally.find { |_name, count| count > 1 }&.first
|
|
476
|
+
raise Invalid, "step names must be unique (#{duplicate} is not)" if duplicate
|
|
477
|
+
|
|
478
|
+
steps.each(&:freeze).freeze
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def parse_step(entry)
|
|
482
|
+
raise Invalid, "each step must be a mapping" unless entry.is_a?(Hash)
|
|
483
|
+
|
|
484
|
+
name = entry["name"].to_s
|
|
485
|
+
unless STEP_NAME.match?(name) && !RESERVED_STEP_NAMES.include?(name)
|
|
486
|
+
raise Invalid, "#{entry["name"].inspect} is not a step name (lowercase, digits and _; not #{RESERVED_STEP_NAMES.join(", ")})"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
step = { "name" => name, "command" => command!(entry["command"], "step #{name}"),
|
|
490
|
+
"timeout" => parse_timeout(entry.fetch("timeout", DEFAULT_STEP_TIMEOUT), "step #{name}'s timeout") }
|
|
491
|
+
if entry.key?("unless_locked")
|
|
492
|
+
raise Invalid, "step #{name}'s unless_locked must name a gem" unless GEM_NAME.match?(entry["unless_locked"].to_s)
|
|
493
|
+
|
|
494
|
+
step["unless_locked"] = entry["unless_locked"].to_s
|
|
495
|
+
end
|
|
496
|
+
if entry.key?("if_task")
|
|
497
|
+
raise Invalid, "step #{name}'s if_task must name a Rake task" unless TASK_NAME.match?(entry["if_task"].to_s)
|
|
498
|
+
|
|
499
|
+
step["if_task"] = entry["if_task"].to_s
|
|
500
|
+
end
|
|
501
|
+
if entry.key?("always")
|
|
502
|
+
raise Invalid, "step #{name}'s always must be true or false" unless [ true, false ].include?(entry["always"])
|
|
503
|
+
if entry["always"] && step.key?("unless_locked")
|
|
504
|
+
raise Invalid, "step #{name} runs always, so it cannot be skipped by what the checkout locks"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
step["always"] = true if entry["always"]
|
|
508
|
+
end
|
|
509
|
+
step
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def parse_command(value, key, default)
|
|
513
|
+
return { "command" => default, "timeout" => DEFAULT_STEP_TIMEOUT }.freeze if value.nil?
|
|
514
|
+
|
|
515
|
+
value = { "command" => value } if value.is_a?(String)
|
|
516
|
+
raise Invalid, "`#{key}` must be a command" unless value.is_a?(Hash)
|
|
517
|
+
|
|
518
|
+
{ "command" => command!(value["command"], key),
|
|
519
|
+
"timeout" => parse_timeout(value.fetch("timeout", DEFAULT_STEP_TIMEOUT), "#{key}'s timeout") }.freeze
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def command!(value, what)
|
|
523
|
+
unless value.is_a?(String) && value.strip.present? && value.length <= MAX_COMMAND_LENGTH && !value.include?("\0")
|
|
524
|
+
raise Invalid, "#{what} needs a command (a string of at most #{MAX_COMMAND_LENGTH} characters)"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
value
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def parse_timeout(value, what)
|
|
531
|
+
seconds = Integer(value, exception: false) if value.is_a?(Integer) || value.is_a?(String)
|
|
532
|
+
raise Invalid, "#{what} must be a number of seconds from 1 to #{MAX_TIMEOUT}" unless seconds&.between?(1, MAX_TIMEOUT)
|
|
533
|
+
|
|
534
|
+
seconds
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def parse_env(value, key)
|
|
538
|
+
return {}.freeze if value.nil?
|
|
539
|
+
raise Invalid, "`#{key}` must map variable names to strings" unless value.is_a?(Hash)
|
|
540
|
+
|
|
541
|
+
value.to_h do |name, setting|
|
|
542
|
+
scalar = setting.is_a?(String) || setting.is_a?(Numeric) || setting == true || setting == false
|
|
543
|
+
unless scalar && ENV_NAME.match?(name.to_s) && !setting.to_s.include?("\0")
|
|
544
|
+
raise Invalid, "`#{key}` must map variable names to strings (#{name.inspect} does not)"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
[ name.to_s, setting.to_s ]
|
|
548
|
+
end.freeze
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def parse_start_url(value)
|
|
552
|
+
url = value.to_s
|
|
553
|
+
raise Invalid, "`start_url` must be a path on the app, such as /" unless self.class.start_url_path?(url)
|
|
554
|
+
|
|
555
|
+
url
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|