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,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