ruby_llm-browser 0.1.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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/CODE_OF_CONDUCT.md +11 -0
  4. data/CONTRIBUTING.md +42 -0
  5. data/LICENSE.txt +21 -0
  6. data/NOTICE.md +92 -0
  7. data/README.md +139 -0
  8. data/SECURITY.md +46 -0
  9. data/docs/API.md +177 -0
  10. data/docs/CNAME +1 -0
  11. data/docs/RAILS.md +167 -0
  12. data/docs/README.md +16 -0
  13. data/docs/RELEASE_ACTION_PLAN.md +81 -0
  14. data/docs/RELEASE_AUDIT.md +158 -0
  15. data/docs/RELEASING.md +131 -0
  16. data/docs/SECURITY_MODEL.md +117 -0
  17. data/docs/SPEC_BASELINE.md +107 -0
  18. data/docs/WORK.md +260 -0
  19. data/docs/_advanced/live-backend-qualification.md +74 -0
  20. data/docs/_advanced/rails-integration.md +56 -0
  21. data/docs/_config.yml +109 -0
  22. data/docs/_core_features/inspect-extract.md +91 -0
  23. data/docs/_core_features/offline-act.md +110 -0
  24. data/docs/_core_features/operation-lifecycle.md +63 -0
  25. data/docs/_core_features/source-policy.md +61 -0
  26. data/docs/_data/navigation.yml +12 -0
  27. data/docs/_data/sidebar.yml +8 -0
  28. data/docs/_data/social_links.yml +3 -0
  29. data/docs/_getting_started/configuration.md +68 -0
  30. data/docs/_getting_started/getting-started.md +77 -0
  31. data/docs/_getting_started/overview.md +52 -0
  32. data/docs/_plugins/versioned_docs.rb +46 -0
  33. data/docs/_reference/api-reference.md +29 -0
  34. data/docs/_reference/release-history.md +23 -0
  35. data/docs/_reference/upgrading.md +33 -0
  36. data/docs/bin/build-api.sh +23 -0
  37. data/docs/bin/build.sh +161 -0
  38. data/docs/bin/export-api-markdown.rb +53 -0
  39. data/docs/bin/link_versions.rb +54 -0
  40. data/docs/bin/postprocess_api_seo.rb +129 -0
  41. data/docs/bin/prepare_versions.rb +40 -0
  42. data/docs/bin/serve.sh +12 -0
  43. data/docs/bin/versions.yml +14 -0
  44. data/docs/index.md +82 -0
  45. data/examples/plain_ruby.rb +34 -0
  46. data/examples/rails_adapter.rb +47 -0
  47. data/lib/generators/ruby_llm/browser/install_generator.rb +29 -0
  48. data/lib/generators/ruby_llm/browser/templates/create_ruby_llm_browser_tables.rb.tt +60 -0
  49. data/lib/ruby_llm/browser/approval.rb +128 -0
  50. data/lib/ruby_llm/browser/approval_store.rb +38 -0
  51. data/lib/ruby_llm/browser/backend.rb +99 -0
  52. data/lib/ruby_llm/browser/budget.rb +90 -0
  53. data/lib/ruby_llm/browser/capabilities.rb +76 -0
  54. data/lib/ruby_llm/browser/configuration.rb +74 -0
  55. data/lib/ruby_llm/browser/context.rb +117 -0
  56. data/lib/ruby_llm/browser/doctor.rb +134 -0
  57. data/lib/ruby_llm/browser/engine.rb +326 -0
  58. data/lib/ruby_llm/browser/errors.rb +86 -0
  59. data/lib/ruby_llm/browser/fake_backend.rb +134 -0
  60. data/lib/ruby_llm/browser/immutable.rb +19 -0
  61. data/lib/ruby_llm/browser/in_memory_approval_store.rb +67 -0
  62. data/lib/ruby_llm/browser/in_memory_operation_store.rb +174 -0
  63. data/lib/ruby_llm/browser/operation.rb +218 -0
  64. data/lib/ruby_llm/browser/operation_store.rb +92 -0
  65. data/lib/ruby_llm/browser/policy.rb +298 -0
  66. data/lib/ruby_llm/browser/rails/active_record_approval_store.rb +72 -0
  67. data/lib/ruby_llm/browser/rails/active_record_operation_store.rb +231 -0
  68. data/lib/ruby_llm/browser/rails/actor_record.rb +13 -0
  69. data/lib/ruby_llm/browser/rails/approval_record.rb +49 -0
  70. data/lib/ruby_llm/browser/rails/operation_job.rb +163 -0
  71. data/lib/ruby_llm/browser/rails/operation_record.rb +56 -0
  72. data/lib/ruby_llm/browser/rails/reaper.rb +117 -0
  73. data/lib/ruby_llm/browser/rails/schema.rb +81 -0
  74. data/lib/ruby_llm/browser/rails.rb +79 -0
  75. data/lib/ruby_llm/browser/redactor.rb +159 -0
  76. data/lib/ruby_llm/browser/result.rb +102 -0
  77. data/lib/ruby_llm/browser/stagehand_backend.rb +332 -0
  78. data/lib/ruby_llm/browser/tool.rb +70 -0
  79. data/lib/ruby_llm/browser/tools/act.rb +91 -0
  80. data/lib/ruby_llm/browser/tools/extract.rb +60 -0
  81. data/lib/ruby_llm/browser/tools/inspect.rb +38 -0
  82. data/lib/ruby_llm/browser/url_guard.rb +253 -0
  83. data/lib/ruby_llm/browser/version.rb +9 -0
  84. data/lib/ruby_llm/browser.rb +79 -0
  85. metadata +267 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ # Durable bookkeeping for browser operations.
