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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +61 -0
- data/CODE_OF_CONDUCT.md +11 -0
- data/CONTRIBUTING.md +42 -0
- data/LICENSE.txt +21 -0
- data/NOTICE.md +92 -0
- data/README.md +139 -0
- data/SECURITY.md +46 -0
- data/docs/API.md +177 -0
- data/docs/CNAME +1 -0
- data/docs/RAILS.md +167 -0
- data/docs/README.md +16 -0
- data/docs/RELEASE_ACTION_PLAN.md +81 -0
- data/docs/RELEASE_AUDIT.md +158 -0
- data/docs/RELEASING.md +131 -0
- data/docs/SECURITY_MODEL.md +117 -0
- data/docs/SPEC_BASELINE.md +107 -0
- data/docs/WORK.md +260 -0
- data/docs/_advanced/live-backend-qualification.md +74 -0
- data/docs/_advanced/rails-integration.md +56 -0
- data/docs/_config.yml +109 -0
- data/docs/_core_features/inspect-extract.md +91 -0
- data/docs/_core_features/offline-act.md +110 -0
- data/docs/_core_features/operation-lifecycle.md +63 -0
- data/docs/_core_features/source-policy.md +61 -0
- data/docs/_data/navigation.yml +12 -0
- data/docs/_data/sidebar.yml +8 -0
- data/docs/_data/social_links.yml +3 -0
- data/docs/_getting_started/configuration.md +68 -0
- data/docs/_getting_started/getting-started.md +77 -0
- data/docs/_getting_started/overview.md +52 -0
- data/docs/_plugins/versioned_docs.rb +46 -0
- data/docs/_reference/api-reference.md +29 -0
- data/docs/_reference/release-history.md +23 -0
- data/docs/_reference/upgrading.md +33 -0
- data/docs/bin/build-api.sh +23 -0
- data/docs/bin/build.sh +161 -0
- data/docs/bin/export-api-markdown.rb +53 -0
- data/docs/bin/link_versions.rb +54 -0
- data/docs/bin/postprocess_api_seo.rb +129 -0
- data/docs/bin/prepare_versions.rb +40 -0
- data/docs/bin/serve.sh +12 -0
- data/docs/bin/versions.yml +14 -0
- data/docs/index.md +82 -0
- data/examples/plain_ruby.rb +34 -0
- data/examples/rails_adapter.rb +47 -0
- data/lib/generators/ruby_llm/browser/install_generator.rb +29 -0
- data/lib/generators/ruby_llm/browser/templates/create_ruby_llm_browser_tables.rb.tt +60 -0
- data/lib/ruby_llm/browser/approval.rb +128 -0
- data/lib/ruby_llm/browser/approval_store.rb +38 -0
- data/lib/ruby_llm/browser/backend.rb +99 -0
- data/lib/ruby_llm/browser/budget.rb +90 -0
- data/lib/ruby_llm/browser/capabilities.rb +76 -0
- data/lib/ruby_llm/browser/configuration.rb +74 -0
- data/lib/ruby_llm/browser/context.rb +117 -0
- data/lib/ruby_llm/browser/doctor.rb +134 -0
- data/lib/ruby_llm/browser/engine.rb +326 -0
- data/lib/ruby_llm/browser/errors.rb +86 -0
- data/lib/ruby_llm/browser/fake_backend.rb +134 -0
- data/lib/ruby_llm/browser/immutable.rb +19 -0
- data/lib/ruby_llm/browser/in_memory_approval_store.rb +67 -0
- data/lib/ruby_llm/browser/in_memory_operation_store.rb +174 -0
- data/lib/ruby_llm/browser/operation.rb +218 -0
- data/lib/ruby_llm/browser/operation_store.rb +92 -0
- data/lib/ruby_llm/browser/policy.rb +298 -0
- data/lib/ruby_llm/browser/rails/active_record_approval_store.rb +72 -0
- data/lib/ruby_llm/browser/rails/active_record_operation_store.rb +231 -0
- data/lib/ruby_llm/browser/rails/actor_record.rb +13 -0
- data/lib/ruby_llm/browser/rails/approval_record.rb +49 -0
- data/lib/ruby_llm/browser/rails/operation_job.rb +163 -0
- data/lib/ruby_llm/browser/rails/operation_record.rb +56 -0
- data/lib/ruby_llm/browser/rails/reaper.rb +117 -0
- data/lib/ruby_llm/browser/rails/schema.rb +81 -0
- data/lib/ruby_llm/browser/rails.rb +79 -0
- data/lib/ruby_llm/browser/redactor.rb +159 -0
- data/lib/ruby_llm/browser/result.rb +102 -0
- data/lib/ruby_llm/browser/stagehand_backend.rb +332 -0
- data/lib/ruby_llm/browser/tool.rb +70 -0
- data/lib/ruby_llm/browser/tools/act.rb +91 -0
- data/lib/ruby_llm/browser/tools/extract.rb +60 -0
- data/lib/ruby_llm/browser/tools/inspect.rb +38 -0
- data/lib/ruby_llm/browser/url_guard.rb +253 -0
- data/lib/ruby_llm/browser/version.rb +9 -0
- data/lib/ruby_llm/browser.rb +79 -0
- 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
|