actionagent 0.0.0 → 1.2.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +103 -0
  4. data/app/assets/builds/action_agent.css +2 -0
  5. data/app/assets/builds/action_agent.js +163 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
  7. data/app/controllers/action_agent/api/agents_controller.rb +411 -0
  8. data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
  9. data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
  10. data/app/controllers/action_agent/api/base_controller.rb +112 -0
  11. data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
  12. data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
  13. data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
  14. data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
  15. data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
  16. data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
  17. data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
  18. data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
  19. data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
  20. data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
  21. data/app/controllers/action_agent/api/templates_controller.rb +94 -0
  22. data/app/controllers/action_agent/api/tools_controller.rb +58 -0
  23. data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
  24. data/app/controllers/action_agent/api/traces_controller.rb +136 -0
  25. data/app/controllers/action_agent/application_controller.rb +105 -0
  26. data/app/controllers/action_agent/dashboard_controller.rb +104 -0
  27. data/app/controllers/action_agent/traces_controller.rb +121 -0
  28. data/app/jobs/action_agent/agent_execution_job.rb +52 -0
  29. data/app/jobs/action_agent/application_job.rb +12 -0
  30. data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
  31. data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
  32. data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
  33. data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
  34. data/app/jobs/action_agent/trace_retention_job.rb +57 -0
  35. data/app/models/action_agent/agent.rb +343 -0
  36. data/app/models/action_agent/agent_context.rb +129 -0
  37. data/app/models/action_agent/agent_generation.rb +48 -0
  38. data/app/models/action_agent/agent_memory.rb +50 -0
  39. data/app/models/action_agent/agent_memory_entry.rb +14 -0
  40. data/app/models/action_agent/agent_message.rb +43 -0
  41. data/app/models/action_agent/agent_run.rb +151 -0
  42. data/app/models/action_agent/agent_template.rb +182 -0
  43. data/app/models/action_agent/agent_version.rb +48 -0
  44. data/app/models/action_agent/api_key.rb +53 -0
  45. data/app/models/action_agent/application_record.rb +27 -0
  46. data/app/models/action_agent/evaluation.rb +80 -0
  47. data/app/models/action_agent/evaluation_run.rb +20 -0
  48. data/app/models/action_agent/model_pricing.rb +80 -0
  49. data/app/models/action_agent/provider_key.rb +60 -0
  50. data/app/models/action_agent/recording_action.rb +119 -0
  51. data/app/models/action_agent/recording_snapshot.rb +88 -0
  52. data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
  53. data/app/models/action_agent/sandbox_run.rb +45 -0
  54. data/app/models/action_agent/sandbox_session.rb +160 -0
  55. data/app/models/action_agent/session_recording.rb +178 -0
  56. data/app/models/action_agent/telemetry_trace.rb +357 -0
  57. data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
  58. data/app/models/concerns/action_agent/ownable.rb +86 -0
  59. data/app/models/concerns/action_agent/session_recordable.rb +91 -0
  60. data/app/queries/action_agent/agent_executions.rb +201 -0
  61. data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
  62. data/app/serializers/action_agent/interaction_preview.rb +22 -0
  63. data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
  64. data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
  65. data/app/services/action_agent/agent_execution_service.rb +572 -0
  66. data/app/services/action_agent/agent_registrar.rb +197 -0
  67. data/app/services/action_agent/agent_scorecard.rb +191 -0
  68. data/app/services/action_agent/agent_toolbox.rb +504 -0
  69. data/app/services/action_agent/evaluation_runner_service.rb +481 -0
  70. data/app/services/action_agent/mcp_catalog.rb +247 -0
  71. data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
  72. data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
  73. data/app/services/action_agent/playwright_mcp_client.rb +148 -0
  74. data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
  75. data/app/services/action_agent/session_recording_service.rb +228 -0
  76. data/app/services/action_agent/tool_discovery.rb +617 -0
  77. data/app/views/action_agent/dashboard/index.html.erb +5 -0
  78. data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
  79. data/app/views/action_agent/traces/index.html.erb +135 -0
  80. data/app/views/action_agent/traces/metrics.html.erb +145 -0
  81. data/app/views/action_agent/traces/show.html.erb +36 -0
  82. data/app/views/layouts/action_agent/application.html.erb +94 -0
  83. data/app/views/layouts/action_agent/react.html.erb +19 -0
  84. data/config/routes.rb +144 -0
  85. data/lib/action_agent/compatibility.rb +49 -0
  86. data/lib/action_agent/engine.rb +51 -0
  87. data/lib/action_agent/version.rb +5 -0
  88. data/lib/action_agent.rb +388 -0
  89. data/lib/actionagent.rb +6 -0
  90. data/lib/generators/action_agent/install_generator.rb +137 -0
  91. data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
  92. data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
  93. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
  94. data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
  95. metadata +209 -12
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # Per-account LLM provider credential (Settings -> Provider API Keys).
5
+ # Generation runs (AgentExecutionService and the evaluation LLM judge)
6
+ # prefer these over the platform's ENV-configured keys, so users can run
7
+ # agents with their own OpenAI/Anthropic/OpenRouter accounts — or point
8
+ # ollama at their own host (e.g. a tunnel to a locally running instance).
9
+ #
10
+ # The credential is encrypted at rest with Active Record Encryption. API
11
+ # keys are never rendered back to the client — only a masked hint; ollama
12
+ # hosts are not secret and are shown in full (see #display_hint).
13
+ class ProviderKey < ApplicationRecord
14
+ # Providers that authenticate with an API key.
15
+ KEY_PROVIDERS = %w[openai anthropic openrouter].freeze
16
+ # Providers addressed by host URL instead of a key.
17
+ HOST_PROVIDERS = %w[ollama].freeze
18
+ PROVIDERS = (KEY_PROVIDERS + HOST_PROVIDERS).freeze
19
+
20
+ include Ownable
21
+ owned_by :account, :user
22
+
23
+ encrypts :credential if ActionAgent.encrypt_credentials
24
+
25
+ validates :provider, presence: true, inclusion: { in: PROVIDERS }
26
+ # One credential per provider per owner; which column that means
27
+ # depends on the configured mode, so it is checked at validation time.
28
+ validate :provider_unique_within_owner
29
+ validates :credential, presence: true, length: { maximum: 500 }
30
+ validates :credential, format: { with: %r{\Ahttps?://\S+\z}, message: "must be an http(s):// URL" },
31
+ if: :host_based?
32
+
33
+ def host_based?
34
+ HOST_PROVIDERS.include?(provider)
35
+ end
36
+
37
+ # Options merged into generate_with for runs owned by this key's owner,
38
+ # overriding the host app's config/active_agent.yml credentials.
39
+ def generation_options
40
+ host_based? ? { host: credential } : { access_token: credential }
41
+ end
42
+
43
+ # "sk-a…Q2z9" for keys; hosts are shown in full.
44
+ def display_hint
45
+ return credential if host_based?
46
+
47
+ "#{credential.first(4)}…#{credential.last(4)}"
48
+ end
49
+
50
+ private
51
+
52
+ def provider_unique_within_owner
53
+ return if provider.blank?
54
+
55
+ siblings = self.class.for_owner(owner)
56
+ siblings = siblings.where.not(id: id) if persisted?
57
+ errors.add(:provider, "has already been taken") if siblings.exists?(provider: provider)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class RecordingAction < ApplicationRecord
5
+ belongs_to :session_recording
6
+ has_one :snapshot, class_name: "RecordingSnapshot", dependent: :nullify
7
+
8
+ ACTION_TYPES = %w[
9
+ navigate
10
+ click
11
+ type
12
+ scroll
13
+ snapshot
14
+ select
15
+ hover
16
+ drag
17
+ file_upload
18
+ dialog
19
+ evaluate
20
+ wait
21
+ form_fill
22
+ key_press
23
+ focus
24
+ submit
25
+ handoff
26
+ user_action
27
+ completion
28
+ ].freeze
29
+
30
+ validates :action_type, presence: true, inclusion: { in: ACTION_TYPES }
31
+ validates :sequence, presence: true, uniqueness: { scope: :session_recording_id }
32
+ validates :timestamp_ms, presence: true
33
+
34
+ scope :ordered, -> { order(:sequence) }
35
+ scope :with_screenshots, -> { where.not(screenshot_key: nil) }
36
+
37
+ # Get the screenshot URL (signed URL from storage)
38
+ def screenshot_url(expires_in: 15.minutes)
39
+ return nil unless screenshot_key.present?
40
+
41
+ SessionRecordingService.signed_url_for(screenshot_key, expires_in: expires_in)
42
+ end
43
+
44
+ # Get the DOM snapshot content
45
+ def dom_snapshot_content
46
+ return nil unless dom_snapshot_key.present?
47
+
48
+ SessionRecordingService.fetch_snapshot(dom_snapshot_key)
49
+ end
50
+
51
+ # Format for API response
52
+ def as_json_for_api
53
+ {
54
+ id: id,
55
+ action_type: action_type,
56
+ sequence: sequence,
57
+ timestamp_ms: timestamp_ms,
58
+ selector: selector,
59
+ value: redacted_value,
60
+ screenshot_url: screenshot_url,
61
+ has_dom_snapshot: dom_snapshot_key.present?,
62
+ metadata: safe_metadata,
63
+ created_at: created_at.iso8601
64
+ }
65
+ end
66
+
67
+ # Get browser state at this action (for handoff)
68
+ def browser_state
69
+ {
70
+ url: extract_url,
71
+ form_values: metadata["form_values"],
72
+ scroll_position: metadata["scroll_position"],
73
+ active_element: selector,
74
+ action_type: action_type
75
+ }
76
+ end
77
+
78
+ private
79
+
80
+ def redacted_value
81
+ return value unless should_redact?
82
+
83
+ "[REDACTED]"
84
+ end
85
+
86
+ def should_redact?
87
+ return false unless value.present?
88
+
89
+ sensitive_patterns = [
90
+ /password/i,
91
+ /credit.?card/i,
92
+ /cvv/i,
93
+ /ssn/i,
94
+ /social.?security/i,
95
+ /\b\d{16}\b/, # Credit card numbers
96
+ /\b\d{3}-\d{2}-\d{4}\b/ # SSN format
97
+ ]
98
+
99
+ selector_is_sensitive = sensitive_patterns.any? { |p| selector&.match?(p) }
100
+ value_is_sensitive = sensitive_patterns.any? { |p| value.match?(p) }
101
+
102
+ selector_is_sensitive || value_is_sensitive
103
+ end
104
+
105
+ def safe_metadata
106
+ # Remove any sensitive data from metadata
107
+ metadata.except("password", "credit_card", "cvv", "ssn")
108
+ end
109
+
110
+ def extract_url
111
+ case action_type
112
+ when "navigate"
113
+ value
114
+ else
115
+ metadata["url"] || metadata["page_url"]
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ class RecordingSnapshot < ApplicationRecord
5
+ belongs_to :session_recording
6
+ belongs_to :recording_action, optional: true
7
+
8
+ # Guarded: a host app created with --skip-active-storage (or --minimal)
9
+ # has no has_one_attached, and the gem depends on railties rather than
10
+ # rails, so it may not even have the gem to require. Without this the
11
+ # engine dies during eager load at boot.
12
+ has_one_attached :file if defined?(ActiveStorage)
13
+
14
+ # Whether file attachment is available in this host app.
15
+ def self.attachments_available?
16
+ defined?(ActiveStorage) && method_defined?(:file)
17
+ end
18
+
19
+ SNAPSHOT_TYPES = %w[screenshot dom full_page].freeze
20
+
21
+ validates :storage_key, presence: true, uniqueness: true
22
+ validates :snapshot_type, presence: true, inclusion: { in: SNAPSHOT_TYPES }
23
+
24
+ scope :screenshots, -> { where(snapshot_type: "screenshot") }
25
+ scope :dom_snapshots, -> { where(snapshot_type: "dom") }
26
+ scope :ordered, -> { order(:created_at) }
27
+
28
+ # Get a signed URL for the file
29
+ def signed_url(expires_in: 15.minutes)
30
+ return nil unless self.class.attachments_available?
31
+ return nil unless file.attached?
32
+
33
+ file.url(expires_in: expires_in)
34
+ rescue StandardError
35
+ # Fallback if storage not configured
36
+ nil
37
+ end
38
+
39
+ # Store file data. Without Active Storage the row still records the
40
+ # snapshot's metadata; only the blob is skipped.
41
+ def store!(data, filename: nil, content_type: nil)
42
+ content_type ||= infer_content_type
43
+ filename ||= generate_filename
44
+
45
+ if self.class.attachments_available?
46
+ file.attach(
47
+ io: StringIO.new(data),
48
+ filename: filename,
49
+ content_type: content_type
50
+ )
51
+ end
52
+
53
+ update!(file_size_bytes: data.bytesize)
54
+ end
55
+
56
+ # For API response
57
+ def as_json_for_api
58
+ {
59
+ id: id,
60
+ storage_key: storage_key,
61
+ snapshot_type: snapshot_type,
62
+ width: width,
63
+ height: height,
64
+ file_size_bytes: file_size_bytes,
65
+ url: signed_url,
66
+ created_at: created_at.iso8601
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def infer_content_type
73
+ case snapshot_type
74
+ when "screenshot", "full_page"
75
+ "image/png"
76
+ when "dom"
77
+ "text/html"
78
+ else
79
+ "application/octet-stream"
80
+ end
81
+ end
82
+
83
+ def generate_filename
84
+ ext = snapshot_type == "dom" ? "html" : "png"
85
+ "#{storage_key.tr('/', '_')}.#{ext}"
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # SandboxInstanceTier
5
+ #
6
+ # Defines available instance sizes for sandbox sessions.
7
+ # Similar to Google Colab or Hugging Face Spaces hardware tiers.
8
+ #
9
+ # Usage:
10
+ # tier = SandboxInstanceTier.find(:gpu_t4)
11
+ # tier.cpu_cores # => 4
12
+ # tier.memory_gb # => 16
13
+ # tier.gpu # => "nvidia-t4"
14
+ # tier.hourly_cost # => 0.35
15
+ #
16
+ class SandboxInstanceTier
17
+ include ActiveModel::Model
18
+ include ActiveModel::Attributes
19
+
20
+ attribute :id, :string
21
+ attribute :name, :string
22
+ attribute :description, :string
23
+ attribute :cpu_cores, :integer
24
+ attribute :memory_gb, :integer
25
+ attribute :disk_gb, :integer
26
+ attribute :gpu, :string
27
+ attribute :gpu_memory_gb, :integer
28
+ attribute :hourly_cost, :decimal
29
+ attribute :monthly_cost, :decimal
30
+ attribute :available, :boolean, default: true
31
+ attribute :category, :string # free, pro, enterprise
32
+
33
+ # Instance tier definitions
34
+ # Pricing modeled after Colab/HuggingFace/Lambda Labs
35
+ TIERS = {
36
+ # --- Free Tier ---
37
+ free: {
38
+ id: "free",
39
+ name: "Free",
40
+ description: "Basic CPU instance for testing",
41
+ cpu_cores: 2,
42
+ memory_gb: 4,
43
+ disk_gb: 10,
44
+ gpu: nil,
45
+ gpu_memory_gb: nil,
46
+ hourly_cost: 0.00,
47
+ monthly_cost: 0.00,
48
+ category: "free"
49
+ },
50
+
51
+ # --- CPU Tiers ---
52
+ cpu_small: {
53
+ id: "cpu_small",
54
+ name: "CPU Small",
55
+ description: "2 vCPUs, 8GB RAM",
56
+ cpu_cores: 2,
57
+ memory_gb: 8,
58
+ disk_gb: 20,
59
+ gpu: nil,
60
+ gpu_memory_gb: nil,
61
+ hourly_cost: 0.05,
62
+ monthly_cost: 36.00,
63
+ category: "pro"
64
+ },
65
+
66
+ cpu_medium: {
67
+ id: "cpu_medium",
68
+ name: "CPU Medium",
69
+ description: "4 vCPUs, 16GB RAM",
70
+ cpu_cores: 4,
71
+ memory_gb: 16,
72
+ disk_gb: 50,
73
+ gpu: nil,
74
+ gpu_memory_gb: nil,
75
+ hourly_cost: 0.10,
76
+ monthly_cost: 72.00,
77
+ category: "pro"
78
+ },
79
+
80
+ cpu_large: {
81
+ id: "cpu_large",
82
+ name: "CPU Large",
83
+ description: "8 vCPUs, 32GB RAM",
84
+ cpu_cores: 8,
85
+ memory_gb: 32,
86
+ disk_gb: 100,
87
+ gpu: nil,
88
+ gpu_memory_gb: nil,
89
+ hourly_cost: 0.20,
90
+ monthly_cost: 144.00,
91
+ category: "pro"
92
+ },
93
+
94
+ cpu_xlarge: {
95
+ id: "cpu_xlarge",
96
+ name: "CPU XLarge",
97
+ description: "16 vCPUs, 64GB RAM",
98
+ cpu_cores: 16,
99
+ memory_gb: 64,
100
+ disk_gb: 200,
101
+ gpu: nil,
102
+ gpu_memory_gb: nil,
103
+ hourly_cost: 0.40,
104
+ monthly_cost: 288.00,
105
+ category: "enterprise"
106
+ },
107
+
108
+ # --- GPU Tiers ---
109
+ gpu_t4: {
110
+ id: "gpu_t4",
111
+ name: "GPU T4",
112
+ description: "NVIDIA T4 (16GB VRAM), 4 vCPUs, 16GB RAM",
113
+ cpu_cores: 4,
114
+ memory_gb: 16,
115
+ disk_gb: 50,
116
+ gpu: "nvidia-tesla-t4",
117
+ gpu_memory_gb: 16,
118
+ hourly_cost: 0.35,
119
+ monthly_cost: 252.00,
120
+ category: "pro"
121
+ },
122
+
123
+ gpu_l4: {
124
+ id: "gpu_l4",
125
+ name: "GPU L4",
126
+ description: "NVIDIA L4 (24GB VRAM), 8 vCPUs, 32GB RAM",
127
+ cpu_cores: 8,
128
+ memory_gb: 32,
129
+ disk_gb: 100,
130
+ gpu: "nvidia-l4",
131
+ gpu_memory_gb: 24,
132
+ hourly_cost: 0.70,
133
+ monthly_cost: 504.00,
134
+ category: "pro"
135
+ },
136
+
137
+ gpu_a10g: {
138
+ id: "gpu_a10g",
139
+ name: "GPU A10G",
140
+ description: "NVIDIA A10G (24GB VRAM), 8 vCPUs, 32GB RAM",
141
+ cpu_cores: 8,
142
+ memory_gb: 32,
143
+ disk_gb: 100,
144
+ gpu: "nvidia-a10g",
145
+ gpu_memory_gb: 24,
146
+ hourly_cost: 1.00,
147
+ monthly_cost: 720.00,
148
+ category: "enterprise"
149
+ },
150
+
151
+ gpu_a100_40: {
152
+ id: "gpu_a100_40",
153
+ name: "GPU A100 40GB",
154
+ description: "NVIDIA A100 (40GB VRAM), 12 vCPUs, 85GB RAM",
155
+ cpu_cores: 12,
156
+ memory_gb: 85,
157
+ disk_gb: 200,
158
+ gpu: "nvidia-a100-40gb",
159
+ gpu_memory_gb: 40,
160
+ hourly_cost: 2.50,
161
+ monthly_cost: 1800.00,
162
+ category: "enterprise"
163
+ },
164
+
165
+ gpu_a100_80: {
166
+ id: "gpu_a100_80",
167
+ name: "GPU A100 80GB",
168
+ description: "NVIDIA A100 (80GB VRAM), 12 vCPUs, 170GB RAM",
169
+ cpu_cores: 12,
170
+ memory_gb: 170,
171
+ disk_gb: 200,
172
+ gpu: "nvidia-a100-80gb",
173
+ gpu_memory_gb: 80,
174
+ hourly_cost: 4.00,
175
+ monthly_cost: 2880.00,
176
+ category: "enterprise"
177
+ },
178
+
179
+ # --- High Memory Tiers ---
180
+ highmem_medium: {
181
+ id: "highmem_medium",
182
+ name: "High Memory Medium",
183
+ description: "4 vCPUs, 64GB RAM",
184
+ cpu_cores: 4,
185
+ memory_gb: 64,
186
+ disk_gb: 100,
187
+ gpu: nil,
188
+ gpu_memory_gb: nil,
189
+ hourly_cost: 0.25,
190
+ monthly_cost: 180.00,
191
+ category: "pro"
192
+ },
193
+
194
+ highmem_large: {
195
+ id: "highmem_large",
196
+ name: "High Memory Large",
197
+ description: "8 vCPUs, 128GB RAM",
198
+ cpu_cores: 8,
199
+ memory_gb: 128,
200
+ disk_gb: 200,
201
+ gpu: nil,
202
+ gpu_memory_gb: nil,
203
+ hourly_cost: 0.50,
204
+ monthly_cost: 360.00,
205
+ category: "enterprise"
206
+ }
207
+ }.freeze
208
+
209
+ class << self
210
+ def all
211
+ TIERS.values.map { |attrs| new(attrs) }
212
+ end
213
+
214
+ def find(id)
215
+ id = id.to_sym
216
+ raise ArgumentError, "Unknown tier: #{id}" unless TIERS.key?(id)
217
+
218
+ new(TIERS[id])
219
+ end
220
+
221
+ def available
222
+ all.select(&:available)
223
+ end
224
+
225
+ def by_category(category)
226
+ all.select { |t| t.category == category.to_s }
227
+ end
228
+
229
+ def cpu_tiers
230
+ all.select { |t| t.gpu.nil? }
231
+ end
232
+
233
+ def gpu_tiers
234
+ all.select { |t| t.gpu.present? }
235
+ end
236
+
237
+ def free_tier
238
+ find(:free)
239
+ end
240
+
241
+ def default_tier
242
+ find(:cpu_small)
243
+ end
244
+ end
245
+
246
+ def free?
247
+ hourly_cost.zero?
248
+ end
249
+
250
+ def has_gpu?
251
+ gpu.present?
252
+ end
253
+
254
+ def display_price
255
+ if free?
256
+ "Free"
257
+ else
258
+ "$#{format('%.2f', hourly_cost)}/hr"
259
+ end
260
+ end
261
+
262
+ def display_specs
263
+ specs = [ "#{cpu_cores} vCPU", "#{memory_gb}GB RAM", "#{disk_gb}GB disk" ]
264
+ specs << "#{gpu_memory_gb}GB #{gpu_display_name}" if has_gpu?
265
+ specs.join(" | ")
266
+ end
267
+
268
+ def gpu_display_name
269
+ return nil unless gpu
270
+
271
+ case gpu
272
+ when "nvidia-tesla-t4" then "T4"
273
+ when "nvidia-l4" then "L4"
274
+ when "nvidia-a10g" then "A10G"
275
+ when "nvidia-a100-40gb" then "A100 40GB"
276
+ when "nvidia-a100-80gb" then "A100 80GB"
277
+ else gpu.gsub("nvidia-", "").upcase
278
+ end
279
+ end
280
+
281
+ # Convert to Incus resource limits
282
+ def to_incus_limits
283
+ limits = {
284
+ "limits.cpu" => cpu_cores.to_s,
285
+ "limits.memory" => "#{memory_gb}GB",
286
+ "limits.processes" => (cpu_cores * 250).to_s
287
+ }
288
+
289
+ # GPU passthrough requires host configuration
290
+ if has_gpu?
291
+ limits["nvidia.runtime"] = "true"
292
+ limits["nvidia.require.cuda"] = "true"
293
+ end
294
+
295
+ limits
296
+ end
297
+
298
+ # Convert to Kubernetes resource spec
299
+ def to_kubernetes_resources
300
+ resources = {
301
+ requests: {
302
+ cpu: "#{cpu_cores * 500}m",
303
+ memory: "#{memory_gb / 2}Gi"
304
+ },
305
+ limits: {
306
+ cpu: cpu_cores.to_s,
307
+ memory: "#{memory_gb}Gi"
308
+ }
309
+ }
310
+
311
+ if has_gpu?
312
+ resources[:limits]["nvidia.com/gpu"] = "1"
313
+ end
314
+
315
+ resources
316
+ end
317
+
318
+ # GCP machine type mapping
319
+ def gcp_machine_type
320
+ if has_gpu?
321
+ # GPU instances use n1 or a2 series
322
+ case gpu
323
+ when "nvidia-a100-40gb", "nvidia-a100-80gb"
324
+ "a2-highgpu-1g"
325
+ else
326
+ "n1-standard-#{cpu_cores}"
327
+ end
328
+ elsif memory_gb > cpu_cores * 8
329
+ # High memory ratio
330
+ "n2-highmem-#{cpu_cores}"
331
+ else
332
+ "n2-standard-#{cpu_cores}"
333
+ end
334
+ end
335
+
336
+ # GCP accelerator config
337
+ def gcp_accelerator_config
338
+ return nil unless has_gpu?
339
+
340
+ {
341
+ accelerator_type: gpu,
342
+ accelerator_count: 1
343
+ }
344
+ end
345
+
346
+ def as_json(*)
347
+ {
348
+ id: id,
349
+ name: name,
350
+ description: description,
351
+ specs: {
352
+ cpu_cores: cpu_cores,
353
+ memory_gb: memory_gb,
354
+ disk_gb: disk_gb,
355
+ gpu: gpu,
356
+ gpu_memory_gb: gpu_memory_gb
357
+ },
358
+ pricing: {
359
+ hourly_cost: hourly_cost.to_f,
360
+ monthly_cost: monthly_cost.to_f,
361
+ display: display_price
362
+ },
363
+ category: category,
364
+ available: available
365
+ }
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # SandboxRun tracks individual agent task executions within a sandbox container.
5
+ # This model uses SQLite in sandbox mode for isolated, ephemeral storage.
6
+ class SandboxRun < ApplicationRecord
7
+ enum :status, {
8
+ pending: 0,
9
+ running: 1,
10
+ completed: 2,
11
+ failed: 3,
12
+ cancelled: 4
13
+ }
14
+
15
+ validates :task, presence: true
16
+ validates :status, presence: true
17
+
18
+ scope :recent, -> { order(created_at: :desc) }
19
+ scope :completed_runs, -> { where(status: [ :completed, :failed ]) }
20
+
21
+ # Summary for API responses
22
+ def summary
23
+ {
24
+ id: id,
25
+ task: task.truncate(100),
26
+ status: status,
27
+ duration_ms: duration_ms,
28
+ tokens_used: tokens_used,
29
+ created_at: created_at&.iso8601,
30
+ completed_at: completed_at&.iso8601
31
+ }
32
+ end
33
+
34
+ # Detailed info including full result
35
+ def details
36
+ summary.merge(
37
+ task: task,
38
+ result: result,
39
+ error: error,
40
+ screenshots: screenshots || [],
41
+ started_at: started_at&.iso8601
42
+ )
43
+ end
44
+ end
45
+ end