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,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module ActionAgent
|
|
7
|
+
# Builds diffs and patches from file contents, in Ruby and with no git
|
|
8
|
+
# process: the dialog's preview of a draft pull request, and the patch a
|
|
9
|
+
# user downloads when publishing is not available (see
|
|
10
|
+
# DraftPullRequestPublisher).
|
|
11
|
+
#
|
|
12
|
+
# A change is a Hash:
|
|
13
|
+
#
|
|
14
|
+
# path: relative to the repository root
|
|
15
|
+
# status: "added", "modified" or "deleted"
|
|
16
|
+
# base_mode: the mode before ("100644" or "100755"); nil when added
|
|
17
|
+
# mode: the mode after; nil when deleted
|
|
18
|
+
# base_content: the bytes before; nil when added
|
|
19
|
+
# content: the bytes after; nil when deleted
|
|
20
|
+
#
|
|
21
|
+
# Text is diffed line by line with three lines of context. A binary file
|
|
22
|
+
# (one with a NUL byte in its first 8000, as git decides) is written as a
|
|
23
|
+
# GIT binary patch holding the new content whole, which `git apply` takes.
|
|
24
|
+
module SandboxPatch
|
|
25
|
+
CONTEXT_LINES = 3
|
|
26
|
+
# Past this many differing lines between two versions, a file's diff
|
|
27
|
+
# replaces every line rather than searching for the shortest edit.
|
|
28
|
+
MAX_EDIT_DISTANCE = 2_000
|
|
29
|
+
NULL_ID = "0" * 40
|
|
30
|
+
BINARY_PROBE_BYTES = 8_000
|
|
31
|
+
# The characters git's base-85 encoding uses, in order.
|
|
32
|
+
BASE85 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
|
33
|
+
# The author `git am` records for a downloaded patch. The domain is
|
|
34
|
+
# reserved, so nothing is ever sent there.
|
|
35
|
+
PATCH_AUTHOR = "ActiveAgent sandbox <sandbox@activeagent.invalid>"
|
|
36
|
+
LINE_PREFIX = { equal: " ".b, delete: "-".b, insert: "+".b }.freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Whether git would treat +content+ as binary.
|
|
41
|
+
def binary?(content)
|
|
42
|
+
content.to_s.b.byteslice(0, BINARY_PROBE_BYTES).include?("\0")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The id git gives +content+ as a blob.
|
|
46
|
+
def blob_id(content)
|
|
47
|
+
content = content.to_s.b
|
|
48
|
+
Digest::SHA1.hexdigest("blob #{content.bytesize}\0".b + content)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# +change+ as `git diff` prints one file: the header, mode lines, the
|
|
52
|
+
# index line and the hunks. Binary-encoded.
|
|
53
|
+
def file_diff(change)
|
|
54
|
+
path = change.fetch(:path)
|
|
55
|
+
before = change[:status] == "added" ? nil : change[:base_content].to_s.b
|
|
56
|
+
after = change[:status] == "deleted" ? nil : change[:content].to_s.b
|
|
57
|
+
base_mode = change[:base_mode] || (before && change[:mode]) || "100644"
|
|
58
|
+
mode = change[:mode] || base_mode
|
|
59
|
+
|
|
60
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
61
|
+
out << "diff --git ".b << quote_path("a/#{path}") << " ".b << quote_path("b/#{path}") << "\n".b
|
|
62
|
+
if before.nil?
|
|
63
|
+
out << "new file mode #{mode}\n"
|
|
64
|
+
elsif after.nil?
|
|
65
|
+
out << "deleted file mode #{base_mode}\n"
|
|
66
|
+
elsif base_mode != mode
|
|
67
|
+
out << "old mode #{base_mode}\nnew mode #{mode}\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
old_id = before ? blob_id(before) : NULL_ID
|
|
71
|
+
new_id = after ? blob_id(after) : NULL_ID
|
|
72
|
+
return out if old_id == new_id
|
|
73
|
+
|
|
74
|
+
out << "index #{old_id}..#{new_id}#{" #{mode}" if before && after && base_mode == mode}\n"
|
|
75
|
+
if binary?(before) || binary?(after)
|
|
76
|
+
out << "GIT binary patch\n" << binary_literal(after.to_s) << "\n"
|
|
77
|
+
elsif !(before.to_s.empty? && after.to_s.empty?)
|
|
78
|
+
out << "--- ".b << (before ? quote_path("a/#{path}") : "/dev/null".b) << "\n".b
|
|
79
|
+
out << "+++ ".b << (after ? quote_path("b/#{path}") : "/dev/null".b) << "\n".b
|
|
80
|
+
out << hunks(lines(before), lines(after))
|
|
81
|
+
end
|
|
82
|
+
out
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A patch of every change, as `git format-patch` writes one commit:
|
|
86
|
+
# `git am` applies it as a commit titled +subject+, and `git apply`
|
|
87
|
+
# applies its changes to a checkout of +base_commit+.
|
|
88
|
+
def format_patch(changes, subject:, base_commit:, body: nil, date: Time.now.utc)
|
|
89
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
90
|
+
out << "From #{base_commit} Mon Sep 17 00:00:00 2001\n".b
|
|
91
|
+
out << "From: #{PATCH_AUTHOR}\n".b
|
|
92
|
+
out << "Date: #{date.rfc2822}\n".b
|
|
93
|
+
out << "Subject: [PATCH] #{subject.to_s.gsub(/\s+/, ' ').strip}\n\n".b
|
|
94
|
+
out << "#{body.to_s.strip}\n\n".b if body.present?
|
|
95
|
+
out << "---\n\n".b
|
|
96
|
+
changes.each { |change| out << file_diff(change) }
|
|
97
|
+
out << "-- \nActiveAgent\n".b
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# +path+ as git writes it in a header: quoted, with C escapes, when it
|
|
101
|
+
# holds a quote, a backslash, a control character or a non-ASCII byte.
|
|
102
|
+
def quote_path(path)
|
|
103
|
+
bytes = path.b
|
|
104
|
+
return bytes unless bytes.match?(/["\\\x00-\x1f\x7f-\xff]/n)
|
|
105
|
+
|
|
106
|
+
escaped = bytes.each_byte.map do |byte|
|
|
107
|
+
case byte
|
|
108
|
+
when 0x07 then "\\a"
|
|
109
|
+
when 0x08 then "\\b"
|
|
110
|
+
when 0x09 then "\\t"
|
|
111
|
+
when 0x0a then "\\n"
|
|
112
|
+
when 0x0b then "\\v"
|
|
113
|
+
when 0x0c then "\\f"
|
|
114
|
+
when 0x0d then "\\r"
|
|
115
|
+
when 0x22 then "\\\""
|
|
116
|
+
when 0x5c then "\\\\"
|
|
117
|
+
when 0x00..0x1f, 0x7f..0xff then format("\\%03o", byte)
|
|
118
|
+
else byte.chr
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
"\"#{escaped.join}\"".b
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# +content+ split into lines, each keeping its newline.
|
|
125
|
+
def lines(content)
|
|
126
|
+
content.to_s.b.split(/(?<=\n)/n)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The unified hunks that turn +old_lines+ into +new_lines+.
|
|
130
|
+
def hunks(old_lines, new_lines)
|
|
131
|
+
edits = line_edits(old_lines, new_lines)
|
|
132
|
+
changed = edits.each_index.reject { |index| edits[index].first == :equal }
|
|
133
|
+
return "".b if changed.empty?
|
|
134
|
+
|
|
135
|
+
groups = changed.slice_when { |a, b| b - a > CONTEXT_LINES * 2 + 1 }.to_a
|
|
136
|
+
groups.each_with_object(String.new(encoding: Encoding::BINARY)) do |group, out|
|
|
137
|
+
first = [ group.first - CONTEXT_LINES, 0 ].max
|
|
138
|
+
last = [ group.last + CONTEXT_LINES, edits.size - 1 ].min
|
|
139
|
+
old_before = edits[0...first].count { |op, _| op != :insert }
|
|
140
|
+
new_before = edits[0...first].count { |op, _| op != :delete }
|
|
141
|
+
window = edits[first..last]
|
|
142
|
+
old_count = window.count { |op, _| op != :insert }
|
|
143
|
+
new_count = window.count { |op, _| op != :delete }
|
|
144
|
+
|
|
145
|
+
out << "@@ -#{range(old_before, old_count)} +#{range(new_before, new_count)} @@\n".b
|
|
146
|
+
window.each do |op, line|
|
|
147
|
+
out << LINE_PREFIX.fetch(op) << line
|
|
148
|
+
out << "\n\\n".b unless line.end_with?("\n")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# "start,count" for a hunk that starts after +before+ lines. An empty
|
|
154
|
+
# side names the line it would follow.
|
|
155
|
+
def range(before, count)
|
|
156
|
+
"#{count.zero? ? before : before + 1},#{count}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The edit script from +a+ to +b+, as [op, line] pairs with op :equal,
|
|
160
|
+
# :delete or :insert: the shortest one (Myers' algorithm) between the
|
|
161
|
+
# lines the two do not share at either end, or a whole replacement of
|
|
162
|
+
# those lines past MAX_EDIT_DISTANCE.
|
|
163
|
+
def line_edits(a, b)
|
|
164
|
+
prefix = 0
|
|
165
|
+
prefix += 1 while prefix < a.size && prefix < b.size && a[prefix] == b[prefix]
|
|
166
|
+
suffix = 0
|
|
167
|
+
suffix += 1 while suffix < a.size - prefix && suffix < b.size - prefix && a[-1 - suffix] == b[-1 - suffix]
|
|
168
|
+
a_middle = a[prefix...(a.size - suffix)]
|
|
169
|
+
b_middle = b[prefix...(b.size - suffix)]
|
|
170
|
+
|
|
171
|
+
middle = shortest_edits(a_middle, b_middle) ||
|
|
172
|
+
(a_middle.map { |line| [ :delete, line ] } + b_middle.map { |line| [ :insert, line ] })
|
|
173
|
+
a.first(prefix).map { |line| [ :equal, line ] } + middle + a.last(suffix).map { |line| [ :equal, line ] }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Myers' O(ND) shortest edit script, or nil when it is longer than
|
|
177
|
+
# MAX_EDIT_DISTANCE. Each round's furthest-reaching paths are kept for
|
|
178
|
+
# the walk back, as only the slice of diagonals that round could reach.
|
|
179
|
+
def shortest_edits(a, b)
|
|
180
|
+
n = a.size
|
|
181
|
+
m = b.size
|
|
182
|
+
return [] if n.zero? && m.zero?
|
|
183
|
+
|
|
184
|
+
max = n + m
|
|
185
|
+
offset = max + 1
|
|
186
|
+
furthest = Array.new((2 * max) + 3, 0)
|
|
187
|
+
trace = []
|
|
188
|
+
found = (0..[ max, MAX_EDIT_DISTANCE ].min).any? do |d|
|
|
189
|
+
trace << furthest[(offset - d - 1)..(offset + d + 1)]
|
|
190
|
+
(-d..d).step(2).any? do |k|
|
|
191
|
+
x = k == -d || (k != d && furthest[offset + k - 1] < furthest[offset + k + 1]) ? furthest[offset + k + 1] : furthest[offset + k - 1] + 1
|
|
192
|
+
y = x - k
|
|
193
|
+
while x < n && y < m && a[x] == b[y]
|
|
194
|
+
x += 1
|
|
195
|
+
y += 1
|
|
196
|
+
end
|
|
197
|
+
furthest[offset + k] = x
|
|
198
|
+
x >= n && y >= m
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
return nil unless found
|
|
202
|
+
|
|
203
|
+
walk_back(trace, a, b)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def walk_back(trace, a, b)
|
|
207
|
+
x = a.size
|
|
208
|
+
y = b.size
|
|
209
|
+
edits = []
|
|
210
|
+
trace.each_with_index.reverse_each do |snapshot, d|
|
|
211
|
+
at = ->(k) { snapshot[k + d + 1] }
|
|
212
|
+
k = x - y
|
|
213
|
+
previous_k = k == -d || (k != d && at.call(k - 1) < at.call(k + 1)) ? k + 1 : k - 1
|
|
214
|
+
previous_x = at.call(previous_k)
|
|
215
|
+
previous_y = previous_x - previous_k
|
|
216
|
+
while x > previous_x && y > previous_y
|
|
217
|
+
x -= 1
|
|
218
|
+
y -= 1
|
|
219
|
+
edits << [ :equal, a[x] ]
|
|
220
|
+
end
|
|
221
|
+
if d.positive?
|
|
222
|
+
edits << (x == previous_x ? [ :insert, b[previous_y] ] : [ :delete, a[previous_x] ])
|
|
223
|
+
end
|
|
224
|
+
x = previous_x
|
|
225
|
+
y = previous_y
|
|
226
|
+
end
|
|
227
|
+
edits.reverse
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# +content+ whole, deflated and base-85 encoded the way a GIT binary
|
|
231
|
+
# patch's "literal" hunk carries it.
|
|
232
|
+
def binary_literal(content)
|
|
233
|
+
deflated = Zlib::Deflate.deflate(content.b)
|
|
234
|
+
out = "literal #{content.bytesize}\n".b
|
|
235
|
+
deflated.bytes.each_slice(52) do |chunk|
|
|
236
|
+
length = chunk.size <= 26 ? ("A".ord + chunk.size - 1) : ("a".ord + chunk.size - 27)
|
|
237
|
+
out << length.chr << encode85(chunk) << "\n"
|
|
238
|
+
end
|
|
239
|
+
out
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def encode85(bytes)
|
|
243
|
+
bytes.each_slice(4).map do |group|
|
|
244
|
+
value = group.each_with_index.sum { |byte, index| byte << (24 - (8 * index)) }
|
|
245
|
+
digits = Array.new(5) do
|
|
246
|
+
value, digit = value.divmod(85)
|
|
247
|
+
BASE85[digit]
|
|
248
|
+
end
|
|
249
|
+
digits.reverse.join
|
|
250
|
+
end.join
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Imports a scenario catalog document into the owner's records: products,
|
|
5
|
+
# sets and scenarios are matched by key. In "replace" mode (the default)
|
|
6
|
+
# the document is the truth, so what it leaves out is removed and
|
|
7
|
+
# importing it twice changes nothing; in "merge" mode what it leaves out
|
|
8
|
+
# is kept, so an export of one set or a few scenarios can be imported back
|
|
9
|
+
# into the whole catalog. The catalog keeps the canonical document and its
|
|
10
|
+
# digest, and is written to Active Storage when the host has it.
|
|
11
|
+
#
|
|
12
|
+
# ScenarioCatalogImport.new(owner: account, document: yaml, agents: ActionAgent.agents_for(account)).call
|
|
13
|
+
#
|
|
14
|
+
# #preview answers what #call would change, by key, without writing.
|
|
15
|
+
#
|
|
16
|
+
# A product's `agent:` is resolved among +agents+ by name, and a project
|
|
17
|
+
# among +projects+ by the product's `repository` or `project` entry, so a
|
|
18
|
+
# catalog written for a repository finds the project that boots it.
|
|
19
|
+
class ScenarioCatalogImport
|
|
20
|
+
# Raised for a document that cannot be imported, with the reason a
|
|
21
|
+
# person can act on.
|
|
22
|
+
class Invalid < StandardError; end
|
|
23
|
+
|
|
24
|
+
MODES = %w[replace merge].freeze
|
|
25
|
+
# The changes #preview lists; the counts cover all of them.
|
|
26
|
+
PREVIEW_LIMIT = 500
|
|
27
|
+
|
|
28
|
+
attr_reader :catalog, :source_path
|
|
29
|
+
|
|
30
|
+
def initialize(owner:, document:, catalog: nil, name: nil, source_kind: "upload", source_path: nil, agents: nil, projects: nil, mode: "replace")
|
|
31
|
+
@owner = owner
|
|
32
|
+
@document = document.to_s
|
|
33
|
+
@catalog = catalog
|
|
34
|
+
@name = name
|
|
35
|
+
@source_kind = source_kind
|
|
36
|
+
@source_path = source_path
|
|
37
|
+
@agents = agents
|
|
38
|
+
@projects = projects
|
|
39
|
+
@mode = mode.to_s.presence || "replace"
|
|
40
|
+
raise Invalid, "The import mode is replace or merge" unless MODES.include?(@mode)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [ScenarioCatalog] the imported catalog
|
|
44
|
+
def call
|
|
45
|
+
parsed = parse
|
|
46
|
+
|
|
47
|
+
ScenarioCatalog.transaction do
|
|
48
|
+
@catalog ||= find_or_initialize(parsed.key)
|
|
49
|
+
merging = merge? && @catalog.persisted?
|
|
50
|
+
@catalog.assign_attributes(
|
|
51
|
+
key: parsed.key, source_kind: @source_kind, source_path: @source_path,
|
|
52
|
+
name: merging ? (parsed_field(parsed, "name") || @catalog.name) : parsed.name,
|
|
53
|
+
description: merging ? (parsed.description || @catalog.description) : parsed.description,
|
|
54
|
+
metadata: merging ? @catalog.metadata.merge(parsed.metadata) : parsed.metadata,
|
|
55
|
+
document: parsed.to_yaml, digest: parsed.digest
|
|
56
|
+
)
|
|
57
|
+
@catalog.save!
|
|
58
|
+
replace_products(parsed)
|
|
59
|
+
if merging
|
|
60
|
+
@catalog.refresh_document!
|
|
61
|
+
if @catalog.scenario_count > ScenarioCatalog::MAX_SCENARIOS
|
|
62
|
+
raise Invalid, "The catalog would hold more than #{ScenarioCatalog::MAX_SCENARIOS} scenarios"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
@catalog.sync_to_storage!
|
|
67
|
+
@catalog
|
|
68
|
+
rescue ActiveAgent::Evals::Catalog::InvalidDocument => e
|
|
69
|
+
raise Invalid, e.message
|
|
70
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
71
|
+
raise Invalid, e.record.errors.full_messages.to_sentence
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# What #call would do, without writing: the catalog it imports into
|
|
75
|
+
# (created or updated), counts by action, and the changes (up to
|
|
76
|
+
# PREVIEW_LIMIT), each { level: product|set|scenario, path, action }
|
|
77
|
+
# where action is create, update (the document changes a record by
|
|
78
|
+
# that key), unchanged, remove (replace mode drops a record the document
|
|
79
|
+
# leaves out) or keep (merge mode keeps it).
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash]
|
|
82
|
+
def preview
|
|
83
|
+
parsed = parse
|
|
84
|
+
existing = @catalog || ScenarioCatalog.for_owner(@owner).find_by(key: parsed.key)
|
|
85
|
+
before = existing ? existing.to_catalog.to_h : {}
|
|
86
|
+
after = parsed.to_h
|
|
87
|
+
changes = []
|
|
88
|
+
diff_level(changes, "product", "", list(before["products"]), list(after["products"]), "sets") do |path, old_product, new_product|
|
|
89
|
+
diff_level(changes, "set", path, list(old_product["sets"]), list(new_product["sets"]), "scenarios") do |set_path, old_set, new_set|
|
|
90
|
+
diff_level(changes, "scenario", set_path, list(old_set["scenarios"]), list(new_set["scenarios"]), nil)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
counts = Hash.new(0)
|
|
95
|
+
changes.each { |change| counts[change[:action]] += 1 }
|
|
96
|
+
{
|
|
97
|
+
mode: @mode,
|
|
98
|
+
catalog: {
|
|
99
|
+
id: existing&.id, key: parsed.key, name: parsed.name,
|
|
100
|
+
action: existing.nil? ? "create" : (catalog_fields(before) == catalog_fields(after) ? "unchanged" : "update")
|
|
101
|
+
},
|
|
102
|
+
scenario_count: parsed.scenario_count,
|
|
103
|
+
counts: counts,
|
|
104
|
+
changes: changes.reject { |change| change[:action] == "unchanged" }.first(PREVIEW_LIMIT)
|
|
105
|
+
}
|
|
106
|
+
rescue ActiveAgent::Evals::Catalog::InvalidDocument => e
|
|
107
|
+
raise Invalid, e.message
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def merge?
|
|
113
|
+
@mode == "merge"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse
|
|
117
|
+
if @document.bytesize > ScenarioCatalog::MAX_BYTES
|
|
118
|
+
raise Invalid, "The catalog is larger than #{ScenarioCatalog::MAX_BYTES / 1.megabyte} MB"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
parsed = ActiveAgent::Evals::Catalog.parse(@document, name: @name)
|
|
122
|
+
if parsed.scenario_count > ScenarioCatalog::MAX_SCENARIOS
|
|
123
|
+
raise Invalid, "The catalog holds #{parsed.scenario_count} scenarios; the limit is #{ScenarioCatalog::MAX_SCENARIOS}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
parsed
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The document's own name, rather than the key the parser falls back to.
|
|
130
|
+
def parsed_field(parsed, field)
|
|
131
|
+
value = parsed.to_h[field]
|
|
132
|
+
value unless field == "name" && value == parsed.key
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def list(value)
|
|
136
|
+
Array(value).select { |entry| entry.is_a?(Hash) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def catalog_fields(document)
|
|
140
|
+
document.except("products")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Appends the changes between +old_entries+ and +new_entries+ (matched
|
|
144
|
+
# by key) to +changes+, comparing each entry without its +children+, and
|
|
145
|
+
# yields the pairs present in the document so their children are
|
|
146
|
+
# compared too.
|
|
147
|
+
def diff_level(changes, level, parent_path, old_entries, new_entries, children)
|
|
148
|
+
old_by_key = old_entries.index_by { |entry| entry["key"].to_s }
|
|
149
|
+
new_entries.each do |entry|
|
|
150
|
+
key = entry["key"].to_s
|
|
151
|
+
path = parent_path.empty? ? key : "#{parent_path}/#{key}"
|
|
152
|
+
previous = old_by_key.delete(key)
|
|
153
|
+
action =
|
|
154
|
+
if previous.nil? then "create"
|
|
155
|
+
elsif children ? previous.except(children) == entry.except(children) : previous == entry then "unchanged"
|
|
156
|
+
else "update"
|
|
157
|
+
end
|
|
158
|
+
changes << { level: level, path: path, action: action }
|
|
159
|
+
yield(path, previous || {}, entry) if children && block_given?
|
|
160
|
+
end
|
|
161
|
+
old_by_key.each_key do |key|
|
|
162
|
+
path = parent_path.empty? ? key : "#{parent_path}/#{key}"
|
|
163
|
+
changes << { level: level, path: path, action: merge? ? "keep" : "remove" }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def find_or_initialize(key)
|
|
168
|
+
catalog = ScenarioCatalog.for_owner(@owner).find_or_initialize_by(key: key)
|
|
169
|
+
catalog.owner = @owner if catalog.new_record? && @owner
|
|
170
|
+
catalog
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def replace_products(parsed)
|
|
174
|
+
@catalog.products.where.not(key: parsed.products.map(&:key)).destroy_all unless merge?
|
|
175
|
+
offset = merge? ? next_position(@catalog.products) : 0
|
|
176
|
+
|
|
177
|
+
parsed.products.each do |entry|
|
|
178
|
+
product = @catalog.products.find_or_initialize_by(key: entry.key)
|
|
179
|
+
product.assign_attributes(
|
|
180
|
+
name: entry.name, description: entry.description, agent_name: entry.agent,
|
|
181
|
+
metadata: entry.metadata, agent: resolve_agent(entry) || (merge? ? product.agent : nil),
|
|
182
|
+
project: resolve_project(entry) || (merge? ? product.project : nil)
|
|
183
|
+
)
|
|
184
|
+
product.position = position_for(product, entry.position, offset)
|
|
185
|
+
product.save!
|
|
186
|
+
replace_sets(product, entry)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def replace_sets(product, entry)
|
|
191
|
+
product.sets.where.not(key: entry.sets.map(&:key)).destroy_all unless merge?
|
|
192
|
+
offset = merge? ? next_position(product.sets) : 0
|
|
193
|
+
|
|
194
|
+
entry.sets.each do |set_entry|
|
|
195
|
+
set = product.sets.find_or_initialize_by(key: set_entry.key)
|
|
196
|
+
set.assign_attributes(
|
|
197
|
+
name: set_entry.name, description: set_entry.description, judge: set_entry.judge,
|
|
198
|
+
criteria: set_entry.criteria, metadata: set_entry.metadata
|
|
199
|
+
)
|
|
200
|
+
set.position = position_for(set, set_entry.position, offset)
|
|
201
|
+
set.save!
|
|
202
|
+
replace_scenarios(set, set_entry)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def replace_scenarios(set, set_entry)
|
|
207
|
+
set.scenarios.where.not(key: set_entry.scenarios.map(&:key)).destroy_all unless merge?
|
|
208
|
+
offset = merge? ? next_position(set.scenarios) : 0
|
|
209
|
+
|
|
210
|
+
set_entry.scenarios.each do |scenario|
|
|
211
|
+
record = set.scenarios.find_or_initialize_by(key: scenario.key)
|
|
212
|
+
record.assign_attributes(
|
|
213
|
+
prompt: scenario.prompt, notes: scenario.notes, expectations: scenario.expectations,
|
|
214
|
+
tags: scenario.tags, params: scenario.params, production_only: scenario.production_only?
|
|
215
|
+
)
|
|
216
|
+
record.position = position_for(record, scenario.position, offset)
|
|
217
|
+
record.save!
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Replace mode takes the document's order. Merge mode keeps a record's
|
|
222
|
+
# place and puts a new one after the records already there.
|
|
223
|
+
def position_for(record, position, offset)
|
|
224
|
+
return position unless merge?
|
|
225
|
+
|
|
226
|
+
record.persisted? ? record.position : offset + position.to_i
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def next_position(relation)
|
|
230
|
+
(relation.maximum(:position) || -1) + 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The owner's agent the product's `agent:` names, kept as the product's
|
|
234
|
+
# agent; a name that matches nothing stays as agent_name alone.
|
|
235
|
+
def resolve_agent(entry)
|
|
236
|
+
return nil if entry.agent.blank? || @agents.nil?
|
|
237
|
+
|
|
238
|
+
@agents.find_by(name: entry.agent)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The project the product's `project:` (a project id or name) or
|
|
242
|
+
# `repository:` (owner/name) names among +projects+.
|
|
243
|
+
def resolve_project(entry)
|
|
244
|
+
return nil if @projects.nil?
|
|
245
|
+
|
|
246
|
+
reference = entry.metadata["project"]
|
|
247
|
+
repository = entry.metadata["repository"]
|
|
248
|
+
if reference.present?
|
|
249
|
+
reference.to_s.match?(/\A\d+\z/) ? @projects.find_by(id: reference) : @projects.find_by(name: reference.to_s)
|
|
250
|
+
elsif repository.present?
|
|
251
|
+
@projects.find_by(repository: repository.to_s)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Imports the scenario catalogs a connected repository keeps, read through
|
|
5
|
+
# GitHub's contents API at a ref: every `.yml` under +path+
|
|
6
|
+
# (`.activeagents/evals` by default, where the install pull request writes
|
|
7
|
+
# a project's suite), or the one file +path+ names. Each file is a
|
|
8
|
+
# catalog; its source is recorded as "owner/name@ref:path", so a re-import
|
|
9
|
+
# of the same ref finds it again, and a pull request's branch can carry
|
|
10
|
+
# the scenarios that accept it.
|
|
11
|
+
class ScenarioCatalogRepositoryImport
|
|
12
|
+
DEFAULT_PATH = ".activeagents/evals"
|
|
13
|
+
MAX_FILES = 50
|
|
14
|
+
EXTENSIONS = %w[.yml .yaml].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(owner:, connection:, repository:, ref: nil, path: nil, agents: nil, projects: nil, mode: "replace")
|
|
17
|
+
@owner = owner
|
|
18
|
+
@connection = connection
|
|
19
|
+
@repository = repository.to_s
|
|
20
|
+
@ref = ref.to_s.presence
|
|
21
|
+
@path = path.to_s.presence || DEFAULT_PATH
|
|
22
|
+
@agents = agents
|
|
23
|
+
@projects = projects
|
|
24
|
+
@mode = mode
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<ScenarioCatalog>] the imported catalogs, one per file
|
|
28
|
+
def call
|
|
29
|
+
imports.map(&:call)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# What #call would change, one ScenarioCatalogImport#preview per file,
|
|
33
|
+
# each with the file's source_path.
|
|
34
|
+
# @return [Array<Hash>]
|
|
35
|
+
def preview
|
|
36
|
+
imports.map { |import| import.preview.merge(source_path: import.source_path) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# One ScenarioCatalogImport per catalog file at the ref.
|
|
42
|
+
def imports
|
|
43
|
+
client = @connection.client
|
|
44
|
+
ref = @ref || default_branch(client)
|
|
45
|
+
files = catalog_files(client, ref)
|
|
46
|
+
if files.empty?
|
|
47
|
+
raise ScenarioCatalogImport::Invalid, "#{@repository} at #{ref} has no catalog at #{@path}: " \
|
|
48
|
+
"add a .yml file there, or name the file to read"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
files.first(MAX_FILES).filter_map do |file_path|
|
|
52
|
+
content = client.file(@repository, file_path, ref: ref)
|
|
53
|
+
next if content.nil?
|
|
54
|
+
|
|
55
|
+
ScenarioCatalogImport.new(
|
|
56
|
+
owner: @owner, document: content, name: File.basename(file_path, File.extname(file_path)),
|
|
57
|
+
source_kind: "repository", source_path: "#{@repository}@#{ref}:#{file_path}",
|
|
58
|
+
agents: @agents, projects: @projects, mode: @mode
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
rescue GithubClient::Error => e
|
|
62
|
+
raise ScenarioCatalogImport::Invalid, "GitHub refused reading #{@repository}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def default_branch(client)
|
|
66
|
+
@connection.repository(@repository)&.dig("default_branch").presence || client.repository(@repository)["default_branch"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The .yml files at @path: the file itself, or the files directly under
|
|
70
|
+
# the directory, in name order.
|
|
71
|
+
def catalog_files(client, ref)
|
|
72
|
+
return [ @path ] if EXTENSIONS.include?(File.extname(@path))
|
|
73
|
+
|
|
74
|
+
directory = @path.chomp("/")
|
|
75
|
+
client.tree(@repository, ref: ref)[:paths]
|
|
76
|
+
.map { |entry| entry[:path] }
|
|
77
|
+
.select { |entry| entry.start_with?("#{directory}/") && EXTENSIONS.include?(File.extname(entry)) && !entry.delete_prefix("#{directory}/").include?("/") }
|
|
78
|
+
.sort
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|