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,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to publish what a project's bootstrap wrote into its checkout as a
|
|
5
|
+
# pull request that installs the engine in the repository: the install
|
|
6
|
+
# pull request. Publishing goes through DraftPullRequestPublisher, from the
|
|
7
|
+
# project's live sandbox, and takes only the paths of #allowlist:
|
|
8
|
+
#
|
|
9
|
+
# Gemfile, Gemfile.lock
|
|
10
|
+
# config/initializers/action_agent.rb, config/routes.rb
|
|
11
|
+
# config/active_agent.yml and app/agents/application_agent.rb, when the
|
|
12
|
+
# bootstrap generated them (the repository locked no activeagent)
|
|
13
|
+
# the migrations `action_agent:install` emits, by exact name after the
|
|
14
|
+
# timestamp (ENGINE_MIGRATIONS and the numbered templates)
|
|
15
|
+
# db/schema.rb or db/structure.sql
|
|
16
|
+
# app/agent_tools/<model>_tools.rb for every model the App assistant was
|
|
17
|
+
# ever given (Project#schema_tools_models), so that a file a boot
|
|
18
|
+
# removed can be removed on the branch too
|
|
19
|
+
# .activeagents/sandbox.yml and .activeagents/evals/<project>.yml
|
|
20
|
+
# whatever a Claude Code boot fix changed to get the boot through
|
|
21
|
+
# (ProjectBootFix.changed_paths_for)
|
|
22
|
+
#
|
|
23
|
+
# The last two are generated here rather than read from the checkout (see
|
|
24
|
+
# #generated_files): sandbox.yml holds the setup that booted the project
|
|
25
|
+
# and the names of its secrets, never their values, and the evaluation
|
|
26
|
+
# suite holds the project evaluation's enabled scenarios in the shape
|
|
27
|
+
# ActiveAgent::Evals::Suite.load reads. Anything else the sandbox changed,
|
|
28
|
+
# .github/ and any file holding one of the project's secrets is refused by
|
|
29
|
+
# the publisher.
|
|
30
|
+
#
|
|
31
|
+
# A publish takes every REQUIRED_PATHS file and engine migration the
|
|
32
|
+
# sandbox changed (#missing_required_paths): every boot of the branch
|
|
33
|
+
# installs nothing, so it boots only with all of them.
|
|
34
|
+
class ProjectInstallPullRequest
|
|
35
|
+
SANDBOX_CONFIG = ".activeagents/sandbox.yml"
|
|
36
|
+
STATIC_PATHS = [
|
|
37
|
+
"Gemfile", "Gemfile.lock", "config/initializers/action_agent.rb", "config/routes.rb", "db/schema.rb", "db/structure.sql",
|
|
38
|
+
SANDBOX_CONFIG
|
|
39
|
+
].freeze
|
|
40
|
+
# What `active_agent:install` writes into a repository without the
|
|
41
|
+
# framework.
|
|
42
|
+
FRAMEWORK_PATHS = %w[config/active_agent.yml app/agents/application_agent.rb].freeze
|
|
43
|
+
# The migrations `action_agent:install` emits by name, beside its
|
|
44
|
+
# numbered templates (ActionAgent::InstallGenerator).
|
|
45
|
+
ENGINE_MIGRATIONS = %w[
|
|
46
|
+
create_active_agent_telemetry_traces add_agent_id_to_active_agent_telemetry_traces add_agent_releases
|
|
47
|
+
create_active_agent_dashboard_tables ensure_agent_release_columns create_active_agent_evaluation_scenarios
|
|
48
|
+
add_evaluation_report_identity add_provider_key_api_key create_active_agent_github_connections
|
|
49
|
+
create_active_agent_code_sessions add_code_session_runner
|
|
50
|
+
].freeze
|
|
51
|
+
# What a boot of the branch, which installs nothing, needs to load the
|
|
52
|
+
# engine.
|
|
53
|
+
REQUIRED_PATHS = [
|
|
54
|
+
"Gemfile", "Gemfile.lock", "config/initializers/action_agent.rb", "config/routes.rb", "db/schema.rb", "db/structure.sql"
|
|
55
|
+
].freeze
|
|
56
|
+
NUMBERED_MIGRATION = /\A\d{3}_(?<name>[a-z0-9_]+)\.rb\.erb\z/
|
|
57
|
+
NUMBERED_MIGRATIONS_PATH = File.expand_path("../../../lib/generators/action_agent/templates/migrations", __dir__)
|
|
58
|
+
DEFAULT_BRANCH = "activeagent/install-engine"
|
|
59
|
+
DEFAULT_SETUP = [ "bundle install", "bin/rails db:prepare" ].freeze
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
# The names of every migration `action_agent:install` emits.
|
|
63
|
+
def engine_migration_names
|
|
64
|
+
numbered = Dir.children(NUMBERED_MIGRATIONS_PATH).sort.filter_map { |file| NUMBERED_MIGRATION.match(file)&.[](:name) }
|
|
65
|
+
ENGINE_MIGRATIONS + numbered
|
|
66
|
+
rescue Errno::ENOENT
|
|
67
|
+
ENGINE_MIGRATIONS
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The publisher a queued +record+ is published with: the project's,
|
|
71
|
+
# with its generated files, when +record+ is a project's install pull
|
|
72
|
+
# request, and a plain one otherwise.
|
|
73
|
+
def publisher_for(record, user: nil)
|
|
74
|
+
project = record.sandbox_session&.project
|
|
75
|
+
if project && project.settings["install_pull_request_id"] == record.id
|
|
76
|
+
new(project, user: user).publisher(record.sandbox_session)
|
|
77
|
+
else
|
|
78
|
+
DraftPullRequestPublisher.new(record.sandbox_session, user: user)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attr_reader :project
|
|
84
|
+
|
|
85
|
+
def initialize(project, user: nil, orchestrator: SandboxOrchestrator.new)
|
|
86
|
+
@project = project
|
|
87
|
+
@user = user
|
|
88
|
+
@orchestrator = orchestrator
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The patterns DraftPullRequestPublisher#changes takes for the project.
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<String, Regexp>]
|
|
94
|
+
def allowlist
|
|
95
|
+
paths = STATIC_PATHS.dup
|
|
96
|
+
paths.concat(FRAMEWORK_PATHS) if framework_generated?
|
|
97
|
+
paths.concat(project.schema_tools_models.map { |model| "#{SandboxBootSpec::SCHEMA_TOOLS_DIR}/#{model.underscore}_tools.rb" })
|
|
98
|
+
paths << evaluation_path
|
|
99
|
+
paths << engine_migration_pattern
|
|
100
|
+
paths.concat(ProjectBootFix.changed_paths_for(project).map { |path| exact_path(path) })
|
|
101
|
+
paths.uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The REQUIRED_PATHS and engine migrations +publisher+'s sandbox changed
|
|
105
|
+
# that +paths+ leaves out.
|
|
106
|
+
#
|
|
107
|
+
# @return [Array<String>]
|
|
108
|
+
def missing_required_paths(publisher, paths)
|
|
109
|
+
chosen = paths.to_set
|
|
110
|
+
publisher.changed_paths.select do |path|
|
|
111
|
+
(REQUIRED_PATHS.include?(path) || engine_migration_pattern.match?(path)) && !chosen.include?(path)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Where the project evaluation's suite is published.
|
|
116
|
+
def evaluation_path
|
|
117
|
+
".activeagents/evals/#{project.name.parameterize.presence || "project-#{project.id}"}.yml"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The publisher for +sandbox+ with the project's generated files.
|
|
121
|
+
def publisher(sandbox = project.current_sandbox_session)
|
|
122
|
+
DraftPullRequestPublisher.new(sandbox, user: @user, orchestrator: @orchestrator, generated_files: generated_files(sandbox))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# What the dashboard writes itself: sandbox.yml, and the evaluation
|
|
126
|
+
# suite when the project's evaluation has enabled scenarios.
|
|
127
|
+
#
|
|
128
|
+
# @return [Hash{String => String}]
|
|
129
|
+
def generated_files(sandbox = project.current_sandbox_session)
|
|
130
|
+
files = { SANDBOX_CONFIG => sandbox_config(sandbox) }
|
|
131
|
+
suite = evaluation_suite
|
|
132
|
+
files[evaluation_path] = suite if suite
|
|
133
|
+
files
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The project's sandbox.yml: the checkout's own, if it has one, with the
|
|
137
|
+
# setup that booted the project and the names of the project's secrets.
|
|
138
|
+
# The values the setup assistant set are not secrets, and stay with the
|
|
139
|
+
# project: a boot passes them as env.
|
|
140
|
+
def sandbox_config(sandbox)
|
|
141
|
+
data = base_sandbox_config(sandbox)
|
|
142
|
+
data["setup"] = setup_commands(sandbox)
|
|
143
|
+
names = Array(data["secrets"]).map(&:to_s) | project.secrets.ordered.reject(&:plain?).map(&:name)
|
|
144
|
+
data["secrets"] = names if names.any?
|
|
145
|
+
|
|
146
|
+
<<~YAML + data.to_yaml.delete_prefix("---\n")
|
|
147
|
+
# How an ActiveAgent checkout sandbox boots this app: the setup commands,
|
|
148
|
+
# and the names of the environment variables it needs. Their values are
|
|
149
|
+
# never written here: a sandbox is given them when it boots.
|
|
150
|
+
YAML
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The project evaluation's enabled scenarios as an evaluation suite
|
|
154
|
+
# (ActiveAgent::Evals::Suite), grouped as the dashboard groups them; nil
|
|
155
|
+
# when there are none.
|
|
156
|
+
def evaluation_suite
|
|
157
|
+
evaluation = project.evaluation
|
|
158
|
+
scenarios = evaluation ? evaluation.scenarios.enabled.ordered.to_a : []
|
|
159
|
+
return nil if scenarios.empty?
|
|
160
|
+
|
|
161
|
+
groups = scenarios.group_by { |scenario| scenario.group.presence || "default" }.map do |group, members|
|
|
162
|
+
{ "key" => group, "name" => group, "scenarios" => members.map { |scenario| suite_entry(scenario) } }
|
|
163
|
+
end
|
|
164
|
+
document = {
|
|
165
|
+
"suite" => evaluation_path.delete_prefix(".activeagents/evals/").delete_suffix(".yml"),
|
|
166
|
+
"description" => "#{evaluation.name}: the scenarios #{project.repository}'s project evaluation replays",
|
|
167
|
+
"groups" => groups
|
|
168
|
+
}
|
|
169
|
+
document.to_yaml
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# +path+ as a pattern File.fnmatch matches only itself.
|
|
175
|
+
def exact_path(path)
|
|
176
|
+
path.gsub(/[*?\[\]{}\\]/) { |char| "\\#{char}" }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def engine_migration_pattern
|
|
180
|
+
@engine_migration_pattern ||= begin
|
|
181
|
+
names = self.class.engine_migration_names.map { |name| Regexp.escape(name) }.join("|")
|
|
182
|
+
%r{\Adb/migrate/\d+_(?:#{names})\.rb\z}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether the bootstrap wrote the framework's files: the repository's
|
|
187
|
+
# lock had no activeagent when the project picked it.
|
|
188
|
+
def framework_generated?
|
|
189
|
+
project.settings.dig("preflight", "activeagent").blank?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def suite_entry(scenario)
|
|
193
|
+
expect = {
|
|
194
|
+
"tools" => scenario.expected_tools.presence,
|
|
195
|
+
"contains" => scenario.expected_patterns.presence,
|
|
196
|
+
"not_contains" => scenario.forbidden_patterns.presence
|
|
197
|
+
}.compact
|
|
198
|
+
{ "key" => scenario.key, "prompt" => scenario.prompt, "expect" => expect.presence, "notes" => scenario.notes.presence }.compact
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def base_sandbox_config(sandbox)
|
|
202
|
+
content = sandbox && @orchestrator.read_file(sandbox, SANDBOX_CONFIG, base: true)
|
|
203
|
+
data = content ? YAML.safe_load(content.force_encoding(Encoding::UTF_8), aliases: false) : nil
|
|
204
|
+
data.is_a?(Hash) ? data : {}
|
|
205
|
+
rescue StandardError
|
|
206
|
+
{}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The commands of the boot steps that succeeded, as the checkout's own
|
|
210
|
+
# sandbox.yml boot runs them: without the steps that installed the
|
|
211
|
+
# engine and wrote the schema tools, which the pull request carries.
|
|
212
|
+
def setup_commands(sandbox)
|
|
213
|
+
status = sandbox && @orchestrator.supports?(:boot_status) ? @orchestrator.boot_status(sandbox) : nil
|
|
214
|
+
succeeded = Array(status&.dig(:steps)).filter_map { |step| step[:name] if step[:status] == "succeeded" }.to_set
|
|
215
|
+
commands = project.boot_spec(sandbox).steps.filter_map do |step|
|
|
216
|
+
next if SandboxBootSpec::INSTALL_STEPS.include?(step["name"]) || SandboxBootSpec.schema_tools_step?(step["name"])
|
|
217
|
+
|
|
218
|
+
step["command"] if succeeded.include?(step["name"])
|
|
219
|
+
end
|
|
220
|
+
commands.presence || DEFAULT_SETUP.dup
|
|
221
|
+
rescue StandardError
|
|
222
|
+
DEFAULT_SETUP.dup
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to decide whether a repository can become a project before any
|
|
5
|
+
# sandbox exists, from files read through GitHub's contents API: its
|
|
6
|
+
# Gemfile.lock, .ruby-version, config/application.rb, config/database.yml
|
|
7
|
+
# and Gemfile. It applies the requirements a bootstrap boot's own preflight
|
|
8
|
+
# applies after checkout (LocalSandboxBackend), so a repository refused
|
|
9
|
+
# here would be refused there.
|
|
10
|
+
#
|
|
11
|
+
# Nothing of the repository runs: the lock is parsed with Bundler's parser,
|
|
12
|
+
# and the other files are only matched against.
|
|
13
|
+
#
|
|
14
|
+
# The files are read at the commit the ref names, and the report is kept in
|
|
15
|
+
# Rails.cache for that commit, so checking a repository again costs one
|
|
16
|
+
# GitHub call until it changes.
|
|
17
|
+
class ProjectPreflight
|
|
18
|
+
STATUSES = %w[supported bootstrap unsupported].freeze
|
|
19
|
+
CACHE_TTL = 1.day
|
|
20
|
+
SUMMARIES = {
|
|
21
|
+
"supported" => "Supported",
|
|
22
|
+
"bootstrap" => "Supported: installs the engine in the sandbox"
|
|
23
|
+
}.freeze
|
|
24
|
+
# Gems whose service a sandbox does not run, with the service's name.
|
|
25
|
+
SERVICE_GEMS = {
|
|
26
|
+
"redis" => "Redis",
|
|
27
|
+
"sidekiq" => "Redis (for Sidekiq)",
|
|
28
|
+
"resque" => "Redis (for Resque)",
|
|
29
|
+
"elasticsearch" => "Elasticsearch",
|
|
30
|
+
"elasticsearch-model" => "Elasticsearch",
|
|
31
|
+
"searchkick" => "Elasticsearch or OpenSearch (for Searchkick)",
|
|
32
|
+
"opensearch-ruby" => "OpenSearch"
|
|
33
|
+
}.freeze
|
|
34
|
+
ERB_TAG = /<%.*?%>/m
|
|
35
|
+
|
|
36
|
+
# @param client [GithubClient]
|
|
37
|
+
# @param repository [String] owner/name
|
|
38
|
+
# @param ref [String] the branch, tag or commit to read
|
|
39
|
+
# @return [Hash] see #call, with +ref+ as asked and +commit+, the SHA it
|
|
40
|
+
# named. A ref naming no commit is unsupported, and nothing is read.
|
|
41
|
+
def self.call(client, repository:, ref:)
|
|
42
|
+
commit = client.commit_sha(repository, ref)
|
|
43
|
+
return no_commit(repository, ref) if commit.nil?
|
|
44
|
+
|
|
45
|
+
report = Rails.cache.fetch([ name, ActionAgent::VERSION, repository, commit ], expires_in: CACHE_TTL) do
|
|
46
|
+
new(client, repository: repository, ref: commit).call
|
|
47
|
+
end
|
|
48
|
+
report.deep_stringify_keys.merge("ref" => ref, "commit" => commit)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.no_commit(repository, ref)
|
|
52
|
+
reason = "#{repository} has no commit at #{ref.presence || "its default branch"}"
|
|
53
|
+
{
|
|
54
|
+
"status" => "unsupported", "summary" => reason, "reasons" => [ reason ], "warnings" => [], "engine" => false,
|
|
55
|
+
"ruby" => nil, "ruby_source" => nil, "railties" => nil, "activeagent" => nil, "actionagent" => nil,
|
|
56
|
+
"database_adapters" => [], "services" => [], "ref" => ref, "commit" => nil, "checked_at" => Time.current.iso8601
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(client, repository:, ref:)
|
|
61
|
+
@client = client
|
|
62
|
+
@repository = repository
|
|
63
|
+
@ref = ref
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash] with string keys:
|
|
67
|
+
# status one of STATUSES
|
|
68
|
+
# summary what the picker shows: SUMMARIES' text, or the
|
|
69
|
+
# first reason the repository is not supported
|
|
70
|
+
# reasons why it is not supported (empty otherwise)
|
|
71
|
+
# warnings services and databases the sandbox may lack
|
|
72
|
+
# engine whether the Gemfile.lock locks actionagent
|
|
73
|
+
# ruby, ruby_source the Ruby it needs and where that was read
|
|
74
|
+
# railties, activeagent, actionagent the locked versions, or nil
|
|
75
|
+
# database_adapters the adapters config/database.yml names
|
|
76
|
+
# services what SERVICE_GEMS found
|
|
77
|
+
# ref, checked_at
|
|
78
|
+
def call
|
|
79
|
+
lock_text = read("Gemfile.lock")
|
|
80
|
+
facts = lock_text ? lock_facts(lock_text) : nil
|
|
81
|
+
ruby, ruby_source = ruby_requirement(facts)
|
|
82
|
+
railties = facts&.dig("gems", "railties")
|
|
83
|
+
reasons = refusals(lock_text, facts, ruby, ruby_source, railties)
|
|
84
|
+
adapters = database_adapters
|
|
85
|
+
services = required_services(facts)
|
|
86
|
+
engine = facts&.dig("gems", "actionagent").present?
|
|
87
|
+
status = if reasons.any? then "unsupported" elsif engine then "supported" else "bootstrap" end
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
"status" => status,
|
|
91
|
+
"summary" => reasons.first || SUMMARIES.fetch(status),
|
|
92
|
+
"reasons" => reasons,
|
|
93
|
+
"warnings" => warnings(adapters, services),
|
|
94
|
+
"engine" => engine,
|
|
95
|
+
"ruby" => ruby,
|
|
96
|
+
"ruby_source" => ruby_source,
|
|
97
|
+
"railties" => railties,
|
|
98
|
+
"activeagent" => facts&.dig("gems", "activeagent"),
|
|
99
|
+
"actionagent" => facts&.dig("gems", "actionagent"),
|
|
100
|
+
"database_adapters" => adapters,
|
|
101
|
+
"services" => services,
|
|
102
|
+
"ref" => @ref,
|
|
103
|
+
"checked_at" => Time.current.iso8601
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def read(path)
|
|
110
|
+
@files ||= {}
|
|
111
|
+
return @files[path] if @files.key?(path)
|
|
112
|
+
|
|
113
|
+
@files[path] = @client.file(@repository, path, ref: @ref)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def refusals(lock_text, facts, ruby, ruby_source, railties)
|
|
117
|
+
return [ "#{@repository} has no Gemfile.lock at its root: a sandbox needs a bundled Rails app" ] if lock_text.nil?
|
|
118
|
+
return [ "#{@repository}'s Gemfile.lock could not be read" ] if facts.nil?
|
|
119
|
+
|
|
120
|
+
reasons = []
|
|
121
|
+
if ruby && below?(ruby, SandboxBootSpec::MINIMUM_RUBY)
|
|
122
|
+
reasons << "#{@repository} needs Ruby #{ruby} (#{ruby_source}); the engine needs Ruby #{SandboxBootSpec::MINIMUM_RUBY} or later"
|
|
123
|
+
end
|
|
124
|
+
if railties.nil?
|
|
125
|
+
reasons << "#{@repository}'s Gemfile.lock locks no railties: a sandbox needs a Rails app"
|
|
126
|
+
elsif below?(railties, SandboxBootSpec::MINIMUM_RAILTIES)
|
|
127
|
+
reasons << "#{@repository} locks railties #{railties}; the engine needs Rails #{SandboxBootSpec::MINIMUM_RAILTIES} or later"
|
|
128
|
+
end
|
|
129
|
+
if read("config/application.rb").nil?
|
|
130
|
+
reasons << "#{@repository} has no config/application.rb at its root: a sandbox needs the Rails app at the repository root"
|
|
131
|
+
end
|
|
132
|
+
reasons
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# { "ruby" => "3.3.6" or nil, "gems" => { name => version } }, or nil
|
|
136
|
+
# when the lock does not parse.
|
|
137
|
+
def lock_facts(text)
|
|
138
|
+
parser = ::Bundler::LockfileParser.new(text)
|
|
139
|
+
gems = parser.specs.each_with_object({}) { |spec, all| all[spec.name] ||= spec.version.to_s }
|
|
140
|
+
{ "ruby" => parser.ruby_version.to_s[/\d+\.\d+(?:\.\d+)?/], "gems" => gems }
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ruby_requirement(facts)
|
|
146
|
+
return [ facts["ruby"], "Gemfile.lock" ] if facts&.dig("ruby")
|
|
147
|
+
|
|
148
|
+
version = read(".ruby-version").to_s.lines.first.to_s.strip.delete_prefix("ruby-")[/\A\d+\.\d+(?:\.\d+)?/]
|
|
149
|
+
version ? [ version, ".ruby-version" ] : [ nil, nil ]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def below?(version, minimum)
|
|
153
|
+
Gem::Version.correct?(version) && Gem::Version.new(version) < minimum
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def database_adapters
|
|
157
|
+
text = read("config/database.yml").to_s.gsub(ERB_TAG, "")
|
|
158
|
+
text.scan(/^[ \t]*adapter:[ \t]*["']?([A-Za-z0-9_]+)/).flatten.uniq
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def required_services(facts)
|
|
162
|
+
gemfile = read("Gemfile").to_s
|
|
163
|
+
named = gemfile.scan(/^\s*gem\s+["']([A-Za-z0-9_.-]+)["']/).flatten
|
|
164
|
+
locked = facts ? facts["gems"].keys : []
|
|
165
|
+
(named | locked).filter_map { |gem| SERVICE_GEMS[gem] }.uniq.sort
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def warnings(adapters, services)
|
|
169
|
+
known = LocalSandboxDatabases::FILE_ADAPTERS + LocalSandboxDatabases::SERVER_ADAPTERS
|
|
170
|
+
unknown = adapters - known
|
|
171
|
+
notes = services.map { |service| "The app uses #{service}, which a sandbox does not run: features that need it may fail." }
|
|
172
|
+
if unknown.any?
|
|
173
|
+
notes << "config/database.yml names #{unknown.join(", ")}, which a local sandbox cannot give databases of its own."
|
|
174
|
+
end
|
|
175
|
+
notes
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to find the environment variables a repository expects before its
|
|
5
|
+
# first boot, so the project's secrets form can ask for all of them at
|
|
6
|
+
# once. Deterministic and model-free: the same files at the same commit give
|
|
7
|
+
# the same names. Read through GitHub's contents API:
|
|
8
|
+
#
|
|
9
|
+
# .env.example, .env.sample every NAME= line
|
|
10
|
+
# .activeagents/sandbox.yml its `secrets:` key, a list of names or a
|
|
11
|
+
# mapping of names to descriptions
|
|
12
|
+
# config/**, lib/** ENV.fetch("NAME") and ENV["NAME"] call sites
|
|
13
|
+
# in Ruby, YAML and ERB files, at most
|
|
14
|
+
# MAX_SCANNED_FILES of them in path order
|
|
15
|
+
#
|
|
16
|
+
# Variables Rails or the sandbox sets (RUNTIME_VARIABLES, and the names a
|
|
17
|
+
# ProjectSecret may not take) are left out.
|
|
18
|
+
#
|
|
19
|
+
# Every file is read at the commit the ref names, and only when the
|
|
20
|
+
# commit's file listing has it. What was found is kept in Rails.cache for
|
|
21
|
+
# that commit, so discovering a repository again costs one GitHub call
|
|
22
|
+
# until it changes.
|
|
23
|
+
class ProjectSecretDiscovery
|
|
24
|
+
ENV_FILES = %w[.env.example .env.sample].freeze
|
|
25
|
+
CACHE_TTL = 1.day
|
|
26
|
+
SANDBOX_CONFIG = LocalSandboxBackend::Config::PATH
|
|
27
|
+
SCANNED = %r{\A(?:config|lib)/[^\0]+\.(?:rb|ya?ml|erb)\z}
|
|
28
|
+
MAX_SCANNED_FILES = 40
|
|
29
|
+
MAX_SCANNED_BYTES = 128 * 1024
|
|
30
|
+
# Where a variable was found, listed up to this many places.
|
|
31
|
+
MAX_SOURCES = 5
|
|
32
|
+
ENV_FILE_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/
|
|
33
|
+
NAME = "[A-Za-z_][A-Za-z0-9_]*"
|
|
34
|
+
# ENV.fetch("NAME") with no default and no block: the app cannot boot
|
|
35
|
+
# without it.
|
|
36
|
+
REQUIRED_FETCH = /ENV\.fetch\(\s*["'](#{NAME})["']\s*\)(?!\s*(?:\{|do\b))/
|
|
37
|
+
ANY_FETCH = /ENV\.fetch\(\s*["'](#{NAME})["']/
|
|
38
|
+
INDEX = /ENV\[\s*["'](#{NAME})["']\s*\]/
|
|
39
|
+
RUNTIME_VARIABLES = %w[
|
|
40
|
+
CI HOME HOSTNAME JOB_CONCURRENCY LANG PIDFILE PORT RACK_ENV RAILS_ENV RAILS_LOG_LEVEL RAILS_LOG_TO_STDOUT
|
|
41
|
+
RAILS_MAX_THREADS RAILS_MIN_THREADS RAILS_SERVE_STATIC_FILES SOLID_QUEUE_IN_PUMA TZ WEB_CONCURRENCY
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# @param client [GithubClient]
|
|
45
|
+
# @param repository [String] owner/name
|
|
46
|
+
# @param ref [String] the branch, tag or commit to read
|
|
47
|
+
# @return [Hash] see #call, with +ref+ as asked and +commit+, the SHA it
|
|
48
|
+
# named
|
|
49
|
+
# @raise [GithubClient::NotFound] when the ref names no commit
|
|
50
|
+
def self.call(client, repository:, ref:)
|
|
51
|
+
commit = client.commit_sha(repository, ref) or
|
|
52
|
+
raise GithubClient::NotFound, "#{repository} has no commit at #{ref.presence || "its default branch"}"
|
|
53
|
+
|
|
54
|
+
found = Rails.cache.fetch([ name, ActionAgent::VERSION, repository, commit ], expires_in: CACHE_TTL) do
|
|
55
|
+
new(client, repository: repository, ref: commit).call
|
|
56
|
+
end
|
|
57
|
+
found.deep_symbolize_keys.merge(ref: ref, commit: commit)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(client, repository:, ref:)
|
|
61
|
+
@client = client
|
|
62
|
+
@repository = repository
|
|
63
|
+
@ref = ref
|
|
64
|
+
@found = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Hash]
|
|
68
|
+
# variables [{ name:, required:, sources: [String] (at most
|
|
69
|
+
# MAX_SOURCES), description:, organization_key: provider or
|
|
70
|
+
# nil }], sorted by name
|
|
71
|
+
# scanned the paths read for ENV call sites
|
|
72
|
+
# truncated whether files were left unread (more than
|
|
73
|
+
# MAX_SCANNED_FILES, or GitHub truncated the file listing)
|
|
74
|
+
def call
|
|
75
|
+
listing = @client.tree(@repository, ref: @ref)
|
|
76
|
+
listed = listing[:paths].to_set { |entry| entry[:path] }
|
|
77
|
+
# A truncated listing may leave out a file that is there.
|
|
78
|
+
present = ->(path) { listing[:truncated] || listed.include?(path) }
|
|
79
|
+
ENV_FILES.select(&present).each { |path| scan_env_file(path) }
|
|
80
|
+
scan_sandbox_config if present.call(SANDBOX_CONFIG)
|
|
81
|
+
candidates = listing[:paths].select { |entry| SCANNED.match?(entry[:path]) && entry[:size] <= MAX_SCANNED_BYTES }
|
|
82
|
+
.sort_by { |entry| entry[:path] }
|
|
83
|
+
scanned = candidates.first(MAX_SCANNED_FILES).map { |entry| entry[:path] }
|
|
84
|
+
scanned.each { |path| scan_source(path) }
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
variables: @found.values.sort_by { |variable| variable[:name] }.map do |variable|
|
|
88
|
+
variable.merge(sources: variable[:sources].uniq.first(MAX_SOURCES),
|
|
89
|
+
organization_key: ProjectSecret::ORGANIZATION_KEY_PROVIDERS[variable[:name]])
|
|
90
|
+
end,
|
|
91
|
+
scanned: scanned,
|
|
92
|
+
truncated: listing[:truncated] || candidates.size > MAX_SCANNED_FILES,
|
|
93
|
+
ref: @ref
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def scan_env_file(path)
|
|
100
|
+
text = @client.file(@repository, path, ref: @ref) or return
|
|
101
|
+
|
|
102
|
+
text.each_line.with_index(1) do |line, number|
|
|
103
|
+
name = line[ENV_FILE_LINE, 1]
|
|
104
|
+
note(name, "#{path}:#{number}") if name
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def scan_sandbox_config
|
|
109
|
+
text = @client.file(@repository, SANDBOX_CONFIG, ref: @ref) or return
|
|
110
|
+
|
|
111
|
+
data = YAML.safe_load(text, aliases: false)
|
|
112
|
+
entries = data.is_a?(Hash) ? data["secrets"] : nil
|
|
113
|
+
case entries
|
|
114
|
+
when Array then entries.each { |name| note(name.to_s, SANDBOX_CONFIG, required: true) }
|
|
115
|
+
when Hash then entries.each { |name, description| note(name.to_s, SANDBOX_CONFIG, required: true, description: description) }
|
|
116
|
+
end
|
|
117
|
+
rescue Psych::Exception
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def scan_source(path)
|
|
122
|
+
text = @client.file(@repository, path, ref: @ref) or return
|
|
123
|
+
|
|
124
|
+
required = text.scan(REQUIRED_FETCH).flatten.to_set
|
|
125
|
+
text.each_line.with_index(1) do |line, number|
|
|
126
|
+
(line.scan(ANY_FETCH) + line.scan(INDEX)).flatten.each do |name|
|
|
127
|
+
note(name, "#{path}:#{number}", required: required.include?(name))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def note(name, source, required: false, description: nil)
|
|
133
|
+
return unless SandboxBootSpec::ENV_NAME.match?(name)
|
|
134
|
+
return if RUNTIME_VARIABLES.include?(name) || ProjectSecret.refused_name?(name)
|
|
135
|
+
|
|
136
|
+
entry = @found[name] ||= { name: name, required: false, sources: [], description: nil }
|
|
137
|
+
entry[:required] ||= required
|
|
138
|
+
entry[:sources] << source
|
|
139
|
+
entry[:description] ||= description.to_s.truncate(200).presence if description.is_a?(String)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|