actionagent 0.0.0 → 1.2.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/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +92 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +334 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +71 -0
- metadata +209 -12
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# SandboxOrchestrator
|
|
5
|
+
#
|
|
6
|
+
# Unified interface for managing agent sandbox sessions.
|
|
7
|
+
# Supports multiple backends for cloud-agnostic deployment:
|
|
8
|
+
#
|
|
9
|
+
# - incus: Self-hosted Incus containers (any Linux host)
|
|
10
|
+
# - cloud_run: Google Cloud Run Jobs (serverless)
|
|
11
|
+
# - kubernetes: Kubernetes pods (GKE, EKS, self-hosted k8s)
|
|
12
|
+
#
|
|
13
|
+
# Configuration:
|
|
14
|
+
# Set SANDBOX_BACKEND environment variable to choose backend.
|
|
15
|
+
# Default: "incus" for simplicity
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# orchestrator = SandboxOrchestrator.new
|
|
19
|
+
# result = orchestrator.create_sandbox(session)
|
|
20
|
+
# status = orchestrator.status(container_id)
|
|
21
|
+
# orchestrator.terminate(container_id)
|
|
22
|
+
#
|
|
23
|
+
class SandboxOrchestrator
|
|
24
|
+
# The engine ships only the in-memory backend. Anything that talks to
|
|
25
|
+
# real infrastructure (Incus, Kubernetes, Cloud Run) is registered by
|
|
26
|
+
# the app that operates it, so the engine carries none of those SDKs:
|
|
27
|
+
#
|
|
28
|
+
# ActionAgent.sandbox_backends = {
|
|
29
|
+
# "cloud_run" => "CloudRunService"
|
|
30
|
+
# }
|
|
31
|
+
BUILT_IN_BACKENDS = { "mock" => "ActionAgent::MockSandboxBackend" }.freeze
|
|
32
|
+
|
|
33
|
+
# Backends disagree on what to call each verb. Candidates are tried in
|
|
34
|
+
# order and the first the backend responds to wins, so a host-registered
|
|
35
|
+
# class needs no adapter of its own.
|
|
36
|
+
ADAPTER_METHODS = {
|
|
37
|
+
create: %i[create_sandbox create_sandbox_pod create_sandbox_job],
|
|
38
|
+
status: %i[status container_status pod_status job_status],
|
|
39
|
+
terminate: %i[terminate terminate_pod cancel_job],
|
|
40
|
+
list: %i[list_sandboxes list_sandbox_pods list_jobs],
|
|
41
|
+
cleanup: %i[cleanup_expired cleanup_expired_pods cleanup_expired_jobs]
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
class UnsupportedBackendError < StandardError; end
|
|
45
|
+
|
|
46
|
+
# All backend names available in this install.
|
|
47
|
+
def self.backends
|
|
48
|
+
BUILT_IN_BACKENDS.merge(ActionAgent.sandbox_backends.to_h.transform_keys(&:to_s))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The backend used when none is named: whatever the host app configured
|
|
52
|
+
# as sandbox_service, falling back to the in-memory one.
|
|
53
|
+
def self.default_backend
|
|
54
|
+
name = ENV["SANDBOX_BACKEND"].presence || ActionAgent.sandbox_service.to_s
|
|
55
|
+
backends.key?(name) ? name : "mock"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(backend: nil)
|
|
59
|
+
@backend_name = (backend || self.class.default_backend).to_s
|
|
60
|
+
class_name = self.class.backends[@backend_name]
|
|
61
|
+
raise UnsupportedBackendError, "Unknown backend: #{@backend_name}" if class_name.nil?
|
|
62
|
+
|
|
63
|
+
@backend = class_name.constantize.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :backend_name
|
|
67
|
+
|
|
68
|
+
# Create a new sandbox for the given session
|
|
69
|
+
#
|
|
70
|
+
# @param sandbox_session [SandboxSession] The session to create a sandbox for
|
|
71
|
+
# @param instance_tier [String, Symbol, SandboxInstanceTier] Optional instance tier
|
|
72
|
+
# @return [Hash] Sandbox details including ID/name and URL
|
|
73
|
+
def create_sandbox(sandbox_session, instance_tier: nil)
|
|
74
|
+
# Resolve tier
|
|
75
|
+
tier = resolve_tier(instance_tier)
|
|
76
|
+
|
|
77
|
+
method = adapter_method(:create)
|
|
78
|
+
result = if accepts_instance_tier?(method)
|
|
79
|
+
@backend.public_send(method, sandbox_session, instance_tier: tier)
|
|
80
|
+
else
|
|
81
|
+
@backend.public_send(method, sandbox_session)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Normalize response format across backends
|
|
85
|
+
{
|
|
86
|
+
sandbox_id: result[:container_name] || result[:pod_name] || result[:job_name],
|
|
87
|
+
url: result[:url],
|
|
88
|
+
ip: result[:container_ip] || result[:pod_ip],
|
|
89
|
+
backend: @backend_name,
|
|
90
|
+
instance_tier: result[:instance_tier] || tier&.id,
|
|
91
|
+
resources: result[:resources],
|
|
92
|
+
hourly_cost: result[:hourly_cost] || tier&.hourly_cost&.to_f,
|
|
93
|
+
created_at: result[:created_at] || Time.current
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# List available instance tiers
|
|
98
|
+
#
|
|
99
|
+
# @param category [String, nil] Optional category filter (free, pro, enterprise)
|
|
100
|
+
# @return [Array<SandboxInstanceTier>] Available tiers
|
|
101
|
+
def available_tiers(category: nil)
|
|
102
|
+
tiers = SandboxInstanceTier.available
|
|
103
|
+
tiers = tiers.select { |t| t.category == category.to_s } if category
|
|
104
|
+
tiers
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get a specific instance tier
|
|
108
|
+
#
|
|
109
|
+
# @param tier_id [String, Symbol] Tier ID
|
|
110
|
+
# @return [SandboxInstanceTier]
|
|
111
|
+
def get_tier(tier_id)
|
|
112
|
+
SandboxInstanceTier.find(tier_id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get the status of a sandbox
|
|
116
|
+
#
|
|
117
|
+
# @param sandbox_id [String] The sandbox ID (container name, pod name, etc.)
|
|
118
|
+
# @return [Hash] Sandbox status
|
|
119
|
+
def status(sandbox_id)
|
|
120
|
+
@backend.public_send(adapter_method(:status), sandbox_id)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Terminate a sandbox
|
|
124
|
+
#
|
|
125
|
+
# @param sandbox_id [String] The sandbox ID to terminate
|
|
126
|
+
# @return [Boolean] true if terminated
|
|
127
|
+
def terminate(sandbox_id)
|
|
128
|
+
@backend.public_send(adapter_method(:terminate), sandbox_id)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# List all active sandboxes
|
|
132
|
+
#
|
|
133
|
+
# @return [Array<Hash>] List of sandbox statuses
|
|
134
|
+
def list_sandboxes
|
|
135
|
+
@backend.public_send(adapter_method(:list))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Cleanup expired sandboxes
|
|
139
|
+
#
|
|
140
|
+
# @return [Integer] Number of sandboxes cleaned up
|
|
141
|
+
def cleanup_expired
|
|
142
|
+
@backend.public_send(adapter_method(:cleanup))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Check if the backend is healthy
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] true if backend is reachable
|
|
148
|
+
def healthy?
|
|
149
|
+
# A backend that can list is reachable; one that cannot is assumed
|
|
150
|
+
# healthy because there is nothing to probe.
|
|
151
|
+
list_sandboxes if ADAPTER_METHODS[:list].any? { |m| @backend.respond_to?(m) }
|
|
152
|
+
true
|
|
153
|
+
rescue => e
|
|
154
|
+
Rails.logger.error("Sandbox backend health check failed: #{e.message}")
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Get backend-specific configuration info
|
|
159
|
+
#
|
|
160
|
+
# @return [Hash] Backend configuration
|
|
161
|
+
def backend_info
|
|
162
|
+
{
|
|
163
|
+
name: @backend_name,
|
|
164
|
+
class: @backend.class.name,
|
|
165
|
+
healthy: healthy?,
|
|
166
|
+
features: backend_features
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# The backend's method for +verb+, or a clear error naming what it
|
|
173
|
+
# would have to implement.
|
|
174
|
+
def adapter_method(verb)
|
|
175
|
+
ADAPTER_METHODS.fetch(verb).find { |m| @backend.respond_to?(m) } ||
|
|
176
|
+
raise(UnsupportedBackendError,
|
|
177
|
+
"#{@backend.class} implements none of #{ADAPTER_METHODS.fetch(verb).join(', ')}")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def accepts_instance_tier?(method)
|
|
181
|
+
@backend.method(method).parameters.any? { |_type, name| name == :instance_tier }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def resolve_tier(tier_param)
|
|
185
|
+
return nil unless tier_param
|
|
186
|
+
|
|
187
|
+
if tier_param.is_a?(SandboxInstanceTier)
|
|
188
|
+
tier_param
|
|
189
|
+
else
|
|
190
|
+
SandboxInstanceTier.find(tier_param)
|
|
191
|
+
end
|
|
192
|
+
rescue ArgumentError
|
|
193
|
+
Rails.logger.warn("Unknown instance tier: #{tier_param}, using default")
|
|
194
|
+
SandboxInstanceTier.default_tier
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def backend_features
|
|
198
|
+
# A host-registered backend can describe itself; otherwise fall back
|
|
199
|
+
# to what the engine knows about the backends it has seen.
|
|
200
|
+
return @backend.features if @backend.respond_to?(:features)
|
|
201
|
+
|
|
202
|
+
case @backend_name
|
|
203
|
+
when "incus"
|
|
204
|
+
{
|
|
205
|
+
cloud_agnostic: true,
|
|
206
|
+
isolation: "namespaces + apparmor",
|
|
207
|
+
networking: "bridge",
|
|
208
|
+
persistent_storage: true,
|
|
209
|
+
live_migration: true,
|
|
210
|
+
self_hosted: true
|
|
211
|
+
}
|
|
212
|
+
when "kubernetes"
|
|
213
|
+
{
|
|
214
|
+
cloud_agnostic: true,
|
|
215
|
+
isolation: "pods + gvisor",
|
|
216
|
+
networking: "cni + network_policies",
|
|
217
|
+
persistent_storage: true,
|
|
218
|
+
live_migration: false,
|
|
219
|
+
self_hosted: true
|
|
220
|
+
}
|
|
221
|
+
when "cloud_run"
|
|
222
|
+
{
|
|
223
|
+
cloud_agnostic: false,
|
|
224
|
+
isolation: "gvisor",
|
|
225
|
+
networking: "vpc_connector",
|
|
226
|
+
persistent_storage: false,
|
|
227
|
+
live_migration: false,
|
|
228
|
+
self_hosted: false
|
|
229
|
+
}
|
|
230
|
+
when "mock"
|
|
231
|
+
{
|
|
232
|
+
cloud_agnostic: true,
|
|
233
|
+
isolation: "none",
|
|
234
|
+
networking: "mock",
|
|
235
|
+
persistent_storage: false,
|
|
236
|
+
live_migration: false,
|
|
237
|
+
self_hosted: true
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Service for managing browser session recordings
|
|
5
|
+
# Captures agent browser interactions for replay and handoff to human users
|
|
6
|
+
class SessionRecordingService
|
|
7
|
+
class RecordingError < StandardError; end
|
|
8
|
+
|
|
9
|
+
attr_reader :recording
|
|
10
|
+
|
|
11
|
+
def initialize(recording = nil)
|
|
12
|
+
@recording = recording
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Start a new recording session
|
|
16
|
+
def self.start(agent_run: nil, sandbox_session: nil, name: nil)
|
|
17
|
+
recording = SessionRecording.start!(
|
|
18
|
+
agent_run: agent_run,
|
|
19
|
+
sandbox_session: sandbox_session,
|
|
20
|
+
name: name
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
new(recording)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Find or create a recording for a context
|
|
27
|
+
def self.for_context(agent_run: nil, sandbox_session: nil)
|
|
28
|
+
recording = SessionRecording.recording.find_by(
|
|
29
|
+
agent_run: agent_run,
|
|
30
|
+
sandbox_session: sandbox_session
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
recording ? new(recording) : start(agent_run: agent_run, sandbox_session: sandbox_session)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Record a browser navigation
|
|
37
|
+
def navigate(url:, screenshot: nil)
|
|
38
|
+
record_action(
|
|
39
|
+
action_type: "navigate",
|
|
40
|
+
value: url,
|
|
41
|
+
screenshot: screenshot,
|
|
42
|
+
metadata: { url: url }
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Record a click action
|
|
47
|
+
def click(selector:, screenshot: nil, metadata: {})
|
|
48
|
+
record_action(
|
|
49
|
+
action_type: "click",
|
|
50
|
+
selector: selector,
|
|
51
|
+
screenshot: screenshot,
|
|
52
|
+
metadata: metadata.merge(element: selector)
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Record a type/input action
|
|
57
|
+
def type(selector:, text:, screenshot: nil, metadata: {})
|
|
58
|
+
record_action(
|
|
59
|
+
action_type: "type",
|
|
60
|
+
selector: selector,
|
|
61
|
+
value: text,
|
|
62
|
+
screenshot: screenshot,
|
|
63
|
+
metadata: metadata
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Record a form fill (multiple fields)
|
|
68
|
+
def fill_form(fields:, screenshot: nil)
|
|
69
|
+
record_action(
|
|
70
|
+
action_type: "form_fill",
|
|
71
|
+
value: fields.to_json,
|
|
72
|
+
screenshot: screenshot,
|
|
73
|
+
metadata: { fields: fields, field_count: fields.size }
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Record a key press
|
|
78
|
+
def key_press(key:, screenshot: nil)
|
|
79
|
+
record_action(
|
|
80
|
+
action_type: "key_press",
|
|
81
|
+
value: key,
|
|
82
|
+
screenshot: screenshot
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Record a scroll action
|
|
87
|
+
def scroll(position:, screenshot: nil)
|
|
88
|
+
record_action(
|
|
89
|
+
action_type: "scroll",
|
|
90
|
+
metadata: { scroll_position: position },
|
|
91
|
+
screenshot: screenshot
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Record a hover action
|
|
96
|
+
def hover(selector:, screenshot: nil)
|
|
97
|
+
record_action(
|
|
98
|
+
action_type: "hover",
|
|
99
|
+
selector: selector,
|
|
100
|
+
screenshot: screenshot
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Record a select option action
|
|
105
|
+
def select_option(selector:, values:, screenshot: nil)
|
|
106
|
+
record_action(
|
|
107
|
+
action_type: "select",
|
|
108
|
+
selector: selector,
|
|
109
|
+
value: values.join(", "),
|
|
110
|
+
screenshot: screenshot,
|
|
111
|
+
metadata: { values: values }
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Record a file upload
|
|
116
|
+
def file_upload(paths:, screenshot: nil)
|
|
117
|
+
record_action(
|
|
118
|
+
action_type: "file_upload",
|
|
119
|
+
value: paths.join(", "),
|
|
120
|
+
screenshot: screenshot,
|
|
121
|
+
metadata: { paths: paths }
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Record a dialog interaction
|
|
126
|
+
def dialog(accept:, text: nil, screenshot: nil)
|
|
127
|
+
record_action(
|
|
128
|
+
action_type: "dialog",
|
|
129
|
+
value: text,
|
|
130
|
+
screenshot: screenshot,
|
|
131
|
+
metadata: { accepted: accept, prompt_text: text }
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Record a JavaScript evaluation
|
|
136
|
+
def evaluate(function:, result: nil, screenshot: nil)
|
|
137
|
+
record_action(
|
|
138
|
+
action_type: "evaluate",
|
|
139
|
+
value: function,
|
|
140
|
+
screenshot: screenshot,
|
|
141
|
+
metadata: { result: result&.to_s&.truncate(1000) }
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Capture a manual snapshot (screenshot + DOM)
|
|
146
|
+
def capture_snapshot(screenshot: nil, dom: nil, full_page: false)
|
|
147
|
+
snapshot_type = full_page ? "full_page" : "snapshot"
|
|
148
|
+
|
|
149
|
+
record_action(
|
|
150
|
+
action_type: "snapshot",
|
|
151
|
+
screenshot: screenshot,
|
|
152
|
+
dom_snapshot: dom,
|
|
153
|
+
metadata: { full_page: full_page }
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Capture browser state for handoff
|
|
158
|
+
def capture_handoff_state(url:, cookies: nil, local_storage: nil, session_storage: nil, form_values: nil)
|
|
159
|
+
@recording.update!(
|
|
160
|
+
metadata: @recording.metadata.merge(
|
|
161
|
+
handoff_state: {
|
|
162
|
+
url: url,
|
|
163
|
+
cookies: cookies,
|
|
164
|
+
local_storage: local_storage,
|
|
165
|
+
session_storage: session_storage,
|
|
166
|
+
form_values: form_values,
|
|
167
|
+
captured_at: Time.current.iso8601
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Complete the recording
|
|
174
|
+
def complete!
|
|
175
|
+
@recording.complete!
|
|
176
|
+
@recording
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Mark recording as failed
|
|
180
|
+
def fail!(error_message = nil)
|
|
181
|
+
@recording.fail!(error_message)
|
|
182
|
+
@recording
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Get the recording timeline for playback
|
|
186
|
+
def timeline
|
|
187
|
+
@recording.timeline
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Get handoff state for resuming session
|
|
191
|
+
def handoff_state
|
|
192
|
+
@recording.metadata["handoff_state"]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Class method: Generate signed URL for stored snapshot
|
|
196
|
+
def self.signed_url_for(storage_key, expires_in: 15.minutes)
|
|
197
|
+
snapshot = RecordingSnapshot.find_by(storage_key: storage_key)
|
|
198
|
+
snapshot&.signed_url(expires_in: expires_in)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Class method: Fetch snapshot content
|
|
202
|
+
def self.fetch_snapshot(storage_key)
|
|
203
|
+
snapshot = RecordingSnapshot.find_by(storage_key: storage_key)
|
|
204
|
+
return nil unless snapshot&.file&.attached?
|
|
205
|
+
|
|
206
|
+
snapshot.file.download
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
Rails.logger.error "Failed to fetch snapshot #{storage_key}: #{e.message}"
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def record_action(action_type:, selector: nil, value: nil, screenshot: nil, dom_snapshot: nil, metadata: {})
|
|
215
|
+
raise RecordingError, "No active recording" unless @recording
|
|
216
|
+
raise RecordingError, "Recording already completed" unless @recording.recording?
|
|
217
|
+
|
|
218
|
+
@recording.record_action!(
|
|
219
|
+
action_type: action_type,
|
|
220
|
+
selector: selector,
|
|
221
|
+
value: value,
|
|
222
|
+
screenshot: screenshot,
|
|
223
|
+
dom_snapshot: dom_snapshot,
|
|
224
|
+
metadata: metadata
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|