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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Base class for every error this Gem raises deliberately.
|
|
6
|
+
#
|
|
7
|
+
# Each subclass carries a stable, machine-readable +code+ from the documented safe
|
|
8
|
+
# set. Messages are constructed by the Gem and never contain credentials, cookies,
|
|
9
|
+
# +cdp_url+ values or raw page dumps.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
# The stable code for this error class.
|
|
12
|
+
def self.code
|
|
13
|
+
const_defined?(:CODE, false) ? const_get(:CODE) : "ERROR"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The stable code for this error instance, e.g. "INVALID_INPUT".
|
|
17
|
+
def code
|
|
18
|
+
self.class.code
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A model-safe representation. Callers may hand this straight back to a tool
|
|
22
|
+
# result without leaking internals.
|
|
23
|
+
def to_h
|
|
24
|
+
{ code: code, message: message }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The caller supplied malformed or out-of-range input.
|
|
29
|
+
class InvalidInput < Error
|
|
30
|
+
CODE = "INVALID_INPUT"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The destination, actor, schema or action is not permitted by the host policy.
|
|
34
|
+
class NotAuthorized < Error
|
|
35
|
+
CODE = "NOT_AUTHORIZED"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The backend cannot enforce a security capability this Gem requires, so no session
|
|
39
|
+
# may start. Returned *before* any remote call.
|
|
40
|
+
class UnsupportedSecurityCapability < Error
|
|
41
|
+
CODE = "UNSUPPORTED_SECURITY_CAPABILITY"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A rendered, step, byte, concurrency or cost budget is exhausted.
|
|
45
|
+
class BudgetExhausted < Error
|
|
46
|
+
CODE = "BUDGET_EXHAUSTED"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A remote start or action has no confirmed outcome. Never treated as success and
|
|
50
|
+
# never automatically retried into a second session.
|
|
51
|
+
class SessionUnknown < Error
|
|
52
|
+
CODE = "SESSION_UNKNOWN"
|
|
53
|
+
|
|
54
|
+
# A returned identity must still be tracked/closed after a partial start failure.
|
|
55
|
+
# It is internal bookkeeping and is deliberately absent from Error#to_h.
|
|
56
|
+
attr_reader :session
|
|
57
|
+
|
|
58
|
+
def initialize(message = nil, session: nil)
|
|
59
|
+
@session = session
|
|
60
|
+
super(message)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Cleanup was attempted but is not confirmed. The reservation is held until closure
|
|
65
|
+
# or expiry is proven.
|
|
66
|
+
class CleanupPending < Error
|
|
67
|
+
CODE = "CLEANUP_PENDING"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# An action requires a host-issued approval that is missing.
|
|
71
|
+
class ApprovalRequired < Error
|
|
72
|
+
CODE = "APPROVAL_REQUIRED"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# An approval exists but is expired, revoked, cross-actor, replayed or bound to a
|
|
76
|
+
# different page revision or action digest.
|
|
77
|
+
class ApprovalStale < Error
|
|
78
|
+
CODE = "APPROVAL_STALE"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The remote provider returned an error, timed out or is otherwise unavailable.
|
|
82
|
+
class ProviderError < Error
|
|
83
|
+
CODE = "PROVIDER_ERROR"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Offline reference backend.
|
|
6
|
+
#
|
|
7
|
+
# It performs no network I/O at all, so it can honestly claim enforced egress
|
|
8
|
+
# isolation, and it never opens a paid session. Tests script its responses and
|
|
9
|
+
# inspect the recorded calls; it is the required default in CI.
|
|
10
|
+
#
|
|
11
|
+
# It does **not** prove anything about a real provider's network isolation, TTL or
|
|
12
|
+
# billing.
|
|
13
|
+
class FakeBackend < Backend
|
|
14
|
+
DEFAULT_OBSERVE = [
|
|
15
|
+
{ "description" => "Open the documentation link", "method" => "click",
|
|
16
|
+
"selector" => "a#docs", "arguments" => [] }
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
DEFAULT_EXTRACT = { "title" => "Example" }.freeze
|
|
20
|
+
DEFAULT_ACT = { "success" => true, "message" => "clicked", "actions" => [] }.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :calls
|
|
23
|
+
|
|
24
|
+
# @param capabilities [Capabilities] defaults to an egress-qualified fake backend
|
|
25
|
+
# @param start_error [Exception, nil] raised by {#start}
|
|
26
|
+
# @param navigate_error [Exception, nil]
|
|
27
|
+
# @param observe_error [Exception, nil]
|
|
28
|
+
# @param extract_error [Exception, nil]
|
|
29
|
+
# @param act_error [Exception, nil]
|
|
30
|
+
# @param end_error [Exception, nil]
|
|
31
|
+
# @param redirect_to [String, nil] simulate an in-provider redirect on navigate
|
|
32
|
+
# @param end_confirmed [Boolean] whether {#end_session} confirms closure
|
|
33
|
+
# @param reconcile_state [Symbol] what {#reconcile} reports
|
|
34
|
+
# @param session_id [String] the id returned by {#start}
|
|
35
|
+
def initialize(capabilities: nil, observe: nil, extract: nil, act: nil,
|
|
36
|
+
start_error: nil, navigate_error: nil, observe_error: nil,
|
|
37
|
+
extract_error: nil, act_error: nil, end_error: nil,
|
|
38
|
+
redirect_to: nil, end_confirmed: true, reconcile_state: :closed,
|
|
39
|
+
session_id: "fake-session-1")
|
|
40
|
+
super()
|
|
41
|
+
@capabilities = capabilities || Capabilities.new(
|
|
42
|
+
server_mode: :fake, egress_isolation: :enforced, act_supported: true
|
|
43
|
+
)
|
|
44
|
+
@observe = observe || DEFAULT_OBSERVE
|
|
45
|
+
@extract = extract || DEFAULT_EXTRACT
|
|
46
|
+
@act = act || DEFAULT_ACT
|
|
47
|
+
@start_error = start_error
|
|
48
|
+
@navigate_error = navigate_error
|
|
49
|
+
@observe_error = observe_error
|
|
50
|
+
@extract_error = extract_error
|
|
51
|
+
@act_error = act_error
|
|
52
|
+
@end_error = end_error
|
|
53
|
+
@redirect_to = redirect_to
|
|
54
|
+
@end_confirmed = end_confirmed
|
|
55
|
+
@reconcile_state = reconcile_state
|
|
56
|
+
@session_id = session_id
|
|
57
|
+
@calls = []
|
|
58
|
+
@started = 0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :capabilities
|
|
62
|
+
|
|
63
|
+
def start(context:, operation:, now:)
|
|
64
|
+
record(:start, actor_id: context.actor_id, run_id: context.run_id, operation_id: operation.id)
|
|
65
|
+
raise @start_error if @start_error
|
|
66
|
+
|
|
67
|
+
@started += 1
|
|
68
|
+
Session.new(id: @started > 1 ? "#{@session_id}-#{@started}" : @session_id, started_at: now)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def navigate(session, url:, operation:, now:)
|
|
72
|
+
record(:navigate, url: url, operation_id: operation.id)
|
|
73
|
+
raise @navigate_error if @navigate_error
|
|
74
|
+
|
|
75
|
+
session.with_url(@redirect_to || url)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def observe(_session, instruction:, operation:, now:)
|
|
79
|
+
record(:observe, instruction: instruction, operation_id: operation.id)
|
|
80
|
+
raise @observe_error if @observe_error
|
|
81
|
+
|
|
82
|
+
deep_dup(@observe)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract(_session, schema:, instruction:, operation:, now:)
|
|
86
|
+
record(:extract, schema: schema, instruction: instruction, operation_id: operation.id)
|
|
87
|
+
raise @extract_error if @extract_error
|
|
88
|
+
|
|
89
|
+
deep_dup(@extract)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def act(_session, action:, operation:, now:)
|
|
93
|
+
record(:act, action: action, operation_id: operation.id)
|
|
94
|
+
raise @act_error if @act_error
|
|
95
|
+
|
|
96
|
+
deep_dup(@act)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def end_session(_session, operation:, now:)
|
|
100
|
+
record(:end_session, operation_id: operation.id)
|
|
101
|
+
raise @end_error if @end_error
|
|
102
|
+
|
|
103
|
+
@end_confirmed
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reconcile(_operation, now:)
|
|
107
|
+
@reconcile_state
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def called?(method_name)
|
|
111
|
+
@calls.any? { |call| call[:method] == method_name.to_sym }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def call_count(method_name)
|
|
115
|
+
@calls.count { |call| call[:method] == method_name.to_sym }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def record(method_name, **payload)
|
|
121
|
+
@calls << { method: method_name, **payload }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deep_dup(value)
|
|
125
|
+
case value
|
|
126
|
+
when Hash then value.to_h { |key, nested| [ key, deep_dup(nested) ] }
|
|
127
|
+
when Array then value.map { |item| deep_dup(item) }
|
|
128
|
+
when String then value.dup
|
|
129
|
+
else value
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Copies host-owned input before freezing it. Never freezes the caller's objects.
|
|
6
|
+
module Immutable
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def copy(value)
|
|
10
|
+
case value
|
|
11
|
+
when Hash then value.to_h { |key, nested| [ copy(key), copy(nested) ] }.freeze
|
|
12
|
+
when Array then value.map { |nested| copy(nested) }.freeze
|
|
13
|
+
when String, Time then value.dup.freeze
|
|
14
|
+
else value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Thread-safe, in-process {ApprovalStore}.
|
|
6
|
+
#
|
|
7
|
+
# A fixture for a single process. Production use needs a durable store so an
|
|
8
|
+
# approval survives a worker restart and cannot be replayed by a second process.
|
|
9
|
+
class InMemoryApprovalStore < ApprovalStore
|
|
10
|
+
def initialize
|
|
11
|
+
super()
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@approvals = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def issue(approval)
|
|
17
|
+
synchronize do
|
|
18
|
+
raise InvalidInput, "approval #{approval.id} already exists" if @approvals.key?(approval.id)
|
|
19
|
+
|
|
20
|
+
@approvals[approval.id] = approval
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch(id)
|
|
25
|
+
synchronize { @approvals[id] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def consume_once(id, actor_id:, run_id:, url:, action_digest:, now:, page_revision: nil,
|
|
29
|
+
operation_id: nil, session_id: nil)
|
|
30
|
+
synchronize do
|
|
31
|
+
approval = @approvals[id]
|
|
32
|
+
raise ApprovalRequired, "no approval #{id.inspect} was issued" if approval.nil?
|
|
33
|
+
raise ApprovalStale, "approval #{id} was already consumed" if approval.consumed?
|
|
34
|
+
raise ApprovalStale, "approval #{id} was revoked" if approval.revoked?
|
|
35
|
+
raise ApprovalStale, "approval #{id} has expired" if approval.expired?(now)
|
|
36
|
+
|
|
37
|
+
unless approval.binding_matches?(
|
|
38
|
+
actor_id: actor_id, run_id: run_id, url: url, action_digest: action_digest,
|
|
39
|
+
page_revision: page_revision, operation_id: operation_id, session_id: session_id
|
|
40
|
+
)
|
|
41
|
+
raise ApprovalStale, "approval #{id} does not authorize this actor, URL, page or action"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
consumed = approval.consume!(now: now)
|
|
45
|
+
@approvals[id] = consumed
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def revoke(id)
|
|
50
|
+
synchronize do
|
|
51
|
+
approval = @approvals.fetch(id) { raise ApprovalRequired, "no approval #{id.inspect} was issued" }
|
|
52
|
+
@approvals[id] = approval.revoke!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def all
|
|
57
|
+
synchronize { @approvals.values.dup }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def synchronize(&block)
|
|
63
|
+
@mutex.synchronize(&block)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Browser
|
|
5
|
+
# Thread-safe, in-process {OperationStore}.
|
|
6
|
+
#
|
|
7
|
+
# This is a fixture for tests and single-process demos. It is **not** production
|
|
8
|
+
# ready: state disappears on restart and cannot synchronize across workers or
|
|
9
|
+
# hosts. Use a durable store for anything long-running.
|
|
10
|
+
class InMemoryOperationStore < OperationStore
|
|
11
|
+
def initialize(clock: nil)
|
|
12
|
+
super()
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@operations = {}
|
|
15
|
+
@clock = clock
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reserve(operation)
|
|
19
|
+
synchronize do
|
|
20
|
+
if @operations.key?(operation.id)
|
|
21
|
+
raise InvalidInput, "operation #{operation.id} is already reserved"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
enforce_concurrency!(operation)
|
|
25
|
+
@operations[operation.id] = operation
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def attach_or_reserve(operation)
|
|
30
|
+
synchronize do
|
|
31
|
+
existing = find_by_idempotency_key(operation.actor_id, operation.run_id, operation.idempotency_key)
|
|
32
|
+
if existing
|
|
33
|
+
raise InvalidInput, "idempotency key belongs to a different request" unless existing.request_digest == operation.request_digest
|
|
34
|
+
|
|
35
|
+
return existing
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
reserve_locked(operation)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch(id)
|
|
43
|
+
synchronize { @operations[id] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch!(id)
|
|
47
|
+
fetch(id) || raise(InvalidInput, "unknown operation #{id.inspect}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lease(id, actor_id:, run_id:, now:)
|
|
51
|
+
synchronize do
|
|
52
|
+
operation = @operations.fetch(id) { raise InvalidInput, "unknown operation #{id.inspect}" }
|
|
53
|
+
assert_actor!(operation, actor_id, run_id)
|
|
54
|
+
if operation.terminal?
|
|
55
|
+
raise InvalidInput, "operation #{id} is already #{operation.state}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
leased = operation.with_fence(operation.fence + 1, now: now)
|
|
59
|
+
@operations[id] = leased
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def update(operation)
|
|
64
|
+
synchronize do
|
|
65
|
+
stored = @operations.fetch(operation.id) { raise InvalidInput, "unknown operation #{operation.id.inspect}" }
|
|
66
|
+
operation.validate_update!(stored)
|
|
67
|
+
@operations[operation.id] = operation
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def claim_start(operation, now:)
|
|
72
|
+
synchronize do
|
|
73
|
+
stored = @operations.fetch(operation.id)
|
|
74
|
+
operation.validate_update!(stored)
|
|
75
|
+
raise InvalidInput, "operation was already started" unless stored.state == :reserved
|
|
76
|
+
raise BudgetExhausted, "operation wall-clock budget exhausted" if stored.expired?(now)
|
|
77
|
+
|
|
78
|
+
@operations[operation.id] = stored.advance(:starting, now: now)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_session(operation, session_id:, now:)
|
|
83
|
+
synchronize do
|
|
84
|
+
stored = @operations.fetch(operation.id)
|
|
85
|
+
assert_actor!(stored, operation.actor_id, operation.run_id)
|
|
86
|
+
raise InvalidInput, "operation session cannot change" if stored.session_id && stored.session_id != session_id
|
|
87
|
+
|
|
88
|
+
target = stored.fence == operation.fence ? :active : :cleanup_pending
|
|
89
|
+
@operations[operation.id] = stored.advance(target, session_id: session_id, now: now)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def confirm_closed(operation, session_id:, now:)
|
|
94
|
+
synchronize do
|
|
95
|
+
stored = @operations.fetch(operation.id)
|
|
96
|
+
assert_actor!(stored, operation.actor_id, operation.run_id)
|
|
97
|
+
raise InvalidInput, "operation session cannot change" unless stored.session_id == session_id
|
|
98
|
+
|
|
99
|
+
target = stored.cancel_requested? ? :cancelled : :closed
|
|
100
|
+
@operations[operation.id] = stored.advance(target, now: now)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cancel(id, actor_id:, run_id:, now:)
|
|
105
|
+
synchronize do
|
|
106
|
+
operation = @operations.fetch(id) { raise InvalidInput, "unknown operation" }
|
|
107
|
+
assert_actor!(operation, actor_id, run_id)
|
|
108
|
+
return operation if operation.terminal? || operation.cancel_requested?
|
|
109
|
+
|
|
110
|
+
@operations[id] = operation.with_fence(operation.fence + 1, now: now).request_cancel(now: now)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def mark_cleanup_pending(id, actor_id:, run_id:, error_code: nil, now:)
|
|
115
|
+
synchronize do
|
|
116
|
+
operation = @operations.fetch(id) { raise InvalidInput, "unknown operation #{id.inspect}" }
|
|
117
|
+
assert_actor!(operation, actor_id, run_id)
|
|
118
|
+
updated = operation.advance(:cleanup_pending, now: now, error_code: error_code)
|
|
119
|
+
@operations[id] = updated
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def open_for(actor_id)
|
|
124
|
+
synchronize { @operations.values.select { |operation| operation.actor_id == actor_id && operation.open? } }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def open_operations
|
|
128
|
+
synchronize { @operations.values.select(&:open?) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Snapshot for tests and diagnostics.
|
|
132
|
+
def all
|
|
133
|
+
synchronize { @operations.values.dup }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def synchronize(&block)
|
|
139
|
+
@mutex.synchronize(&block)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def reserve_locked(operation)
|
|
143
|
+
if @operations.key?(operation.id)
|
|
144
|
+
raise InvalidInput, "operation #{operation.id} is already reserved"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
enforce_concurrency!(operation)
|
|
148
|
+
@operations[operation.id] = operation
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def find_by_idempotency_key(actor_id, run_id, key)
|
|
152
|
+
return nil if key.nil?
|
|
153
|
+
|
|
154
|
+
@operations.values.find do |operation|
|
|
155
|
+
operation.actor_id == actor_id && operation.run_id == run_id && operation.idempotency_key == key
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def enforce_concurrency!(operation)
|
|
160
|
+
open = @operations.values.count { |existing| existing.actor_id == operation.actor_id && existing.open? }
|
|
161
|
+
return if open < operation.budget.max_concurrent
|
|
162
|
+
|
|
163
|
+
raise BudgetExhausted,
|
|
164
|
+
"actor #{operation.actor_id.inspect} already holds #{open} open browser operation(s)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def assert_actor!(operation, actor_id, run_id)
|
|
168
|
+
return if operation.actor_id == actor_id && operation.run_id == run_id
|
|
169
|
+
|
|
170
|
+
raise NotAuthorized, "operation #{operation.id} belongs to a different actor or run"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Browser
|
|
7
|
+
# Durable identity and lifecycle of one browser operation.
|
|
8
|
+
#
|
|
9
|
+
# An operation is reserved *before* the first remote call and is the anchor for
|
|
10
|
+
# duplicate-job attachment, concurrency, budgets, fencing and cleanup. It never
|
|
11
|
+
# reports an unresolved outcome as success: an ambiguous start or action is
|
|
12
|
+
# +unknown+ and unsuccessful cleanup is +cleanup_pending+, and both keep their
|
|
13
|
+
# budget reservation until closure or expiry is confirmed.
|
|
14
|
+
#
|
|
15
|
+
# Instances are immutable; each transition returns a new operation.
|
|
16
|
+
class Operation
|
|
17
|
+
STATES = %i[reserved starting active closing closed unknown cleanup_pending cancelled].freeze
|
|
18
|
+
|
|
19
|
+
# States that keep the reservation open.
|
|
20
|
+
OPEN_STATES = %i[reserved starting active closing unknown cleanup_pending].freeze
|
|
21
|
+
|
|
22
|
+
# The only confirmed-finished states.
|
|
23
|
+
TERMINAL_STATES = %i[closed cancelled].freeze
|
|
24
|
+
|
|
25
|
+
# Allowed transitions. A state may always advance to itself (refresh/fence bump).
|
|
26
|
+
TRANSITIONS = {
|
|
27
|
+
reserved: %i[starting unknown cleanup_pending closed cancelled],
|
|
28
|
+
starting: %i[active unknown cleanup_pending closed cancelled],
|
|
29
|
+
active: %i[closing unknown cleanup_pending closed cancelled],
|
|
30
|
+
closing: %i[closed unknown cleanup_pending cancelled],
|
|
31
|
+
unknown: %i[active closing cleanup_pending closed cancelled],
|
|
32
|
+
cleanup_pending: %i[closed unknown cancelled],
|
|
33
|
+
closed: [],
|
|
34
|
+
cancelled: []
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :id, :actor_id, :run_id, :state, :budget, :session_id,
|
|
38
|
+
:idempotency_key, :steps, :bytes, :fence, :error_code,
|
|
39
|
+
:created_at, :updated_at, :request_digest
|
|
40
|
+
|
|
41
|
+
def self.generate_id(prefix: "op")
|
|
42
|
+
"#{prefix}_#{SecureRandom.uuid}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(id:, actor_id:, run_id:, budget:, state: :reserved, session_id: nil,
|
|
46
|
+
idempotency_key: nil, steps: 0, bytes: 0, fence: 0, error_code: nil,
|
|
47
|
+
created_at: nil, updated_at: nil, cancel_requested: false, request_digest: nil)
|
|
48
|
+
@id = string!(id, :id)
|
|
49
|
+
@actor_id = string!(actor_id, :actor_id)
|
|
50
|
+
@run_id = string!(run_id, :run_id)
|
|
51
|
+
@budget = budget!(budget)
|
|
52
|
+
@state = state!(state)
|
|
53
|
+
@session_id = Immutable.copy(session_id)
|
|
54
|
+
@idempotency_key = Immutable.copy(idempotency_key)
|
|
55
|
+
@steps = count!(steps, :steps)
|
|
56
|
+
@bytes = count!(bytes, :bytes)
|
|
57
|
+
@fence = count!(fence, :fence)
|
|
58
|
+
@error_code = error_code
|
|
59
|
+
@created_at = Immutable.copy(created_at)
|
|
60
|
+
@updated_at = Immutable.copy(updated_at || created_at)
|
|
61
|
+
@cancel_requested = !!cancel_requested
|
|
62
|
+
@request_digest = Immutable.copy(request_digest)
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def open? = OPEN_STATES.include?(state)
|
|
67
|
+
def terminal? = TERMINAL_STATES.include?(state)
|
|
68
|
+
def unknown? = state == :unknown
|
|
69
|
+
def cleanup_pending? = state == :cleanup_pending
|
|
70
|
+
def cancel_requested? = @cancel_requested
|
|
71
|
+
|
|
72
|
+
# Cancellation of a dispatched operation holds quota until cleanup is confirmed.
|
|
73
|
+
def request_cancel(now:)
|
|
74
|
+
target = state == :reserved ? :cancelled : :cleanup_pending
|
|
75
|
+
self.class.new(**advance(target, now: now).to_h.merge(cancel_requested: true))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def elapsed_seconds(now)
|
|
79
|
+
return 0.0 if created_at.nil?
|
|
80
|
+
|
|
81
|
+
(now - created_at).to_f
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True when the operation has outlived its wall-clock budget.
|
|
85
|
+
def expired?(now)
|
|
86
|
+
elapsed_seconds(now) >= budget.max_seconds
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def remaining_steps = [ budget.max_steps - steps, 0 ].max
|
|
90
|
+
def remaining_bytes = [ budget.max_bytes - bytes, 0 ].max
|
|
91
|
+
def bytes_exhausted? = bytes >= budget.max_bytes
|
|
92
|
+
def steps_exhausted? = steps >= budget.max_steps
|
|
93
|
+
|
|
94
|
+
# Advances to +target+, refusing illegal transitions.
|
|
95
|
+
#
|
|
96
|
+
# @param now [Time] timestamp for +updated_at+
|
|
97
|
+
# @param session_id [String, nil] set or clear the provider session id
|
|
98
|
+
# @param error_code [String, nil] record a typed failure code
|
|
99
|
+
def advance(target, now:, session_id: :keep, error_code: :keep)
|
|
100
|
+
target = state!(target)
|
|
101
|
+
validate_transition!(target)
|
|
102
|
+
self.class.new(
|
|
103
|
+
id: id, actor_id: actor_id, run_id: run_id, budget: budget,
|
|
104
|
+
state: target,
|
|
105
|
+
session_id: session_id == :keep ? @session_id : session_id,
|
|
106
|
+
idempotency_key: idempotency_key,
|
|
107
|
+
steps: steps, bytes: bytes, fence: fence,
|
|
108
|
+
error_code: error_code == :keep ? @error_code : error_code,
|
|
109
|
+
created_at: created_at, updated_at: now, cancel_requested: cancel_requested?, request_digest: request_digest
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Records one provider round-trip, refusing to exceed the step budget.
|
|
114
|
+
def consume_step!(now:)
|
|
115
|
+
raise BudgetExhausted, "operation step budget exhausted" if steps_exhausted?
|
|
116
|
+
|
|
117
|
+
self.class.new(**to_h.merge(steps: steps + 1, updated_at: now))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Records bytes returned by the provider. Bounding/truncation is decided by the
|
|
121
|
+
# caller from {#remaining_bytes}; this never silently drops data.
|
|
122
|
+
def add_bytes!(amount, now:)
|
|
123
|
+
raise InvalidInput, "bytes must be a non-negative integer" unless amount.is_a?(Integer) && amount >= 0
|
|
124
|
+
raise BudgetExhausted, "operation byte budget exhausted" if amount > remaining_bytes
|
|
125
|
+
|
|
126
|
+
self.class.new(**to_h.merge(bytes: bytes + amount, updated_at: now))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns a copy carrying a new fence token.
|
|
130
|
+
def with_fence(value, now: nil)
|
|
131
|
+
raise InvalidInput, "fence must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
|
|
132
|
+
|
|
133
|
+
self.class.new(**to_h.merge(fence: value, updated_at: now || updated_at))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# True when every field matches except possibly timestamps/fence.
|
|
137
|
+
def same_identity?(other)
|
|
138
|
+
other.is_a?(Operation) && id == other.id && actor_id == other.actor_id && run_id == other.run_id
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def to_h
|
|
142
|
+
{
|
|
143
|
+
id: id, actor_id: actor_id, run_id: run_id, state: state, budget: budget,
|
|
144
|
+
session_id: session_id, idempotency_key: idempotency_key, steps: steps,
|
|
145
|
+
bytes: bytes, fence: fence, error_code: error_code,
|
|
146
|
+
created_at: created_at, updated_at: updated_at, cancel_requested: cancel_requested?, request_digest: request_digest
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# An audit-safe summary. Omits the provider session id.
|
|
151
|
+
def summary
|
|
152
|
+
to_h.except(:session_id, :budget, :idempotency_key)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def ==(other)
|
|
156
|
+
other.is_a?(Operation) && to_h == other.to_h
|
|
157
|
+
end
|
|
158
|
+
alias eql? ==
|
|
159
|
+
|
|
160
|
+
def hash = to_h.hash
|
|
161
|
+
|
|
162
|
+
def inspect
|
|
163
|
+
"#<#{self.class} id=#{id} state=#{state} actor=#{actor_id} steps=#{steps}/#{budget.max_steps}>"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Checks a normal worker write against the authoritative stored version.
|
|
167
|
+
def validate_update!(stored)
|
|
168
|
+
raise NotAuthorized, "operation identity cannot change" unless same_identity?(stored)
|
|
169
|
+
raise InvalidInput, "stale operation writer (fenced)" unless fence == stored.fence
|
|
170
|
+
raise InvalidInput, "operation budget cannot change" unless budget == stored.budget
|
|
171
|
+
unless idempotency_key == stored.idempotency_key && request_digest == stored.request_digest && created_at == stored.created_at
|
|
172
|
+
raise InvalidInput, "operation reservation cannot change"
|
|
173
|
+
end
|
|
174
|
+
raise InvalidInput, "operation cancellation cannot change" unless cancel_requested? == stored.cancel_requested?
|
|
175
|
+
raise InvalidInput, "operation accounting cannot go backwards" if steps < stored.steps || bytes < stored.bytes
|
|
176
|
+
if stored.session_id && session_id != stored.session_id
|
|
177
|
+
raise InvalidInput, "operation session cannot change"
|
|
178
|
+
end
|
|
179
|
+
stored.advance(state, now: updated_at)
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def validate_transition!(target)
|
|
186
|
+
return if target == state
|
|
187
|
+
return if TRANSITIONS.fetch(state).include?(target)
|
|
188
|
+
|
|
189
|
+
raise InvalidInput, "illegal operation transition #{state} -> #{target}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def string!(value, name)
|
|
193
|
+
raise InvalidInput, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
|
|
194
|
+
|
|
195
|
+
value.dup.freeze
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def budget!(value)
|
|
199
|
+
raise InvalidInput, "budget must be a RubyLLM::Browser::Budget" unless value.is_a?(Budget)
|
|
200
|
+
|
|
201
|
+
value
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def state!(value)
|
|
205
|
+
symbol = value.to_sym
|
|
206
|
+
return symbol if STATES.include?(symbol)
|
|
207
|
+
|
|
208
|
+
raise InvalidInput, "unknown operation state #{symbol.inspect}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def count!(value, name)
|
|
212
|
+
raise InvalidInput, "#{name} must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
|
|
213
|
+
|
|
214
|
+
value
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|