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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # A durable mutex per actor. Locking an empty set of operations cannot serialize
7
+ # the first two reservations, so reservations first write this shared row.
8
+ class ActorRecord < ActiveRecord::Base
9
+ self.table_name = "ruby_llm_browser_actors"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Durable row for a host-issued single-use {RubyLLM::Browser::Approval}.
7
+ #
8
+ # Only {ActiveRecordApprovalStore#consume_once} mutates a consumed approval, and it
9
+ # does so inside a row-locked transaction so two workers cannot both consume the
10
+ # same grant.
11
+ class ApprovalRecord < ActiveRecord::Base
12
+ self.table_name = "ruby_llm_browser_approvals"
13
+
14
+ # Approval timestamps come from the host clock, not from ActiveRecord.
15
+ self.record_timestamps = false
16
+
17
+ def to_approval
18
+ Approval.new(
19
+ id: id, actor_id: actor_id, run_id: run_id, url: url,
20
+ action_digest: action_digest, action: self.class.load_action(action),
21
+ page_revision: page_revision, operation_id: operation_id, session_id: session_id,
22
+ issued_at: self.class.to_time(issued_at), expires_at: self.class.to_time(expires_at),
23
+ consumed_at: self.class.to_time(consumed_at), revoked: revoked
24
+ )
25
+ end
26
+
27
+ def self.load_action(value)
28
+ return nil if value.nil?
29
+
30
+ value.is_a?(String) ? JSON.parse(value) : value
31
+ end
32
+
33
+ def self.dump_action(action)
34
+ return nil if action.nil?
35
+
36
+ Redactor.canonical_json(action)
37
+ end
38
+
39
+ def self.to_time(value)
40
+ case value
41
+ when nil then nil
42
+ when Time then value
43
+ else value.respond_to?(:to_time) ? value.to_time : value
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Active Job wrapper for one bounded browser operation.
7
+ #
8
+ # The job is deliberately *not* a scheduler: it never reserves a second operation
9
+ # and never retries an effectful call. The host reserves (or attaches to) a durable
10
+ # operation with {.enqueue} and this job attaches to that exact row. On delivery it
11
+ # leases the row (bumping its fence), refuses the wrong actor or an already-terminal
12
+ # operation, and runs the read-only operation through the {Engine}. A duplicated or
13
+ # resumed delivery finds a non-`:reserved` row and returns without a remote call; a
14
+ # worker that lost the lease is refused as stale.
15
+ #
16
+ # The context (backend, source policy, stores, budget, consent) is host-supplied and
17
+ # cannot be serialized, so the job builds it from a configured factory:
18
+ #
19
+ # RubyLLM::Browser::Rails.context_factory = lambda do |actor_id:, run_id:|
20
+ # RubyLLM::Browser::Context.new(
21
+ # actor_id: actor_id, run_id: run_id, backend: qualified_backend,
22
+ # source_policy: host_policy, operation_store: RubyLLM::Browser::Rails.operation_store
23
+ # )
24
+ # end
25
+ class OperationJob < ActiveJob::Base
26
+ # Do not write URLs, instructions or host-pinned schemas into Active Job logs.
27
+ self.log_arguments = false
28
+ class << self
29
+ # ->(actor_id:, run_id:) { RubyLLM::Browser::Context }
30
+ attr_accessor :context_factory
31
+
32
+ # Overrides the module store for this job class. Defaults to the module store.
33
+ attr_accessor :operation_store
34
+
35
+ # Reserves (or attaches to) the durable operation for +idempotency_key+ and
36
+ # enqueues exactly one job for it. A second call with the same key attaches to
37
+ # the same operation instead of reserving a new one.
38
+ #
39
+ # @return [Operation] the reserved or attached operation
40
+ def enqueue(actor_id:, run_id:, kind:, url:, budget:, idempotency_key: nil,
41
+ instruction: nil, schema: nil, max_bytes: nil, store: nil, now: nil)
42
+ configured_store = operation_store || RubyLLM::Browser::Rails.operation_store
43
+ store ||= configured_store
44
+ raise InvalidInput, "enqueue must use the job's operation store" unless store.equal?(configured_store)
45
+ now ||= RubyLLM::Browser::Rails.clock.call
46
+ unless %w[inspect extract].include?(kind.to_s)
47
+ raise InvalidInput, "browser jobs support inspect and extract only"
48
+ end
49
+ candidate = Operation.new(
50
+ id: Operation.generate_id, actor_id: actor_id, run_id: run_id, budget: budget,
51
+ state: :reserved, idempotency_key: idempotency_key, created_at: now, updated_at: now,
52
+ request_digest: request_digest(kind: kind, url: url, instruction: instruction,
53
+ schema: schema, max_bytes: max_bytes, budget: budget)
54
+ )
55
+ operation = store.attach_or_reserve(candidate)
56
+ perform_later(
57
+ operation_id: operation.id, actor_id: actor_id, run_id: run_id, kind: kind.to_s,
58
+ url: url, instruction: instruction, schema: schema, max_bytes: max_bytes
59
+ )
60
+ operation
61
+ end
62
+
63
+ # Binds an idempotency key to the exact host-issued payload and budget.
64
+ def request_digest(kind:, url:, instruction:, schema:, max_bytes:, budget:)
65
+ Redactor.digest(kind: kind.to_s, url: url, instruction: instruction, schema: schema,
66
+ max_bytes: max_bytes, budget: budget.to_h)
67
+ end
68
+ end
69
+
70
+ queue_as { RubyLLM::Browser::Rails.job_queue }
71
+
72
+ def perform(operation_id:, actor_id:, run_id:, kind:, url:, instruction: nil,
73
+ schema: nil, max_bytes: nil)
74
+ operation = store.fetch(operation_id)
75
+ return :missing if operation.nil?
76
+
77
+ assert_scope!(operation, actor_id, run_id)
78
+ digest = self.class.request_digest(kind: kind, url: url, instruction: instruction,
79
+ schema: schema, max_bytes: max_bytes, budget: operation.budget)
80
+ if operation.request_digest && operation.request_digest != digest
81
+ raise NotAuthorized, "job payload does not match the reserved request"
82
+ end
83
+ return :cancelled if operation.state == :cancelled
84
+ return :terminal if operation.terminal?
85
+ return :attached unless operation.state == :reserved
86
+
87
+ leased = store.lease(operation_id, actor_id: actor_id, run_id: run_id, now: now)
88
+ return :cancelled if leased.state == :cancelled
89
+
90
+ result = run(kind, leased, url: url, instruction: instruction, schema: schema,
91
+ max_bytes: max_bytes)
92
+
93
+ # Keep unknown action/start results, even when cleanup later closes a session.
94
+ final = settled(operation_id)
95
+ return :cancelled if final == :cancelled
96
+
97
+ result.ok? ? (final || result.to_h) : result.to_h
98
+ rescue InvalidInput => e
99
+ raise unless fenced?(e)
100
+
101
+ :fenced_out
102
+ end
103
+
104
+ private
105
+
106
+ # Reports the durable state if the host cancelled or another worker fenced this
107
+ # one while the operation was running; otherwise returns nil so the engine's own
108
+ # result is used.
109
+ def settled(operation_id)
110
+ final = store.fetch(operation_id)
111
+ case final&.state
112
+ when :cancelled then :cancelled
113
+ when :cleanup_pending then :cleanup_pending
114
+ when :unknown then :unknown
115
+ end
116
+ end
117
+
118
+ def run(kind, operation, url:, instruction:, schema:, max_bytes:)
119
+ context = build_context(operation)
120
+ unless context.is_a?(Context) && context.operation_store.equal?(store)
121
+ raise InvalidInput, "job context must use the job's operation store"
122
+ end
123
+ engine = Engine.new(context: context)
124
+ case kind.to_s
125
+ when "inspect"
126
+ engine.inspect(url: url, instruction: instruction, max_bytes: max_bytes, operation: operation)
127
+ when "extract"
128
+ engine.extract(url: url, schema: schema, instruction: instruction,
129
+ max_bytes: max_bytes, operation: operation)
130
+ else
131
+ raise InvalidInput, "unknown browser operation kind #{kind.inspect}"
132
+ end
133
+ end
134
+
135
+ def build_context(operation)
136
+ factory = self.class.context_factory || RubyLLM::Browser::Rails.context_factory
137
+ raise InvalidInput, "no RubyLLM::Browser context factory is configured" if factory.nil?
138
+
139
+ factory.call(actor_id: operation.actor_id, run_id: operation.run_id)
140
+ end
141
+
142
+ def assert_scope!(operation, actor_id, run_id)
143
+ return if operation.actor_id == actor_id && operation.run_id == run_id
144
+
145
+ raise NotAuthorized, "operation #{operation.id} belongs to a different actor or run"
146
+ end
147
+
148
+ def fenced?(error)
149
+ error.message.include?("stale") || error.message.include?("fenced") ||
150
+ error.message.include?("cancelled")
151
+ end
152
+
153
+ def store
154
+ self.class.operation_store || RubyLLM::Browser::Rails.operation_store
155
+ end
156
+
157
+ def now
158
+ RubyLLM::Browser::Rails.clock.call
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Durable row for one browser operation.
7
+ #
8
+ # The record is a projection of {RubyLLM::Browser::Operation}: the store is the
9
+ # only writer, and every write goes through a transaction that checks the fence
10
+ # token, so a stale, duplicated or cancelled worker cannot overwrite newer state.
11
+ class OperationRecord < ActiveRecord::Base
12
+ self.table_name = "ruby_llm_browser_operations"
13
+
14
+ # The domain object owns its own timestamps (driven by the injected clock), so
15
+ # ActiveRecord must not overwrite them on save.
16
+ self.record_timestamps = false
17
+
18
+ # Rebuilds the immutable domain object from persisted columns.
19
+ def to_operation
20
+ Operation.new(
21
+ id: id, actor_id: actor_id, run_id: run_id, state: state.to_sym,
22
+ session_id: session_id, idempotency_key: idempotency_key,
23
+ steps: steps, bytes: bytes, fence: fence, error_code: error_code,
24
+ budget: self.class.load_budget(budget),
25
+ cancel_requested: cancel_requested,
26
+ request_digest: request_digest,
27
+ created_at: self.class.to_time(created_at), updated_at: self.class.to_time(updated_at)
28
+ )
29
+ end
30
+
31
+ def self.load_budget(value)
32
+ case value
33
+ when Budget then value
34
+ when String then Budget.new(**JSON.parse(value, symbolize_names: true))
35
+ when Hash then Budget.new(**value.to_h { |key, nested| [ key.to_sym, nested ] })
36
+ else raise InvalidInput, "stored operation budget is missing or malformed"
37
+ end
38
+ end
39
+
40
+ def self.dump_budget(budget)
41
+ Redactor.canonical_json(budget.to_h)
42
+ end
43
+
44
+ # ActiveRecord hands back +ActiveSupport::TimeWithZone+, which is not a +Time+.
45
+ # The domain objects insist on real +Time+ values.
46
+ def self.to_time(value)
47
+ case value
48
+ when nil then nil
49
+ when Time then value
50
+ else value.respond_to?(:to_time) ? value.to_time : value
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Durable cleanup and reconciliation sweep for the Rails adapter.
7
+ #
8
+ # The reaper is the safety net for operations whose worker died, whose start or end
9
+ # outcome is unknown, or that exceeded their wall-clock budget. It never invents
10
+ # success: it asks the backend to reconcile, attempts a real session close, and
11
+ # leaves `unknown` / `cleanup_pending` visible (with the reservation held) until
12
+ # closure or provider expiry is confirmed.
13
+ #
14
+ # RubyLLM::Browser::Rails::Reaper.new(backend: qualified_backend).sweep
15
+ #
16
+ # Closing or re-marking an operation bumps its fence, so a surviving worker that
17
+ # resumes after the sweep is refused as stale.
18
+ class Reaper
19
+ # @param backend [RubyLLM::Browser::Backend] used to reconcile and close sessions
20
+ # @param store [RubyLLM::Browser::OperationStore] defaults to the module store
21
+ # @param ttl_seconds [Numeric, nil] optional idle TTL in addition to the budget
22
+ def initialize(backend:, store: nil, clock: nil, logger: nil, ttl_seconds: nil)
23
+ @backend = backend
24
+ @store = store || RubyLLM::Browser::Rails.operation_store
25
+ @clock = clock || RubyLLM::Browser::Rails.clock
26
+ @logger = logger || RubyLLM::Browser.logger
27
+ @ttl_seconds = ttl_seconds
28
+ if ttl_seconds && !(ttl_seconds.is_a?(Numeric) && ttl_seconds.finite? && ttl_seconds.positive?)
29
+ raise InvalidInput, "reaper TTL must be a positive finite number"
30
+ end
31
+ end
32
+
33
+ # Sweeps every open operation once.
34
+ #
35
+ # @return [Hash] counts keyed by outcome (`:checked`, `:closed`, `:cleanup_pending`,
36
+ # `:unknown`, `:kept`) plus `:at`
37
+ def sweep(now: nil)
38
+ time = now || @clock.call
39
+ summary = Hash.new(0)
40
+ @store.open_operations.each do |operation|
41
+ summary[:checked] += 1
42
+ summary[reap(operation, time)] += 1
43
+ end
44
+ summary[:at] = time
45
+ summary
46
+ end
47
+
48
+ private
49
+
50
+ def reap(operation, now)
51
+ return :kept unless reapable?(operation, now)
52
+
53
+ operation = @store.lease(operation.id, actor_id: operation.actor_id,
54
+ run_id: operation.run_id, now: now)
55
+ return close(operation, now) if never_started?(operation)
56
+
57
+ case @backend.reconcile(operation, now: now)
58
+ when :closed then close(operation, now)
59
+ else
60
+ if operation.session_id
61
+ close_remote?(operation, now) ? close(operation, now) : mark_cleanup_pending(operation, now)
62
+ else
63
+ write(operation, :unknown, now, error_code: SessionUnknown.code)
64
+ :unknown
65
+ end
66
+ end
67
+ rescue StandardError
68
+ @logger&.warn { "browser reaper could not reconcile an operation" }
69
+ :failed
70
+ end
71
+
72
+ def reapable?(operation, now)
73
+ operation.cancel_requested? || operation.cleanup_pending? || operation.expired?(now) || idle?(operation, now)
74
+ end
75
+
76
+ def idle?(operation, now)
77
+ return false if @ttl_seconds.nil?
78
+
79
+ last = operation.updated_at || operation.created_at
80
+ !last.nil? && (now - last) >= @ttl_seconds
81
+ end
82
+
83
+ # A reserved operation with no session never reached the provider.
84
+ def never_started?(operation)
85
+ operation.session_id.nil? && operation.state == :reserved
86
+ end
87
+
88
+ def close_remote?(operation, now)
89
+ return false if operation.session_id.nil?
90
+
91
+ session = Session.new(id: operation.session_id)
92
+ @backend.end_session(session, operation: operation, now: now)
93
+ rescue StandardError
94
+ false
95
+ end
96
+
97
+ def close(operation, now)
98
+ if operation.session_id
99
+ @store.confirm_closed(operation, session_id: operation.session_id, now: now)
100
+ else
101
+ write(operation, :closed, now)
102
+ end
103
+ :closed
104
+ end
105
+
106
+ def mark_cleanup_pending(operation, now)
107
+ write(operation, :cleanup_pending, now, error_code: CleanupPending.code)
108
+ :cleanup_pending
109
+ end
110
+
111
+ def write(operation, target, now, error_code: :keep)
112
+ @store.update(operation.advance(target, now: now, error_code: error_code))
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # Canonical table definitions for the durable Rails adapter.
7
+ #
8
+ # This is a plain-Ruby equivalent of the install generator's migration, so tests
9
+ # and non-migrated fixtures can create the schema directly:
10
+ #
11
+ # RubyLLM::Browser::Rails::Schema.create!(ActiveRecord::Base.connection)
12
+ module Schema
13
+ OPERATIONS_TABLE = "ruby_llm_browser_operations"
14
+ APPROVALS_TABLE = "ruby_llm_browser_approvals"
15
+
16
+ module_function
17
+
18
+ # Creates any missing tables. Existing tables are left untouched, so this is
19
+ # safe to call from a test harness or a boot task.
20
+ def create!(connection = ActiveRecord::Base.connection)
21
+ unless connection.table_exists?("ruby_llm_browser_actors")
22
+ connection.create_table("ruby_llm_browser_actors", id: :string) do |table|
23
+ table.integer :sequence, null: false, default: 0
24
+ end
25
+ end
26
+ create_operations!(connection) unless connection.table_exists?(OPERATIONS_TABLE)
27
+ create_approvals!(connection) unless connection.table_exists?(APPROVALS_TABLE)
28
+ true
29
+ end
30
+
31
+ def create_operations!(connection)
32
+ connection.create_table(OPERATIONS_TABLE, id: false) do |table|
33
+ table.string :id, null: false, primary_key: true
34
+ table.string :actor_id, null: false
35
+ table.string :run_id, null: false
36
+ table.string :state, null: false
37
+ table.string :session_id
38
+ table.string :idempotency_key
39
+ table.string :request_digest
40
+ table.integer :steps, null: false, default: 0
41
+ table.integer :bytes, null: false, default: 0
42
+ table.integer :fence, null: false, default: 0
43
+ table.string :error_code
44
+ table.boolean :cancel_requested, null: false, default: false
45
+ table.text :budget, null: false
46
+ table.datetime :created_at
47
+ table.datetime :updated_at
48
+
49
+ table.index [ :actor_id, :run_id, :idempotency_key ], unique: true,
50
+ name: "index_ruby_llm_browser_operations_on_idempotency",
51
+ where: "idempotency_key IS NOT NULL"
52
+ table.index %i[actor_id state], name: "index_ruby_llm_browser_operations_on_actor_state"
53
+ table.index :state, name: "index_ruby_llm_browser_operations_on_state"
54
+ end
55
+ end
56
+
57
+ def create_approvals!(connection)
58
+ connection.create_table(APPROVALS_TABLE, id: false) do |table|
59
+ table.string :id, null: false, primary_key: true
60
+ table.string :actor_id, null: false
61
+ table.string :run_id, null: false
62
+ table.string :url, null: false
63
+ table.string :action_digest, null: false
64
+ table.text :action
65
+ table.string :page_revision
66
+ table.string :operation_id
67
+ table.string :session_id
68
+ table.datetime :issued_at, null: false
69
+ table.datetime :expires_at, null: false
70
+ table.datetime :consumed_at
71
+ table.boolean :revoked, null: false, default: false
72
+ table.datetime :created_at
73
+ table.datetime :updated_at
74
+
75
+ table.index %i[actor_id run_id], name: "index_ruby_llm_browser_approvals_on_actor_run"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Rails 8 / ActiveRecord / Active Job adapter for RubyLLM::Browser.
4
+ #
5
+ # # config/initializers/ruby_llm_browser.rb
6
+ # require "ruby_llm/browser/rails"
7
+ #
8
+ # RubyLLM::Browser::Rails.context_factory = lambda do |actor_id:, run_id:|
9
+ # RubyLLM::Browser::Context.new(
10
+ # actor_id: actor_id, run_id: run_id, backend: qualified_backend,
11
+ # source_policy: host_policy,
12
+ # operation_store: RubyLLM::Browser::Rails.operation_store
13
+ # )
14
+ # end
15
+ #
16
+ # This file is *not* loaded by `require "ruby_llm/browser"`. The Gem core stays
17
+ # Rails-free; only hosts that opt in load ActiveRecord and Active Job.
18
+ require "ruby_llm/browser"
19
+
20
+ require "json"
21
+ require "active_record"
22
+ require "active_job"
23
+
24
+ require_relative "rails/schema"
25
+ require_relative "rails/actor_record"
26
+ require_relative "rails/operation_record"
27
+ require_relative "rails/approval_record"
28
+ require_relative "rails/active_record_operation_store"
29
+ require_relative "rails/active_record_approval_store"
30
+ require_relative "rails/operation_job"
31
+ require_relative "rails/reaper"
32
+
33
+ module RubyLLM
34
+ module Browser
35
+ # Durable, multi-worker adapter for hosts that run Rails (or plain ActiveRecord +
36
+ # Active Job). It provides an ActiveRecord operation/approval store, a scoped
37
+ # Active Job, and a cleanup reaper. It never guesses provider truth and never starts
38
+ # a session the core would refuse.
39
+ module Rails
40
+ class << self
41
+ # ->(actor_id:, run_id:) { RubyLLM::Browser::Context }, supplied by the host.
42
+ attr_accessor :context_factory
43
+
44
+ # Returns the current UTC time used by the adapter.
45
+ def clock
46
+ @clock ||= -> { Time.now.utc }
47
+ end
48
+ attr_writer :clock
49
+
50
+ # The Active Job queue name. Defaults to `:default`.
51
+ def job_queue
52
+ @job_queue || :default
53
+ end
54
+ attr_writer :job_queue
55
+
56
+ # Durable operation store. Defaults to an {ActiveRecordOperationStore}.
57
+ def operation_store
58
+ @operation_store ||= ActiveRecordOperationStore.new
59
+ end
60
+ attr_writer :operation_store
61
+
62
+ # Durable approval store. Defaults to an {ActiveRecordApprovalStore}.
63
+ def approval_store
64
+ @approval_store ||= ActiveRecordApprovalStore.new
65
+ end
66
+ attr_writer :approval_store
67
+
68
+ # Restores process defaults. Primarily a test helper.
69
+ def reset!
70
+ @context_factory = nil
71
+ @clock = nil
72
+ @job_queue = nil
73
+ @operation_store = nil
74
+ @approval_store = nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end