6
+ #
7
+ # The host supplies an implementation. {InMemoryOperationStore} is a fixture for
8
+ # tests and single-process demos only; production multi-worker or restart-safe use
9
+ # requires a durable store (for example the ActiveRecord store in
10
+ # `ruby_llm/browser/rails`).
11
+ #
12
+ # Every method is expected to be atomic. Callers assume:
13
+ #
14
+ # - {#reserve} never starts two operations for one idempotency key
15
+ # - {#update} rejects a stale writer (fencing)
16
+ # - the reservation is held for `unknown` / `cleanup_pending`, not released early
17
+ class OperationStore
18
+ # Atomically stores a new operation and enforces the actor's concurrency budget.
19
+ # @raise [InvalidInput] the id already exists
20
+ # @raise [BudgetExhausted] the concurrency slot is taken
21
+ def reserve(_operation)
22
+ raise NotImplementedError, "OperationStore#reserve must be implemented"
23
+ end
24
+
25
+ # Returns the existing operation for the same actor/run/idempotency key, or
26
+ # reserves +operation+. Duplicate jobs must attach, never start a second session.
27
+ def attach_or_reserve(_operation)
28
+ raise NotImplementedError, "OperationStore#attach_or_reserve must be implemented"
29
+ end
30
+
31
+ # @return [Operation, nil]
32
+ def fetch(_id)
33
+ raise NotImplementedError, "OperationStore#fetch must be implemented"
34
+ end
35
+
36
+ # Returns the operation or raises {InvalidInput} when absent.
37
+ def fetch!(_id)
38
+ raise NotImplementedError, "OperationStore#fetch! must be implemented"
39
+ end
40
+
41
+ # Claims the operation for one worker, bumping its fence token.
42
+ #
43
+ # @return [Operation] the newly leased operation
44
+ # @raise [NotAuthorized] a different actor/run tries to claim it
45
+ def lease(_id, actor_id:, run_id:, now:)
46
+ raise NotImplementedError, "OperationStore#lease must be implemented"
47
+ end
48
+
49
+ # Persists +operation+ only if its fence is current.
50
+ #
51
+ # @return [Operation] the stored operation
52
+ # @raise [InvalidInput] the writer is fenced (stale)
53
+ def update(_operation)
54
+ raise NotImplementedError, "OperationStore#update must be implemented"
55
+ end
56
+
57
+ # Atomically changes reserved to starting, checking the caller's fence. A second
58
+ # delivery must never start even when it read the row before the first claim.
59
+ def claim_start(_operation, now:)
60
+ raise NotImplementedError, "OperationStore#claim_start must be implemented"
61
+ end
62
+
63
+ # Persists a returned session even if cancellation fenced its starting worker.
64
+ def record_session(_operation, session_id:, now:)
65
+ raise NotImplementedError, "OperationStore#record_session must be implemented"
66
+ end
67
+
68
+ # Records provider-confirmed closure of this exact session, including cancellation.
69
+ def confirm_closed(_operation, session_id:, now:)
70
+ raise NotImplementedError, "OperationStore#confirm_closed must be implemented"
71
+ end
72
+
73
+ # Marks an operation unable to confirm its own cleanup.
74
+ #
75
+ # @return [Operation]
76
+ def mark_cleanup_pending(_id, actor_id:, run_id:, error_code: nil, now:)
77
+ raise NotImplementedError, "OperationStore#mark_cleanup_pending must be implemented"
78
+ end
79
+
80
+ # @return [Array<Operation>] operations currently holding a slot for the actor
81
+ def open_for(_actor_id)
82
+ raise NotImplementedError, "OperationStore#open_for must be implemented"
83
+ end
84
+
85
+ # @return [Array<Operation>] every open operation across all actors. Used by the
86
+ # cleanup reaper, which is a host-operated, system-wide sweep.
87
+ def open_operations
88
+ raise NotImplementedError, "OperationStore#open_operations must be implemented"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+
6
+ module RubyLLM
7
+ module Browser
8
+ # The host's source, schema, action and egress policy.
9
+ #
10
+ # Policy is composed by the host application and enforced by the Gem. It never
11
+ # infers permissions from model arguments or page text, and it fails closed: an
12
+ # unknown host, schema, action or an unqualified backend stops the operation.
13
+ class Policy
14
+ # JSON Schema keys this Gem accepts inside an extraction schema.
15
+ SCHEMA_KEYS = %w[
16
+ type description properties required items enum
17
+ additionalProperties title minimum maximum
18
+ minLength maxLength minItems maxItems
19
+ format default
20
+ ].freeze
21
+
22
+ ALLOWED_SCHEMA_TYPES = %w[object array string integer number boolean null].freeze
23
+
24
+ # Action methods an approved Act may use. There is no keypress/execute/download and
25
+ # no arbitrary JavaScript.
26
+ DEFAULT_ACTION_METHODS = %w[click fill press select_option check uncheck hover].freeze
27
+
28
+ # Sensitive operations are refused even with a broad host approval.
29
+ DEFAULT_FORBIDDEN_ACTIONS = [
30
+ /\b(?:purchase|buy|checkout|pay(?:ment)?|place[-_ ]?order|subscribe|billing)\b/i,
31
+ /\b(?:post|publish|tweet|toot|comment|reply|send(?:[-\s]?message)?|submit|share)\b/i,
32
+ /\b(?:delete|destroy|remove|deactivate|close[-_ ]?account|cancel[-_ ]?account|unsubscribe)\b/i,
33
+ /\b(?:password|passwd|credential|api[-_ ]?key|secret|credit[-_ ]?card|cvv|ssn|passport)\b/i,
34
+ /\b(?:sign[-\s]?in|log[-\s]?in|log[-\s]?out|authenticate|sign[-\s]?up|register|2fa|otp)\b/i,
35
+ /\b(?:transfer|withdraw|refund|donate)\b/i
36
+ ].freeze
37
+
38
+ DEFAULT_MAX_SCHEMA_DEPTH = 8
39
+ DEFAULT_MAX_SCHEMA_PROPERTIES = 64
40
+ DEFAULT_MAX_SCHEMA_BYTES = 16_384
41
+
42
+ attr_reader :allowed_hosts, :allowed_schemes, :allowed_ports, :max_result_bytes,
43
+ :allowed_action_methods, :forbidden_action_patterns,
44
+ :max_schema_depth, :max_schema_properties, :max_schema_bytes
45
+
46
+ def initialize(allowed_hosts:, allowed_schemes: UrlGuard::DEFAULT_SCHEMES,
47
+ allowed_ports: UrlGuard::DEFAULT_PORTS, verify_dns: true, resolver: nil,
48
+ max_result_bytes: nil,
49
+ allowed_action_methods: DEFAULT_ACTION_METHODS,
50
+ forbidden_action_patterns: DEFAULT_FORBIDDEN_ACTIONS,
51
+ max_schema_depth: DEFAULT_MAX_SCHEMA_DEPTH,
52
+ max_schema_properties: DEFAULT_MAX_SCHEMA_PROPERTIES,
53
+ max_schema_bytes: DEFAULT_MAX_SCHEMA_BYTES)
54
+ @url_guard = UrlGuard.new(
55
+ allowed_hosts: allowed_hosts, allowed_schemes: allowed_schemes,
56
+ allowed_ports: allowed_ports, verify_dns: verify_dns, resolver: resolver
57
+ )
58
+ @allowed_hosts = @url_guard.allowed_hosts
59
+ @allowed_schemes = @url_guard.allowed_schemes
60
+ @allowed_ports = @url_guard.allowed_ports
61
+ @max_result_bytes = positive_integer!(max_result_bytes || RubyLLM::Browser.configuration.max_result_bytes, :max_result_bytes)
62
+ @allowed_action_methods = Immutable.copy(normalize_action_methods!(allowed_action_methods))
63
+ @forbidden_action_patterns = (DEFAULT_FORBIDDEN_ACTIONS + Array(forbidden_action_patterns)).uniq.map do |pattern|
64
+ raise InvalidInput, "forbidden actions must be regexps" unless pattern.is_a?(Regexp)
65
+
66
+ pattern
67
+ end.freeze
68
+ @max_schema_depth = positive_integer!(max_schema_depth, :max_schema_depth)
69
+ @max_schema_properties = positive_integer!(max_schema_properties, :max_schema_properties)
70
+ @max_schema_bytes = positive_integer!(max_schema_bytes, :max_schema_bytes)
71
+ freeze
72
+ end
73
+
74
+ # Authorizes and normalizes a destination URL.
75
+ #
76
+ # @return [URI::Generic]
77
+ # @raise [InvalidInput] malformed or ambiguous URL
78
+ # @raise [NotAuthorized] policy denial
79
+ def authorize_destination!(url, actor_id: nil)
80
+ @url_guard.authorize!(url)
81
+ end
82
+
83
+ # Validates a model-supplied extraction schema: an object schema, bounded in depth,
84
+ # properties and bytes, using only known keys and types.
85
+ #
86
+ # @return [Hash] the schema (JSON string keys)
87
+ def authorize_schema!(schema)
88
+ schema = coerce_schema!(schema)
89
+ raise InvalidInput, "extraction schema must be an object schema" unless schema.is_a?(Hash)
90
+ json = Redactor.canonical_json(schema)
91
+ raise InvalidInput, "extraction schema is too large" if json.bytesize > max_schema_bytes
92
+ raise InvalidInput, "extraction schema must be an object schema" unless schema["type"] == "object"
93
+
94
+ validate_schema_node!(schema, depth: 1)
95
+ unless JSONSchemer.valid_schema?(schema)
96
+ raise InvalidInput, "extraction schema is malformed"
97
+ end
98
+ properties = schema["properties"] || {}
99
+ if properties.size > max_schema_properties
100
+ raise InvalidInput, "extraction schema has too many properties"
101
+ end
102
+
103
+ Immutable.copy(schema)
104
+ rescue JSON::GeneratorError, JSON::NestingError, SystemStackError
105
+ raise InvalidInput, "extraction schema is not bounded JSON"
106
+ end
107
+
108
+ # Rejects provider data that does not satisfy the host's JSON Schema.
109
+ def authorize_extraction!(data, schema:)
110
+ raise ProviderError, "browser extraction did not match the host schema" unless JSONSchemer.schema(schema).valid?(data)
111
+
112
+ data
113
+ end
114
+
115
+ # Redacts and bounds arbitrary extraction/observation data.
116
+ #
117
+ # @return [Array(Object, Boolean)] bounded data and truncation flag
118
+ def authorize_result!(data, max_bytes: nil, secrets: [])
119
+ Redactor.bound(data, max_bytes: [ max_bytes || max_result_bytes, max_result_bytes ].min, secrets: secrets)
120
+ end
121
+
122
+ # Validates one observed/approved action hash.
123
+ #
124
+ # @return [Hash] the normalized action (`description`, `method`, `selector`, `arguments`)
125
+ def authorize_action!(action)
126
+ action = symbolize_action!(action)
127
+ method = action.fetch("method", nil)&.to_s&.downcase
128
+ selector = action["selector"].to_s
129
+ description = action["description"].to_s
130
+ arguments = Array(action["arguments"]).map(&:to_s)
131
+
132
+ raise InvalidInput, "action method is required" if method.to_s.empty?
133
+ raise InvalidInput, "action selector is required" if selector.empty?
134
+ unless allowed_action_methods.include?(method)
135
+ raise NotAuthorized, "action method #{method.inspect} is not allowed"
136
+ end
137
+
138
+ haystack = ([ description, selector ] + arguments).join(" ")
139
+ if forbidden_action_patterns.any? { |pattern| pattern.match?(haystack) }
140
+ raise NotAuthorized, "action is outside the allowed, non-sensitive set"
141
+ end
142
+
143
+ {
144
+ "description" => description.empty? ? "#{method} #{selector}" : description,
145
+ "method" => method,
146
+ "selector" => selector,
147
+ "arguments" => arguments
148
+ }
149
+ end
150
+
151
+ # True when a backend proves the egress isolation this Gem requires.
152
+ def egress_qualified?(capabilities)
153
+ return false unless capabilities.is_a?(Capabilities)
154
+ return false unless capabilities.enforced_egress?
155
+ return false if capabilities.local_mode? && !capabilities.local_binary_pinned?
156
+ return false if capabilities.custom_headers_present? && !capabilities.custom_headers_approved?
157
+
158
+ true
159
+ end
160
+
161
+ # Raises unless the backend is egress-qualified. Called before any session starts.
162
+ def authorize_egress!(capabilities)
163
+ return true if egress_qualified?(capabilities)
164
+
165
+ raise UnsupportedSecurityCapability, egress_failure_reason(capabilities)
166
+ end
167
+
168
+ def to_h
169
+ {
170
+ allowed_hosts: allowed_hosts,
171
+ allowed_schemes: allowed_schemes,
172
+ allowed_ports: allowed_ports,
173
+ max_result_bytes: max_result_bytes,
174
+ allowed_action_methods: allowed_action_methods,
175
+ max_schema_depth: max_schema_depth
176
+ }
177
+ end
178
+
179
+ def digest = Redactor.digest(to_h)
180
+
181
+ private
182
+
183
+ def egress_failure_reason(capabilities)
184
+ return "backend did not report capabilities" unless capabilities.is_a?(Capabilities)
185
+
186
+ reasons = []
187
+ reasons << "browser egress isolation is not enforced" unless capabilities.enforced_egress?
188
+ if capabilities.local_mode? && !capabilities.local_binary_pinned?
189
+ reasons << "local mode requires a host-pinned server binary"
190
+ end
191
+ if capabilities.custom_headers_present? && !capabilities.custom_headers_approved?
192
+ reasons << "STAGEHAND_CUSTOM_HEADERS is set but not host-approved"
193
+ end
194
+ "backend cannot enforce egress isolation: #{reasons.join("; ")}"
195
+ end
196
+
197
+ def coerce_schema!(schema)
198
+ case schema
199
+ when Hash
200
+ stringify_keys(schema)
201
+ when String
202
+ parse_schema_json!(schema)
203
+ when Class
204
+ if schema.respond_to?(:to_json_schema)
205
+ return stringify_keys(schema.to_json_schema)
206
+ end
207
+ if schema.instance_methods.include?(:to_json_schema)
208
+ return stringify_keys(schema.new.to_json_schema)
209
+ end
210
+ raise InvalidInput, "unsupported schema class"
211
+ else
212
+ if schema.respond_to?(:to_json_schema)
213
+ stringify_keys(schema.to_json_schema)
214
+ else
215
+ raise InvalidInput, "extraction schema must be a Hash or JSON object string"
216
+ end
217
+ end
218
+ end
219
+
220
+ def parse_schema_json!(string)
221
+ raise InvalidInput, "extraction schema is too large" if string.bytesize > max_schema_bytes
222
+
223
+ parsed = JSON.parse(string)
224
+ raise InvalidInput, "extraction schema must be a JSON object" unless parsed.is_a?(Hash)
225
+
226
+ stringify_keys(parsed)
227
+ rescue JSON::ParserError
228
+ raise InvalidInput, "extraction schema is not valid JSON", cause: nil
229
+ end
230
+
231
+ def validate_schema_node!(node, depth:)
232
+ return unless node.is_a?(Hash) || node.is_a?(Array)
233
+
234
+ if depth > max_schema_depth
235
+ raise InvalidInput, "extraction schema is nested too deeply"
236
+ end
237
+
238
+ case node
239
+ when Array
240
+ node.each { |item| validate_schema_node!(item, depth: depth + 1) }
241
+ when Hash
242
+ node.each do |key, value|
243
+ name = key.to_s
244
+ unless SCHEMA_KEYS.include?(name)
245
+ raise InvalidInput, "unsupported schema key #{name.inspect}"
246
+ end
247
+ validate_schema_type!(value) if name == "type"
248
+ if name == "properties"
249
+ # Values are schemas; property names themselves are arbitrary.
250
+ value.each_value { |subschema| validate_schema_node!(subschema, depth: depth + 1) } if value.is_a?(Hash)
251
+ elsif name == "items" || name == "additionalProperties"
252
+ validate_schema_node!(value, depth: depth + 1)
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ def validate_schema_type!(value)
259
+ types = Array(value).map(&:to_s)
260
+ invalid = types - ALLOWED_SCHEMA_TYPES
261
+ raise InvalidInput, "unsupported schema type #{invalid.inspect}" if invalid.any?
262
+ end
263
+
264
+ def symbolize_action!(action)
265
+ raise InvalidInput, "action must be a Hash" unless action.is_a?(Hash)
266
+
267
+ action.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
268
+ end
269
+
270
+ def stringify_keys(value)
271
+ case value
272
+ when Hash
273
+ value.each_with_object({}) { |(key, nested), result| result[key.to_s] = stringify_keys(nested) }
274
+ when Array
275
+ value.map { |item| stringify_keys(item) }
276
+ else
277
+ value
278
+ end
279
+ end
280
+
281
+ def normalize_action_methods!(methods)
282
+ normalized = Array(methods).map { |method| method.to_s.downcase }
283
+ raise InvalidInput, "allowed_action_methods must not be empty" if normalized.empty?
284
+ unless (normalized - DEFAULT_ACTION_METHODS).empty?
285
+ raise InvalidInput, "only supported non-script action methods are allowed"
286
+ end
287
+
288
+ normalized.uniq
289
+ end
290
+
291
+ def positive_integer!(value, name)
292
+ raise InvalidInput, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive?
293
+
294
+ value
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Durable {RubyLLM::Browser::ApprovalStore} backed by ActiveRecord.
7
+ #
8
+ # {#consume_once} performs every check and uses a conditional database update:
9
+ # existence, active, actor/run, URL, page revision, operation/session and exact
10
+ # action digest — before it marks the row consumed. Two workers cannot both consume
11
+ # the same grant, and a replayed or cross-actor approval is refused.
12
+ class ActiveRecordApprovalStore < ApprovalStore
13
+ def initialize(record_class: ApprovalRecord)
14
+ super()
15
+ @record_class = record_class
16
+ end
17
+
18
+ def issue(approval)
19
+ @record_class.create!(
20
+ id: approval.id, actor_id: approval.actor_id, run_id: approval.run_id,
21
+ url: approval.url, action_digest: approval.action_digest,
22
+ action: @record_class.dump_action(approval.action),
23
+ page_revision: approval.page_revision, operation_id: approval.operation_id,
24
+ session_id: approval.session_id, issued_at: approval.issued_at,
25
+ expires_at: approval.expires_at, consumed_at: approval.consumed_at,
26
+ revoked: approval.revoked?, created_at: approval.issued_at,
27
+ updated_at: approval.issued_at
28
+ )
29
+ approval
30
+ rescue ActiveRecord::RecordNotUnique
31
+ raise InvalidInput, "approval #{approval.id} already exists"
32
+ end
33
+
34
+ def fetch(id)
35
+ @record_class.find_by(id: id)&.to_approval
36
+ end
37
+
38
+ def consume_once(id, actor_id:, run_id:, url:, action_digest:, now:, page_revision: nil,
39
+ operation_id: nil, session_id: nil)
40
+ record = @record_class.find_by(id: id)
41
+ raise ApprovalRequired, "no approval was issued" unless record
42
+ approval = record.to_approval
43
+ raise ApprovalStale, "approval #{id} was already consumed" if approval.consumed?
44
+ raise ApprovalStale, "approval #{id} was revoked" if approval.revoked?
45
+ raise ApprovalStale, "approval #{id} has expired" if approval.expired?(now)
46
+
47
+ unless approval.binding_matches?(
48
+ actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
49
+ page_revision: page_revision, operation_id: operation_id, session_id: session_id
50
+ )
51
+ raise ApprovalStale, "approval #{id} does not authorize this actor, URL, page or action"
52
+ end
53
+
54
+ consumed = approval.consume!(now: now)
55
+ changed = @record_class.where(id: id, consumed_at: nil, revoked: false)
56
+ .where("expires_at > ?", now)
57
+ .update_all(consumed_at: now, updated_at: now)
58
+ raise ApprovalStale, "approval is no longer active" unless changed == 1
59
+
60
+ consumed
61
+ end
62
+
63
+ def revoke(id)
64
+ changed = @record_class.where(id: id).update_all(revoked: true, updated_at: Time.now.utc)
65
+ raise ApprovalRequired, "no approval was issued" if changed.zero?
66
+
67
+ fetch(id)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Durable, multi-worker {RubyLLM::Browser::OperationStore} backed by ActiveRecord.
7
+ #
8
+ # Supports SQLite and PostgreSQL. Reservations acquire a durable per-actor write
9
+ # lock, including when the actor has no existing operations. Worker writes check
10
+ # the exact fence and legal transition. No database transaction spans network I/O.
11
+ #
12
+ # - a duplicate reservation loses the unique-index race and is reported as
13
+ # {InvalidInput} instead of silently starting a second session
14
+ # - a duplicated worker that re-leases an operation bumps its fence, and any
15
+ # earlier writer is then refused as stale (fenced out)
16
+ # - a cancelled operation keeps its row and reservation until closure/expiry is
17
+ # proven; cancellation fences stale workers by bumping the fence
18
+ #
19
+ # The store never interprets provider truth: it records the states the engine and
20
+ # reaper report.
21
+ class ActiveRecordOperationStore < OperationStore
22
+ def initialize(record_class: OperationRecord)
23
+ super()
24
+ @record_class = record_class
25
+ end
26
+
27
+ def reserve(operation)
28
+ @record_class.transaction do
29
+ lock_actor!(operation.actor_id)
30
+ if @record_class.exists?(operation.id)
31
+ raise InvalidInput, "operation #{operation.id} is already reserved"
32
+ end
33
+
34
+ enforce_concurrency!(operation)
35
+ insert!(operation)
36
+ end
37
+ rescue ActiveRecord::RecordNotUnique
38
+ raise InvalidInput, "operation #{operation.id} is already reserved"
39
+ end
40
+
41
+ def attach_or_reserve(operation)
42
+ @record_class.transaction do
43
+ lock_actor!(operation.actor_id)
44
+ existing = find_by_idempotency_key(operation.actor_id, operation.run_id, operation.idempotency_key)
45
+ if existing
46
+ unless existing.request_digest == operation.request_digest
47
+ raise InvalidInput, "idempotency key belongs to a different request"
48
+ end
49
+ return existing.to_operation
50
+ end
51
+ raise InvalidInput, "operation is already reserved" if @record_class.exists?(operation.id)
52
+
53
+ enforce_concurrency!(operation)
54
+ insert!(operation)
55
+ end
56
+ end
57
+
58
+ def fetch(id)
59
+ @record_class.find_by(id: id)&.to_operation
60
+ end
61
+
62
+ def fetch!(id)
63
+ fetch(id) || raise(InvalidInput, "unknown operation #{id.inspect}")
64
+ end
65
+
66
+ # Claims the operation for one worker by bumping its fence token.
67
+ def lease(id, actor_id:, run_id:, now:)
68
+ @record_class.transaction do
69
+ record = locked!(id)
70
+ assert_actor!(record, actor_id, run_id)
71
+ if record.to_operation.terminal?
72
+ raise InvalidInput, "operation #{id} is already #{record.state}"
73
+ end
74
+
75
+ leased = record.to_operation.with_fence(record.fence + 1, now: now)
76
+ persist!(record, leased)
77
+ end
78
+ end
79
+
80
+ # Persists +operation+ only when its fence is current.
81
+ def update(operation)
82
+ @record_class.transaction do
83
+ record = locked!(operation.id)
84
+ operation.validate_update!(record.to_operation)
85
+ persist!(record, operation)
86
+ end
87
+ end
88
+
89
+ def claim_start(operation, now:)
90
+ @record_class.transaction do
91
+ record = locked!(operation.id)
92
+ current = record.to_operation
93
+ operation.validate_update!(current)
94
+ raise InvalidInput, "operation was already started" unless current.state == :reserved
95
+ raise BudgetExhausted, "operation wall-clock budget exhausted" if current.expired?(now)
96
+
97
+ persist!(record, current.advance(:starting, now: now))
98
+ end
99
+ end
100
+
101
+ def record_session(operation, session_id:, now:)
102
+ @record_class.transaction do
103
+ record = locked!(operation.id)
104
+ assert_actor!(record, operation.actor_id, operation.run_id)
105
+ current = record.to_operation
106
+ if current.session_id && current.session_id != session_id
107
+ raise InvalidInput, "operation session cannot change"
108
+ end
109
+ target = current.fence == operation.fence ? :active : :cleanup_pending
110
+ persist!(record, current.advance(target, session_id: session_id, now: now))
111
+ end
112
+ end
113
+
114
+ def confirm_closed(operation, session_id:, now:)
115
+ @record_class.transaction do
116
+ record = locked!(operation.id)
117
+ assert_actor!(record, operation.actor_id, operation.run_id)
118
+ current = record.to_operation
119
+ raise InvalidInput, "operation session cannot change" unless current.session_id == session_id
120
+
121
+ target = current.cancel_requested? ? :cancelled : :closed
122
+ persist!(record, current.advance(target, now: now))
123
+ end
124
+ end
125
+
126
+ def mark_cleanup_pending(id, actor_id:, run_id:, error_code: nil, now:)
127
+ @record_class.transaction do
128
+ record = locked!(id)
129
+ assert_actor!(record, actor_id, run_id)
130
+ updated = record.to_operation.advance(:cleanup_pending, now: now, error_code: error_code)
131
+ persist!(record, updated)
132
+ end
133
+ end
134
+
135
+ # Host-initiated cancellation. Marks the operation and fences stale workers.
136
+ #
137
+ # @return [Operation] the cancelled (or already terminal) operation
138
+ def cancel(id, actor_id:, run_id:, now:)
139
+ @record_class.transaction do
140
+ record = locked!(id)
141
+ assert_actor!(record, actor_id, run_id)
142
+ current = record.to_operation
143
+ return current if current.terminal? || current.cancel_requested?
144
+
145
+ cancelled = current.with_fence(record.fence + 1, now: now).request_cancel(now: now)
146
+ persist!(record, cancelled)
147
+ end
148
+ end
149
+
150
+ def open_for(actor_id)
151
+ @record_class.where(actor_id: actor_id).where(state: open_states).order(:created_at).map(&:to_operation)
152
+ end
153
+
154
+ def open_operations
155
+ @record_class.where(state: open_states).order(:created_at).map(&:to_operation)
156
+ end
157
+
158
+ # Finds an existing operation by its actor/run idempotency key.
159
+ def find_by_idempotency_key(actor_id, run_id, key)
160
+ return nil if key.nil?
161
+
162
+ @record_class.find_by(actor_id: actor_id, run_id: run_id, idempotency_key: key)
163
+ end
164
+
165
+ private
166
+
167
+ def locked!(id)
168
+ # SQLite ignores SELECT FOR UPDATE. Acquire its write lock before reading
169
+ # so a second worker cannot read the old state and later overwrite it.
170
+ if @record_class.connection.adapter_name == "SQLite"
171
+ @record_class.where(id: id).update_all("fence = fence")
172
+ end
173
+ @record_class.lock.find_by(id: id) || raise(InvalidInput, "unknown operation #{id.inspect}")
174
+ end
175
+
176
+ def open_states = Operation::OPEN_STATES.map(&:to_s)
177
+
178
+ # The first statement acquires SQLite's write lock before any reads. On
179
+ # PostgreSQL both statements serialize on the same primary-key row.
180
+ def lock_actor!(actor_id)
181
+ unless %w[SQLite PostgreSQL].include?(@record_class.connection.adapter_name)
182
+ raise UnsupportedSecurityCapability, "durable store supports SQLite and PostgreSQL only"
183
+ end
184
+ ActorRecord.insert_all([ { id: actor_id, sequence: 0 } ], unique_by: :id)
185
+ ActorRecord.where(id: actor_id).update_all("sequence = sequence + 1")
186
+ end
187
+
188
+ def enforce_concurrency!(operation)
189
+ open = @record_class.where(actor_id: operation.actor_id).where(state: open_states).count
190
+ return if open < operation.budget.max_concurrent
191
+
192
+ raise BudgetExhausted,
193
+ "actor #{operation.actor_id.inspect} already holds #{open} open browser operation(s)"
194
+ end
195
+
196
+ def assert_actor!(record, actor_id, run_id)
197
+ return if record.actor_id == actor_id && record.run_id == run_id
198
+
199
+ raise NotAuthorized, "operation #{record.id} belongs to a different actor or run"
200
+ end
201
+
202
+ def insert!(operation)
203
+ record = @record_class.new(id: operation.id)
204
+ persist!(record, operation)
205
+ # Databases persist timestamps at their configured precision. Return that
206
+ # canonical identity rather than a nanosecond value that cannot round-trip.
207
+ record.reload.to_operation
208
+ end
209
+
210
+ def persist!(record, operation)
211
+ record.actor_id = operation.actor_id
212
+ record.run_id = operation.run_id
213
+ record.state = operation.state.to_s
214
+ record.session_id = operation.session_id
215
+ record.idempotency_key = operation.idempotency_key
216
+ record.request_digest = operation.request_digest
217
+ record.steps = operation.steps
218
+ record.bytes = operation.bytes
219
+ record.fence = operation.fence
220
+ record.error_code = operation.error_code
221
+ record.cancel_requested = operation.cancel_requested?
222
+ record.budget = @record_class.dump_budget(operation.budget)
223
+ record.created_at = operation.created_at
224
+ record.updated_at = operation.updated_at
225
+ record.save!
226
+ operation
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end