actionagent 1.8.1 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +53 -6
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +9 -4
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/feedback_ingest_controller.rb +34 -0
- data/app/controllers/action_agent/api/feedbacks_controller.rb +40 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +140 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +623 -18
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +26 -2
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +39 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +16 -4
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +84 -0
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/interaction_feedback.rb +24 -0
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +200 -3
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +297 -14
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_runner_service.rb +4 -5
- data/app/services/action_agent/evaluation_standing.rb +15 -8
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1218 -83
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +276 -22
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +119 -16
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +155 -1
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +507 -22
- data/lib/generators/action_agent/install_generator.rb +37 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +158 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/generators/action_agent/templates/migrations/027_create_active_agent_interaction_feedbacks.rb.erb +17 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# Scenario catalogs: the YAML catalogs of evaluation scenarios an owner
|
|
6
|
+
# keeps, imported from a document, an uploaded file or a connected
|
|
7
|
+
# repository at a ref, exported as YAML, synced to Active Storage, and
|
|
8
|
+
# run one set at a time against an agent or a project.
|
|
9
|
+
#
|
|
10
|
+
# Importing and running change evaluation scenarios, so both need the
|
|
11
|
+
# :replace_scenarios permission, as replacing an evaluation's scenarios
|
|
12
|
+
# does. A run executes the agent, so it is gated as POST
|
|
13
|
+
# /api/evaluations/:id/run is: the execution switch and the owner's
|
|
14
|
+
# execution quota.
|
|
15
|
+
class ScenarioCatalogsController < BaseController
|
|
16
|
+
before_action :require_owner!
|
|
17
|
+
before_action :set_catalog, only: [ :show, :update, :destroy, :export, :sync, :run_set, :materialize_set ]
|
|
18
|
+
before_action :require_execution_enabled!, :enforce_execution_quota!, only: [ :run_set ]
|
|
19
|
+
|
|
20
|
+
rescue_from ScenarioCatalogImport::Invalid, ScenarioSet::NoAgent do |error|
|
|
21
|
+
render json: { errors: [ error.message ] }, status: :unprocessable_entity
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# GET /api/scenario_catalogs
|
|
25
|
+
def index
|
|
26
|
+
catalogs = owned(ScenarioCatalog).ordered.includes(products: { sets: :scenarios }).to_a
|
|
27
|
+
render json: {
|
|
28
|
+
catalogs: catalogs.map { |catalog| ScenarioCatalogSerializer.summary(catalog) },
|
|
29
|
+
storage_available: ScenarioCatalog.attachments_available?
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GET /api/scenario_catalogs/:id
|
|
34
|
+
def show
|
|
35
|
+
render json: { catalog: ScenarioCatalogSerializer.full(@catalog) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# POST /api/scenario_catalogs
|
|
39
|
+
# One of:
|
|
40
|
+
# catalog: { key, name, description } for an empty catalog, built
|
|
41
|
+
# up in the dashboard (see ScenarioCatalogEntriesController)
|
|
42
|
+
# document: the catalog's YAML text
|
|
43
|
+
# file: an uploaded .yml
|
|
44
|
+
# repository: owner/name of a connected repository, with ref (its
|
|
45
|
+
# default branch when omitted) and path (a directory of
|
|
46
|
+
# .yml files, .activeagents/evals by default, or one file)
|
|
47
|
+
# mode is "replace" (the default: the document is the catalog) or
|
|
48
|
+
# "merge" (what the document leaves out is kept). Answers the imported
|
|
49
|
+
# catalogs. A repository the GitHub connection has not selected needs
|
|
50
|
+
# :manage_github, as reading one for a project does.
|
|
51
|
+
def create
|
|
52
|
+
return unless authorize_action!(:replace_scenarios, ScenarioCatalog.new)
|
|
53
|
+
return create_empty if params[:document].blank? && params[:file].blank? && params[:repository].blank? && params[:catalog].present?
|
|
54
|
+
|
|
55
|
+
catalogs =
|
|
56
|
+
if params[:repository].present?
|
|
57
|
+
import_from_repository or return
|
|
58
|
+
else
|
|
59
|
+
[ import_document(document_param, name: params[:name].presence, source_path: params[:source_path].presence) ]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
render json: { catalogs: catalogs.map { |catalog| ScenarioCatalogSerializer.full(catalog) } }, status: :created
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# PATCH /api/scenario_catalogs/:id
|
|
66
|
+
# With a document or file, re-imports it into this catalog (its
|
|
67
|
+
# products, sets and scenarios become the document's); otherwise
|
|
68
|
+
# renames or describes the catalog.
|
|
69
|
+
def update
|
|
70
|
+
return unless authorize_action!(:replace_scenarios, @catalog)
|
|
71
|
+
|
|
72
|
+
if params[:document].present? || params[:file].present?
|
|
73
|
+
import_document(document_param, catalog: @catalog, source_path: params[:source_path].presence || @catalog.source_path)
|
|
74
|
+
else
|
|
75
|
+
attributes = params.require(:catalog).permit(:name, :description)
|
|
76
|
+
@catalog.update!(attributes)
|
|
77
|
+
@catalog.refresh_document!
|
|
78
|
+
@catalog.sync_to_storage!
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
render json: { catalog: ScenarioCatalogSerializer.full(@catalog.reload) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# DELETE /api/scenario_catalogs/:id
|
|
85
|
+
# The evaluations the catalog's sets became stay, with their runs.
|
|
86
|
+
def destroy
|
|
87
|
+
return unless authorize_action!(:replace_scenarios, @catalog)
|
|
88
|
+
|
|
89
|
+
@catalog.destroy!
|
|
90
|
+
head :no_content
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# POST /api/scenario_catalogs/preview
|
|
94
|
+
# What POST /api/scenario_catalogs would change, with the same
|
|
95
|
+
# document, file or repository and mode, without writing: one
|
|
96
|
+
# ScenarioCatalogImport#preview per catalog.
|
|
97
|
+
def preview
|
|
98
|
+
previews =
|
|
99
|
+
if params[:repository].present?
|
|
100
|
+
preview_repository or return
|
|
101
|
+
else
|
|
102
|
+
[ build_import(document_param, name: params[:name].presence).preview ]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
render json: { previews: previews }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# GET /api/scenario_catalogs/:id/export?product_ids=&set_ids=&scenario_ids=
|
|
109
|
+
# The canonical YAML, as a download: the whole catalog, or only the
|
|
110
|
+
# products, sets or scenarios named (comma-separated ids or arrays).
|
|
111
|
+
# disposition=inline answers it as text, for copying.
|
|
112
|
+
def export
|
|
113
|
+
yaml = @catalog.export_yaml(product_ids: params[:product_ids], set_ids: params[:set_ids], scenario_ids: params[:scenario_ids])
|
|
114
|
+
if params[:disposition].to_s == "inline"
|
|
115
|
+
render plain: yaml, content_type: "application/x-yaml"
|
|
116
|
+
else
|
|
117
|
+
suffix = [ params[:product_ids], params[:set_ids], params[:scenario_ids] ].any?(&:present?) ? "-selection" : ""
|
|
118
|
+
send_data yaml, filename: "#{@catalog.key.tr('/', '-')}#{suffix}.yml", type: "application/x-yaml", disposition: "attachment"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# POST /api/scenario_catalogs/:id/sync
|
|
123
|
+
# direction=push (the default) writes the document to Active Storage;
|
|
124
|
+
# direction=pull replaces the records with the stored document.
|
|
125
|
+
def sync
|
|
126
|
+
return unless authorize_action!(:replace_scenarios, @catalog)
|
|
127
|
+
unless ScenarioCatalog.attachments_available?
|
|
128
|
+
return render json: { error: "Active Storage is off for this dashboard (ActionAgent.active_storage)", code: "no_storage" },
|
|
129
|
+
status: :unprocessable_entity
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if params[:direction].to_s == "pull"
|
|
133
|
+
@catalog.restore_from_storage!
|
|
134
|
+
else
|
|
135
|
+
@catalog.sync_to_storage!
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
render json: { catalog: ScenarioCatalogSerializer.full(@catalog.reload) }
|
|
139
|
+
rescue ActiveRecord::RecordNotFound => e
|
|
140
|
+
render json: { error: e.message, code: "no_document" }, status: :not_found
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# POST /api/scenario_catalogs/:id/sets/:set_id/materialize
|
|
144
|
+
# Creates or refreshes the evaluation the set runs as, on agent_id or
|
|
145
|
+
# the product's target, without running it.
|
|
146
|
+
def materialize_set
|
|
147
|
+
return unless authorize_action!(:replace_scenarios, @catalog)
|
|
148
|
+
|
|
149
|
+
set = find_set
|
|
150
|
+
evaluation = set.materialize!(agent: requested_agent)
|
|
151
|
+
render json: { set: set.reload.summary, evaluation: EvaluationSerializer.summary(evaluation) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# POST /api/scenario_catalogs/:id/sets/:set_id/run
|
|
155
|
+
# Runs the set against agent_id, against project_id (the project's
|
|
156
|
+
# sandbox is booted if it is not running, and every replay reaches it
|
|
157
|
+
# and its browser), or against the product's own target. models and
|
|
158
|
+
# keys narrow the run as they do for an evaluation.
|
|
159
|
+
def run_set
|
|
160
|
+
return unless authorize_action!(:replace_scenarios, @catalog)
|
|
161
|
+
|
|
162
|
+
set = find_set
|
|
163
|
+
project = owned(Project).find(params[:project_id]) if params[:project_id].present?
|
|
164
|
+
agent = requested_agent || project&.target_agent || set.product.target_agent(owner_agents)
|
|
165
|
+
if agent.nil?
|
|
166
|
+
return render json: { error: "Choose the agent to run against", code: "no_target" }, status: :unprocessable_entity
|
|
167
|
+
end
|
|
168
|
+
if agent.observed?
|
|
169
|
+
return render json: { error: "Observed agents are read-only — duplicate this agent to create an executable copy" },
|
|
170
|
+
status: :unprocessable_entity
|
|
171
|
+
end
|
|
172
|
+
if project
|
|
173
|
+
boot_project_sandbox!(project) or return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
selection = evaluation_run_selection(params).except(:scenario_ids, :group)
|
|
177
|
+
run = set.run!(agent: agent, project: project, mount_url: "#{request.base_url}#{request.script_name}", **selection)
|
|
178
|
+
render json: {
|
|
179
|
+
set: set.reload.summary,
|
|
180
|
+
evaluation: EvaluationSerializer.summary(run.evaluation),
|
|
181
|
+
run: { id: run.id, status: run.status, evaluation_id: run.evaluation_id }
|
|
182
|
+
}, status: :accepted
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
include EvaluationRunStarting
|
|
188
|
+
|
|
189
|
+
def set_catalog
|
|
190
|
+
@catalog = owned(ScenarioCatalog).find(params[:id])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def find_set
|
|
194
|
+
ScenarioSet.where(scenario_product_id: @catalog.products.select(:id)).find(params[:set_id])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def requested_agent
|
|
198
|
+
params[:agent_id].present? ? owner_agents.find(params[:agent_id]) : nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The YAML given as text or as an uploaded file.
|
|
202
|
+
def document_param
|
|
203
|
+
file = params[:file]
|
|
204
|
+
return file.read if file.respond_to?(:read)
|
|
205
|
+
|
|
206
|
+
params[:document].to_s
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def import_document(document, catalog: nil, name: nil, source_path: nil)
|
|
210
|
+
build_import(document, catalog: catalog, name: name, source_path: source_path).call
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def build_import(document, catalog: nil, name: nil, source_path: nil)
|
|
214
|
+
ScenarioCatalogImport.new(
|
|
215
|
+
owner: current_owner, document: document, catalog: catalog, name: name,
|
|
216
|
+
source_kind: "upload", source_path: source_path,
|
|
217
|
+
agents: owner_agents, projects: owned(Project), mode: params[:mode].presence || "replace"
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# An empty catalog from catalog: { key, name, description }; a blank
|
|
222
|
+
# key is made from the name.
|
|
223
|
+
def create_empty
|
|
224
|
+
attributes = params.require(:catalog).permit(:key, :name, :description).to_h.symbolize_keys
|
|
225
|
+
taken = owned(ScenarioCatalog).pluck(:key)
|
|
226
|
+
attributes[:key] = ScenarioCatalog.key_for(attributes[:name], taken) if attributes[:key].blank?
|
|
227
|
+
attributes[:name] = attributes[:key] if attributes[:name].blank?
|
|
228
|
+
catalog = ScenarioCatalog.new(attributes.merge(source_kind: "api"))
|
|
229
|
+
catalog.owner = current_owner
|
|
230
|
+
ScenarioCatalog.transaction do
|
|
231
|
+
catalog.save!
|
|
232
|
+
catalog.refresh_document!
|
|
233
|
+
end
|
|
234
|
+
catalog.sync_to_storage!
|
|
235
|
+
|
|
236
|
+
render json: { catalogs: [ ScenarioCatalogSerializer.full(catalog) ] }, status: :created
|
|
237
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
238
|
+
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Imports from the connected repository params name. nil, with the
|
|
242
|
+
# refusal rendered, when there is no connection or the repository is
|
|
243
|
+
# not one the caller may read.
|
|
244
|
+
def import_from_repository
|
|
245
|
+
connection = GithubConnection.for_owner(current_owner).first
|
|
246
|
+
if connection.nil?
|
|
247
|
+
render json: { error: "Connect GitHub in Settings first", code: "no_github_connection" }, status: :unprocessable_entity
|
|
248
|
+
return nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
repository = params[:repository].to_s
|
|
252
|
+
if connection.repository(repository).nil? && !ActionAgent.permitted?(current_user, :manage_github, connection)
|
|
253
|
+
permission_denied(:manage_github)
|
|
254
|
+
return nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
repository_import(connection, repository).call
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Previews what the connected repository params name would import. nil,
|
|
261
|
+
# with the refusal rendered, as #import_from_repository.
|
|
262
|
+
def preview_repository
|
|
263
|
+
connection = GithubConnection.for_owner(current_owner).first
|
|
264
|
+
if connection.nil?
|
|
265
|
+
render json: { error: "Connect GitHub in Settings first", code: "no_github_connection" }, status: :unprocessable_entity
|
|
266
|
+
return nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
repository = params[:repository].to_s
|
|
270
|
+
if connection.repository(repository).nil? && !ActionAgent.permitted?(current_user, :manage_github, connection)
|
|
271
|
+
permission_denied(:manage_github)
|
|
272
|
+
return nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
repository_import(connection, repository).preview
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def repository_import(connection, repository)
|
|
279
|
+
ScenarioCatalogRepositoryImport.new(
|
|
280
|
+
owner: current_owner, connection: connection, repository: repository,
|
|
281
|
+
ref: params[:ref].presence, path: params[:path].presence,
|
|
282
|
+
agents: owner_agents, projects: owned(Project), mode: params[:mode].presence || "replace"
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Boots the project's sandbox when none is running, as the project's
|
|
287
|
+
# own run does. nil, with the refusal rendered, when it cannot.
|
|
288
|
+
def boot_project_sandbox!(project)
|
|
289
|
+
before = project.current_sandbox_session_id
|
|
290
|
+
sandbox = project.ensure_sandbox!(confirm: ActiveModel::Type::Boolean.new.cast(params[:confirm]) == true, confirmed_by: current_user)
|
|
291
|
+
record_execution_usage if sandbox.id != before
|
|
292
|
+
sandbox
|
|
293
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
294
|
+
raise unless e.record.is_a?(SandboxSession)
|
|
295
|
+
|
|
296
|
+
render json: { error: "The project's sandbox could not start: #{e.record.errors.full_messages.to_sentence}" },
|
|
297
|
+
status: :unprocessable_entity
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -11,21 +11,21 @@ module ActionAgent
|
|
|
11
11
|
# own visitors should do so against its own endpoint, not one the
|
|
12
12
|
# engine exposes on every host that mounts it.
|
|
13
13
|
|
|
14
|
-
before_action :
|
|
14
|
+
before_action :require_dashboard_capture!, only: [ :create, :create_events ]
|
|
15
|
+
before_action :require_owner!, only: :create
|
|
16
|
+
before_action :set_recording, only: [ :show, :actions, :snapshot, :export, :handoff, :timeline, :events, :create_events ]
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
18
|
+
SENSITIVE_STATE_KEYS = SessionRecording::SENSITIVE_STATE_KEYS
|
|
19
|
+
|
|
20
|
+
# Recording event rows one page of #events returns at most, and the
|
|
21
|
+
# event bytes after which a page ends early.
|
|
22
|
+
EVENTS_PAGE_LIMIT = 100
|
|
23
|
+
EVENTS_PAGE_BYTES = 4.megabytes
|
|
21
24
|
|
|
22
25
|
# GET /api/session_recordings
|
|
23
26
|
# List recordings with optional filters
|
|
24
27
|
def index
|
|
25
|
-
|
|
26
|
-
# a real column rather than a JSON metadata key, so this works on
|
|
27
|
-
# every adapter.
|
|
28
|
-
recordings = reachable_recordings.or(SessionRecording.where(name: "lander_demo")).recent
|
|
28
|
+
recordings = reachable_recordings.recent
|
|
29
29
|
|
|
30
30
|
# Filter by status
|
|
31
31
|
recordings = recordings.where(status: params[:status]) if params[:status].present?
|
|
@@ -79,25 +79,108 @@ module ActionAgent
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
# GET /api/session_recordings/:id/actions
|
|
82
|
-
#
|
|
82
|
+
# One page of the action timeline, in sequence order. Pass the last
|
|
83
|
+
# sequence received as after_sequence to get the next page; has_more
|
|
84
|
+
# says whether one exists. limit is 1..500, default 100.
|
|
83
85
|
def actions
|
|
84
86
|
actions = @recording.recording_actions.ordered
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
if
|
|
88
|
-
actions = actions.where("sequence > ?", integer_param(:after_sequence, default: 0))
|
|
89
|
-
end
|
|
88
|
+
after_sequence = integer_param(:after_sequence)
|
|
89
|
+
actions = actions.where("sequence > ?", after_sequence) if after_sequence
|
|
90
90
|
|
|
91
|
-
limit =
|
|
92
|
-
|
|
91
|
+
limit = clamped_param(:limit, default: 100, min: 1, max: 500)
|
|
92
|
+
# One row past the page tells whether another page follows.
|
|
93
|
+
page = actions.limit(limit + 1).to_a
|
|
94
|
+
has_more = page.size > limit
|
|
93
95
|
|
|
94
96
|
render json: {
|
|
95
|
-
actions:
|
|
96
|
-
has_more:
|
|
97
|
+
actions: page.first(limit).map(&:as_json_for_api),
|
|
98
|
+
has_more: has_more,
|
|
97
99
|
total_actions: @recording.action_count
|
|
98
100
|
}
|
|
99
101
|
end
|
|
100
102
|
|
|
103
|
+
# GET /api/session_recordings/:id/timeline
|
|
104
|
+
# The recording's browser lane with the message, llm and tool lanes of
|
|
105
|
+
# its conversation or run (SessionTimeline).
|
|
106
|
+
def timeline
|
|
107
|
+
render json: RecordingEvent.generate_json(timeline: SessionTimeline.for_recording(@recording, timeline_scope).as_json)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# GET /api/session_recordings/:id/events
|
|
111
|
+
# The recording's events in time order, a page of rows at a time, with
|
|
112
|
+
# their payloads. rrweb only unless +kind+ names others (comma
|
|
113
|
+
# separated). +after+ is the id of the last row already read.
|
|
114
|
+
def events
|
|
115
|
+
kinds = params[:kind].to_s.split(",").map(&:strip) & RecordingEvent::KINDS
|
|
116
|
+
rows = @recording.recording_events.where(kind: kinds.presence || "rrweb").chronological
|
|
117
|
+
if params[:after].present?
|
|
118
|
+
cursor = @recording.recording_events.find_by(id: integer_param(:after))
|
|
119
|
+
return render(json: { error: "Unknown cursor" }, status: :unprocessable_entity) unless cursor
|
|
120
|
+
|
|
121
|
+
rows = rows.where(after_row(cursor))
|
|
122
|
+
end
|
|
123
|
+
limit = clamped_param(:limit, default: 20, min: 1, max: EVENTS_PAGE_LIMIT)
|
|
124
|
+
fetched = rows.limit(limit + 1).to_a
|
|
125
|
+
page = page_of_rows(fetched, limit)
|
|
126
|
+
|
|
127
|
+
render json: RecordingEvent.generate_json(
|
|
128
|
+
events: page.map { |row| event_row_json(row) },
|
|
129
|
+
has_more: fetched.size > page.size,
|
|
130
|
+
next_after: page.last&.id
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# POST /api/session_recordings
|
|
135
|
+
# Starts the caller's recording of one visit to conversation
|
|
136
|
+
# +agent_context_id+ in the Run Agent workbench (`source: "dashboard"`),
|
|
137
|
+
# which the dashboard posts its rrweb batches to (201).
|
|
138
|
+
#
|
|
139
|
+
# Each visit gets a recording of its own. An rrweb stream replays only
|
|
140
|
+
# against the full snapshot it began with, so two tabs on one
|
|
141
|
+
# conversation need two recordings. The recording caps
|
|
142
|
+
# (ActionAgent.recording_limits) then bound one visit.
|
|
143
|
+
#
|
|
144
|
+
# 400 without a conversation id, 404 for a conversation of an agent the
|
|
145
|
+
# caller cannot reach. 401 when recordings have owners and the caller
|
|
146
|
+
# resolves to none, since the caller could not reach the recording
|
|
147
|
+
# afterwards.
|
|
148
|
+
def create
|
|
149
|
+
context_id = integer_param(:agent_context_id)
|
|
150
|
+
return render(json: { error: "agent_context_id is required" }, status: :bad_request) if context_id.nil?
|
|
151
|
+
|
|
152
|
+
context = AgentContext.for_agents(owner_agents).find(context_id)
|
|
153
|
+
owner = recording_owner
|
|
154
|
+
if owner.nil? && SessionRecording.owner_association
|
|
155
|
+
return render(json: { error: "Sign in to record this conversation" }, status: :unauthorized)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
recording = SessionRecording.start!(agent_context: context, source: "dashboard", owner: owner)
|
|
159
|
+
render json: { recording: dashboard_recording_json(recording) }, status: :created
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# POST /api/session_recordings/:id/events
|
|
163
|
+
# A batch of browser events (RecordingEventIngest) from a dashboard
|
|
164
|
+
# session, with the owner's credentials masked in it. A recorder holding
|
|
165
|
+
# the recording's ingest token posts to the same path, and is answered
|
|
166
|
+
# by RecordingEventIngestController.
|
|
167
|
+
#
|
|
168
|
+
# 503 when the credentials cannot be read: the batch is not stored
|
|
169
|
+
# without them masked.
|
|
170
|
+
def create_events
|
|
171
|
+
secrets =
|
|
172
|
+
begin
|
|
173
|
+
owner_credentials
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
Rails.logger.warn("[ActionAgent] credential lookup for a recording batch failed: #{e.class}: #{e.message}")
|
|
176
|
+
return render(json: { error: "The batch could not be checked for credentials", code: "credential_check_failed" },
|
|
177
|
+
status: :service_unavailable)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
result = RecordingEventIngest.call(@recording, request.raw_post, secrets: secrets)
|
|
181
|
+
render json: result.body, status: result.status
|
|
182
|
+
end
|
|
183
|
+
|
|
101
184
|
# GET /api/session_recordings/:id/snapshot/:action_id
|
|
102
185
|
# Get a specific snapshot (screenshot or DOM)
|
|
103
186
|
def snapshot
|
|
@@ -270,6 +353,7 @@ module ActionAgent
|
|
|
270
353
|
render json: { error: "Not authorized" }, status: :forbidden
|
|
271
354
|
return
|
|
272
355
|
end
|
|
356
|
+
return unless authorize_action!(:manage_recordings, @recording)
|
|
273
357
|
|
|
274
358
|
@recording.destroy!
|
|
275
359
|
render json: { message: "Recording deleted" }
|
|
@@ -277,10 +361,9 @@ module ActionAgent
|
|
|
277
361
|
|
|
278
362
|
private
|
|
279
363
|
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
# than 403 so ids stay unenumerable.
|
|
364
|
+
# Opens a recording only when can_manage_recording? allows it, the
|
|
365
|
+
# reachability reachable_recordings lists by. 404 rather than 403 so
|
|
366
|
+
# ids stay unenumerable.
|
|
284
367
|
def set_recording
|
|
285
368
|
@recording = SessionRecording.find(params[:id])
|
|
286
369
|
return if can_manage_recording?(@recording)
|
|
@@ -288,17 +371,30 @@ module ActionAgent
|
|
|
288
371
|
not_found
|
|
289
372
|
end
|
|
290
373
|
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
374
|
+
# Refuses the workbench's recordings and every batch a dashboard session
|
|
375
|
+
# posts when the host turned capture off.
|
|
376
|
+
def require_dashboard_capture!
|
|
377
|
+
return if ActionAgent.capture_dashboard_sessions?
|
|
378
|
+
|
|
379
|
+
render json: { error: "Session capture is turned off on this dashboard", code: "capture_disabled" }, status: :forbidden
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Whom a recording the caller starts belongs to: the record #owned
|
|
383
|
+
# scopes SessionRecording by, so the caller finds it again.
|
|
384
|
+
def recording_owner
|
|
385
|
+
case SessionRecording.owner_association
|
|
386
|
+
when :user then current_user
|
|
387
|
+
when :account then current_account
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def dashboard_recording_json(recording)
|
|
392
|
+
{
|
|
393
|
+
id: recording.id,
|
|
394
|
+
agent_context_id: recording.agent_context_id,
|
|
395
|
+
source: recording.source,
|
|
396
|
+
status: recording.status
|
|
397
|
+
}
|
|
302
398
|
end
|
|
303
399
|
|
|
304
400
|
def can_manage_recording?(recording)
|
|
@@ -313,6 +409,41 @@ module ActionAgent
|
|
|
313
409
|
false
|
|
314
410
|
end
|
|
315
411
|
|
|
412
|
+
# The rows that sort after +row+ in RecordingEvent.chronological order.
|
|
413
|
+
def after_row(row)
|
|
414
|
+
table = RecordingEvent.arel_table
|
|
415
|
+
later_in_batch = table[:batch_index].gt(row.batch_index)
|
|
416
|
+
.or(table[:batch_index].eq(row.batch_index).and(table[:id].gt(row.id)))
|
|
417
|
+
table[:occurred_from].gt(row.occurred_from)
|
|
418
|
+
.or(table[:occurred_from].eq(row.occurred_from).and(later_in_batch))
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# The first +limit+ of +rows+, ending early once EVENTS_PAGE_BYTES of
|
|
422
|
+
# events are in the page. Always at least one row.
|
|
423
|
+
def page_of_rows(rows, limit)
|
|
424
|
+
bytes = 0
|
|
425
|
+
rows.first(limit).take_while do |row|
|
|
426
|
+
fits = bytes.zero? || bytes + row.byte_size <= EVENTS_PAGE_BYTES
|
|
427
|
+
bytes += row.byte_size
|
|
428
|
+
fits
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# rrweb events are returned as recorded, since a DOM attribute may carry
|
|
433
|
+
# any name and a stripped snapshot would not replay. Other kinds lose
|
|
434
|
+
# SessionRecording::SENSITIVE_STATE_KEYS, as they do in a timeline.
|
|
435
|
+
def event_row_json(row)
|
|
436
|
+
{
|
|
437
|
+
id: row.id,
|
|
438
|
+
kind: row.kind,
|
|
439
|
+
occurred_from: row.occurred_from.utc.iso8601(3),
|
|
440
|
+
occurred_to: row.occurred_to.utc.iso8601(3),
|
|
441
|
+
clock_offset_ms: row.clock_offset_ms,
|
|
442
|
+
event_count: row.event_count,
|
|
443
|
+
events: row.kind == "rrweb" ? row.events : SessionRecording.without_sensitive_state(row.events)
|
|
444
|
+
}
|
|
445
|
+
end
|
|
446
|
+
|
|
316
447
|
def recording_summary(recording)
|
|
317
448
|
{
|
|
318
449
|
id: recording.id,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
module Api
|
|
5
|
+
# The sessions a caller can replay (SessionIndex), and the timelines of
|
|
6
|
+
# sessions that need no recording: a conversation, a run, or the run that
|
|
7
|
+
# replayed an evaluation scenario. Each timeline adds the browser lane of
|
|
8
|
+
# any recording linked to it. See SessionTimeline.
|
|
9
|
+
class SessionsController < BaseController
|
|
10
|
+
# A filter value the index cannot read.
|
|
11
|
+
class InvalidFilter < ArgumentError; end
|
|
12
|
+
|
|
13
|
+
before_action :require_owner!, only: :index
|
|
14
|
+
|
|
15
|
+
# GET /api/sessions
|
|
16
|
+
#
|
|
17
|
+
# Parameters, each optional:
|
|
18
|
+
# agent_id one of the caller's agents
|
|
19
|
+
# user "me": sessions whose runs ran on behalf of the caller
|
|
20
|
+
# (#agent_actor), and the caller's own browser recordings
|
|
21
|
+
# source dashboard, evaluation or agent
|
|
22
|
+
# outcome failed or passed
|
|
23
|
+
# from, to last activity in [from, to), ISO 8601 times or dates
|
|
24
|
+
# before the next_before of the previous page
|
|
25
|
+
# per_page 25 by default, at most 100
|
|
26
|
+
def index
|
|
27
|
+
index = SessionIndex.new(
|
|
28
|
+
agents: owner_agents, recordings: reachable_recordings, filters: index_filters,
|
|
29
|
+
per_page: clamped_param(:per_page, default: SessionIndex::DEFAULT_PER_PAGE, min: 1, max: SessionIndex::MAX_PER_PAGE),
|
|
30
|
+
before: params[:before].is_a?(String) ? params[:before] : nil
|
|
31
|
+
)
|
|
32
|
+
render json: index.as_json
|
|
33
|
+
rescue InvalidFilter, SessionIndex::InvalidCursor => e
|
|
34
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /api/sessions/:kind/:id/timeline, where +kind+ is context, run or
|
|
38
|
+
# scenario_result.
|
|
39
|
+
def timeline
|
|
40
|
+
timeline =
|
|
41
|
+
case params[:kind]
|
|
42
|
+
when "context" then SessionTimeline.for_context(owned_context, timeline_scope)
|
|
43
|
+
when "run" then SessionTimeline.for_run(owned_run(params[:id]), timeline_scope)
|
|
44
|
+
when "scenario_result" then SessionTimeline.for_run(scenario_result_run, timeline_scope)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
render json: RecordingEvent.generate_json(timeline: timeline.retitle(params[:kind], integer_param(:id)).as_json)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# "Mine" compares the identity runs record (#agent_actor), which a host
|
|
53
|
+
# may resolve to a record other than its signed-in user.
|
|
54
|
+
def index_filters
|
|
55
|
+
mine = choice_param(:user, %w[me]).present?
|
|
56
|
+
SessionIndex::Filters.new(
|
|
57
|
+
agent_id: integer_param(:agent_id),
|
|
58
|
+
actor: mine ? agent_actor || SessionIndex::NO_ACTOR : nil,
|
|
59
|
+
user: mine ? current_user : nil,
|
|
60
|
+
source: choice_param(:source, SessionIndex::SOURCES),
|
|
61
|
+
outcome: choice_param(:outcome, SessionIndex::OUTCOMES),
|
|
62
|
+
from: time_param(:from),
|
|
63
|
+
to: time_param(:to)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# +name+'s value when it is one of +choices+, nil when absent.
|
|
68
|
+
# @raise [InvalidFilter] for any other value
|
|
69
|
+
def choice_param(name, choices)
|
|
70
|
+
value = params[name]
|
|
71
|
+
return nil if value.blank?
|
|
72
|
+
return value if value.is_a?(String) && choices.include?(value)
|
|
73
|
+
|
|
74
|
+
raise InvalidFilter, "#{name} must be one of: #{choices.join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# +name+ as a Time: an ISO 8601 time, or a date read as its first moment
|
|
78
|
+
# in the app's time zone. Nil when absent.
|
|
79
|
+
# @raise [InvalidFilter] for anything else
|
|
80
|
+
def time_param(name)
|
|
81
|
+
value = params[name]
|
|
82
|
+
return nil if value.blank?
|
|
83
|
+
raise ArgumentError unless value.is_a?(String)
|
|
84
|
+
|
|
85
|
+
value.match?(/\A\d{4}-\d{2}-\d{2}\z/) ? Date.iso8601(value).in_time_zone : Time.iso8601(value)
|
|
86
|
+
rescue ArgumentError
|
|
87
|
+
raise InvalidFilter, "#{name} must be an ISO 8601 time or date"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def owned_context
|
|
91
|
+
AgentContext.for_agents(owner_agents).find(params[:id])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def owned_run(id)
|
|
95
|
+
AgentRun.where(agent: owner_agents).find(id)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The run a scenario result was replayed by. A result belongs to the
|
|
99
|
+
# caller through its evaluation's agent.
|
|
100
|
+
def scenario_result_run
|
|
101
|
+
evaluations = Evaluation.where(agent: owner_agents)
|
|
102
|
+
result = EvaluationScenarioResult
|
|
103
|
+
.where(evaluation_run: EvaluationRun.where(evaluation: evaluations))
|
|
104
|
+
.find(params[:id])
|
|
105
|
+
raise ActiveRecord::RecordNotFound unless result.agent_run_id
|
|
106
|
+
|
|
107
|
+
owned_run(result.agent_run_id)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|