ruby_llm-browser 0.1.0 → 0.3.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/CONTRIBUTING.md +8 -1
  4. data/README.md +25 -96
  5. data/docs/API.md +5 -172
  6. data/docs/RAILS.md +5 -162
  7. data/docs/README.md +23 -12
  8. data/docs/RELEASE_ACTION_PLAN.md +28 -13
  9. data/docs/RELEASE_AUDIT.md +19 -0
  10. data/docs/RELEASING.md +24 -12
  11. data/docs/SECURITY_MODEL.md +1 -1
  12. data/docs/WORK.md +22 -0
  13. data/docs/_advanced/development-and-testing.md +107 -0
  14. data/docs/_advanced/live-backend-qualification.md +5 -0
  15. data/docs/_advanced/rails-integration.md +147 -29
  16. data/docs/_advanced/result-delivery.md +119 -0
  17. data/docs/_config.yml +8 -4
  18. data/docs/_core_features/diagnostics.md +68 -0
  19. data/docs/_core_features/inspect-extract.md +18 -2
  20. data/docs/_data/navigation.yml +2 -0
  21. data/docs/_getting_started/getting-started.md +11 -1
  22. data/docs/_getting_started/overview.md +3 -0
  23. data/docs/_getting_started/whats-new-in-0-3.md +48 -0
  24. data/docs/_plugins/versioned_docs.rb +3 -3
  25. data/docs/_reference/api-and-configuration.md +203 -0
  26. data/docs/_reference/api-reference.md +3 -2
  27. data/docs/_reference/release-history.md +3 -1
  28. data/docs/_reference/roadmap.md +171 -0
  29. data/docs/_reference/upgrading.md +48 -14
  30. data/docs/_reference/whats-new-in-0-2.md +46 -0
  31. data/docs/index.md +2 -3
  32. data/examples/plain_ruby.rb +7 -4
  33. data/examples/rails_results.rb +141 -0
  34. data/lib/ruby_llm/browser/doctor.rb +25 -10
  35. data/lib/ruby_llm/browser/rails/operation_job.rb +16 -4
  36. data/lib/ruby_llm/browser/rails/result_delivery_error.rb +22 -0
  37. data/lib/ruby_llm/browser/rails.rb +16 -0
  38. data/lib/ruby_llm/browser/result.rb +3 -3
  39. data/lib/ruby_llm/browser/version.rb +1 -1
  40. data/lib/ruby_llm/browser.rb +11 -0
  41. metadata +11 -2
@@ -0,0 +1,171 @@
1
+ ---
2
+ layout: default
3
+ title: Roadmap to 1.0
4
+ nav_order: 8
5
+ description: Plan small releases toward a reliable read-only browser integration for RubyLLM.
6
+ ---
7
+
8
+ # {{ page.title }}
9
+
10
+ {{ page.description }}
11
+
12
+ After reading this guide, you will know:
13
+
14
+ - What works today and what still blocks practical live use.
15
+ - Which small features belong in the next release.
16
+ - What must pass before the project releases 1.0.0.
17
+
18
+ ## Start with a Narrow 1.0
19
+
20
+ The proposed 1.0 supports one complete workflow: your RubyLLM application inspects an
21
+ approved public page or extracts schema-checked data, receives a bounded result with
22
+ provenance, and can account for the browser session through cleanup. It works in plain
23
+ Ruby and through the optional Rails adapter.
24
+
25
+ Version 0.1.0 introduced the offline foundation: Inspect, Extract, URL policy, bounded
26
+ results, operation bookkeeping, Rails jobs, and Act simulation. It does not provide a
27
+ supported live backend. The current Stagehand adapter refuses live operations. The
28
+ 0.3.0 release adds opt-in Rails result delivery to host-owned storage;
29
+ it does not add a gem-managed results database or guaranteed delivery.
30
+
31
+ **This is a proposed development sequence, not a list of available features or release
32
+ dates.** Future API names below are proposals. Keep each release small; use patch
33
+ releases for fixes and split a milestone if implementation reveals a larger change.
34
+
35
+ ## Release Sequence
36
+
37
+ Build convenience on the current offline API first. Complete the Rails result path
38
+ next. Qualify one live deployment before advertising browser support, then use pilot
39
+ feedback to settle the public API.
40
+
41
+ | Version | Outcome | Release condition |
42
+ | --- | --- | --- |
43
+ | 0.2.0 | Easier tool setup and clearer offline diagnostics | Two additive features, tested with no provider requests |
44
+ | 0.3.0 | Usable Rails result delivery | A host can retrieve a job's result after the worker exits |
45
+ | 0.4.0 | One supported live read-only deployment | Egress, final URL, session expiry and cleanup qualified on that deployment |
46
+ | 0.5.0 | Pilot feedback and recovery hardening | Plain-Ruby and Rails workflows exercised outside the test suite |
47
+ | 1.0.0-rc.1 | Stable API candidate | Feature freeze, complete upgrade instructions, candidate package verified |
48
+ | 1.0.0 | Supported read-only integration | All acceptance criteria below pass on the release candidate |
49
+
50
+ ### 0.2.0: Two Small Features
51
+
52
+ Version `0.2.0` adds both features below. Milestones after 0.3 remain proposed scope. See
53
+ [What's New]({% link _reference/whats-new-in-0-2.md %}) for usage and
54
+ [development and testing]({% link _advanced/development-and-testing.md %}) for validation.
55
+
56
+ 1. Add the read-only tool factory `RubyLLM::Browser.tools(context:, schema:)`.
57
+ Return Inspect and Extract instances suitable for `chat.with_tools(*tools)`. Keep
58
+ the context and extraction schema explicit; never include Act. Reuse the existing
59
+ constructors and validation instead of creating a second configuration API.
60
+ 2. Make Doctor distinguish offline and live-backend checks. FakeBackend diagnostics
61
+ should not warn that provider credentials are missing. Live-backend reports must
62
+ keep the unsupported Stagehand reason visible. Preserve the structured report and
63
+ make clear that a passing local check does not qualify a provider.
64
+
65
+ Acceptance: exercise the factory through RubyLLM's public tool-registration API on
66
+ both supported RubyLLM versions; reject an invalid schema; verify no Act registration,
67
+ provider request, credential discovery, or Rails load. Test Doctor with fake and
68
+ unsupported live backends. Update the relevant guides and changelog with the features.
69
+
70
+ Do not add a CLI, a context builder, new database tables, or live support in this slice.
71
+ The factory and Doctor are implemented in separate commits. Local and remote
72
+ validation precede the release tag and publication.
73
+
74
+ ### 0.3.0: Complete the Rails Result Path
75
+
76
+ Version `0.3.0` implements `Rails.result_delivery`, typed
77
+ host-delivery failures, and a runnable SQLite host example with separate workers.
78
+ Read [What's New in 0.3]({% link _getting_started/whats-new-in-0-3.md %}) and
79
+ [Rails Result Delivery]({% link _advanced/result-delivery.md %}) for the current API.
80
+ The source repository's `docs/evidence/V0_3_VALIDATION.md` records local candidate
81
+ checks. `docs/evidence/V0_3_RELEASE.md` records remote verification and publication.
82
+
83
+ The result-delivery hook lets the host persist the sanitized `Result`
84
+ against its operation ID. Its runnable host example stores and retrieves
85
+ the result after an asynchronous job finishes. The host owns access checks, retention,
86
+ and storage; the gem does not need an admin interface or a general results database.
87
+
88
+ Acceptance: retrieve a schema-checked extraction after worker exit; refuse cross-actor
89
+ reads in the host example; handle success, truncation, failure, and pending cleanup.
90
+ A delivery failure must be visible and must never cause the browser operation to run
91
+ again. Document delivery semantics, duplicate handling, and recovery separately from
92
+ the operation's session state. Verify generator upgrades preserve existing records.
93
+
94
+ ### 0.4.0: Qualify One Live Read-only Deployment
95
+
96
+ Begin with a feasibility check of the pinned SDK and a named deployment. Determine
97
+ whether the deployment can enforce every required browser request boundary and report
98
+ verified final URLs and terminal session state. Implement only the missing adapter
99
+ integration needed for that deployment. Keep live Act disabled.
100
+
101
+ Acceptance requires deployment-specific evidence for redirects, subresources, frames,
102
+ popups, WebSockets, connection-time DNS/IP restrictions, final URLs, session expiry,
103
+ and confirmed cleanup. Exercise timeouts, worker death, and lost start responses with
104
+ controlled fixtures. Unknown sessions retain quota and have an explicit host review
105
+ path; an SDK acknowledgement alone cannot release them.
106
+
107
+ Prepare the fixtures and opt-in qualification runner offline first. Live runs require
108
+ separate owner authorization for the deployment, data, and spending. Keep default CI
109
+ offline. Report provider billing controls separately from the advisory cost field.
110
+
111
+ If the deployment cannot meet the required controls, stop this milestone and record
112
+ the gap. Keep live support disabled. Do not replace qualification with a boolean flag
113
+ or promote an offline-only package to 1.0 under this plan.
114
+
115
+ ### 0.5.0: Exercise Real Application Workflows
116
+
117
+ Run two authorized pilots: a plain-Ruby extraction and a Rails background extraction
118
+ with durable host result retrieval. Record the exact dependency/deployment versions,
119
+ fixture expectations, observed results, failures, and cleanup outcomes. A controlled
120
+ host application is sufficient; customer adoption is not a release prerequisite.
121
+
122
+ Use pilot findings to improve errors, delivery, cancellation, and recovery. Confirm
123
+ that duplicate jobs do not repeat provider starts, unknown outcomes remain visible,
124
+ and result limits and redaction behave as documented. Resolve public API changes now
125
+ and provide migrations before freezing the API.
126
+
127
+ ### 1.0.0-rc.1: Freeze and Verify
128
+
129
+ Freeze tool constructors, factory arguments, result fields, error codes, backend/store
130
+ contracts, and Rails schema/delivery hooks. Publish migration instructions for every
131
+ breaking pre-1.0 change. Run the declared Ruby/RubyLLM/Rails matrix, build the gem,
132
+ install the actual artifact in a clean environment, and verify its shipped examples,
133
+ generator, guides, and dependency audit.
134
+
135
+ Repeat the live qualification against the candidate's exact artifact and deployment.
136
+ Fix defects through further release candidates. Feature requests return to a later
137
+ minor release. A candidate with an unresolved blocker does not advance to 1.0.0.
138
+
139
+ ## 1.0.0 Acceptance Criteria
140
+
141
+ - **Useful:** both pilot workflows complete, and one named live read-only deployment
142
+ is supported with reproducible evidence and documented limits.
143
+ - **Controlled:** URL policy, schema validation, result bounds, and redaction pass
144
+ positive and denial cases. Deployment qualification covers all browser request paths.
145
+ - **Recoverable:** cancellation, duplicate jobs, worker death, unknown starts, and
146
+ pending cleanup preserve correct state and quota. Host recovery steps are tested.
147
+ - **Retrievable:** a Rails host can persist and authorize result retrieval after a job
148
+ exits. Delivery failure cannot trigger a repeated browser action.
149
+ - **Stable:** the public API and database upgrade path are documented and covered by
150
+ compatibility checks. Unsupported databases and backends are identified explicitly.
151
+ - **Distributable:** the release artifact passes the compatibility matrix, clean
152
+ installation, examples, documentation build, and dependency checks. Version, tag,
153
+ changelog, and stable documentation agree. The owner authorizes publication.
154
+
155
+ Live Act, multiple providers, PostgreSQL qualification, authenticated browsing,
156
+ screenshots, downloads, arbitrary JavaScript, a hosted UI, and a cost ledger are not
157
+ required for this 1.0. Keep unsupported capabilities explicit. Consider live Act only
158
+ as a separate feature with same-session approval and target identity guarantees.
159
+
160
+ ## Keep Planning and Documentation Separate
161
+
162
+ The README introduces the project. Site guides teach supported behavior and provide
163
+ the API reference. This page owns future scope; the changelog records shipped changes.
164
+ Historical release audits remain evidence for their exact versions, not a current
165
+ task queue. Plan changes do not rewrite the approved specification baseline.
166
+
167
+ Read [API and configuration]({% link _reference/api-and-configuration.md %}),
168
+ [Rails jobs and recovery]({% link _advanced/rails-integration.md %}), and
169
+ [live-backend qualification]({% link _advanced/live-backend-qualification.md %}) for
170
+ the current contracts. Follow [the release checklist]({% link RELEASING.md %}) when a
171
+ milestone is ready to ship.
@@ -2,32 +2,66 @@
2
2
  layout: default
3
3
  title: Upgrading
4
4
  nav_order: 2
5
- description: Check the supported upgrade path before moving a RubyLLM::Browser application to a new version.
5
+ description: Upgrade from 0.2.0 while preserving operation data and configuring optional Rails result delivery.
6
6
  ---
7
7
 
8
8
  # {{ page.title }}
9
9
 
10
10
  {{ page.description }}
11
- {: .fs-6 .fw-300 }
12
11
 
13
12
  After reading this guide, you will know:
14
13
 
15
- * What changed in the first public release.
16
- * Where to find migration steps for earlier development snapshots.
17
- * What evidence must exist before a release tag is treated as supported.
14
+ - How to update an application from 0.2.0.
15
+ - How to preserve existing data and enable result delivery.
16
+ - Which result and worker behaviors change.
18
17
 
19
- ## Public Releases
18
+ ## Upgrade from 0.2.0
20
19
 
21
- Version `{{ site.version }}` is the first public release. It has no earlier public version
22
- to migrate from. Install it with `bundle add ruby_llm-browser --version "~> {{ site.version }}"`.
20
+ Update your Gemfile and bundle:
23
21
 
24
- ## Earlier Development Snapshots
22
+ ```ruby
23
+ gem "ruby_llm-browser", "~> {{ site.version }}"
24
+ ```
25
25
 
26
- If you ran an earlier development Rails generator, do not drop existing operation data
27
- or rerun a create-table migration. Follow the additive migration instructions under
28
- "Upgrading an earlier development snapshot" in the [Rails guide]({% link RAILS.md %}). Drain workers during the migration and do not reuse idempotency keys for legacy rows that lack request digests.
26
+ ```sh
27
+ bundle update ruby_llm-browser
28
+ ```
29
+
30
+ This release adds no gem-managed database migration or runtime dependency. Preserve all
31
+ operation, approval, and actor-lock rows. Do not rerun the install generator on an
32
+ existing installation. Result storage belongs to your application.
33
+
34
+ ## Enable Result Delivery When Needed
35
+
36
+ `RubyLLM::Browser::Rails.result_delivery` defaults to `nil`. Existing job return values
37
+ remain supported. To retain results after a worker exits, follow
38
+ [Rails Result Delivery]({% link _advanced/result-delivery.md %}). Configure persistence,
39
+ authorized reads, idempotent writes, and recovery before enabling the callback.
40
+
41
+ A callback failure raises `Rails::ResultDeliveryError`. Queue retries do not redeliver
42
+ a result. The error retains the result only in process; it is not durable storage.
43
+ Recover delivery separately from browser execution. A crash before host persistence
44
+ can still lose the result, so do not treat this hook as guaranteed delivery.
45
+
46
+ ## Account for Immutable Results and Fenced Workers
47
+
48
+ `Result` now also copies and freezes operation ID, error code and error message strings,
49
+ consistent with its other fields. Mutating these attributes raises `FrozenError`;
50
+ duplicate a string before changing it in host code. Caller-owned inputs stay mutable.
51
+
52
+ A worker fenced during browser execution now returns `:fenced_out` and cannot deliver
53
+ its stale result. Cancelled work never invokes the delivery hook, including while
54
+ cleanup remains pending. Delivery failures do not change the session state or free
55
+ quota. Stagehand and live Act remain unsupported.
56
+
57
+ ## Earlier Versions
58
+
59
+ For upgrades from 0.1.0 and earlier development snapshots, first follow the
60
+ [0.2.0 upgrading guide](https://github.com/yorzi/ruby_llm-browser/blob/v0.2.0/docs/_reference/upgrading.md).
61
+ Preserve operation data and use additive migrations where required.
29
62
 
30
63
  ## Next Steps
31
64
 
32
- * Review the [release history]({% link _reference/release-history.md %}) for version records.
33
- * Review the [release audit]({% link RELEASE_AUDIT.md %}) before enabling a live backend.
65
+ - Read [What's New in 0.3]({% link _getting_started/whats-new-in-0-3.md %}).
66
+ - Review [release history]({% link _reference/release-history.md %}) and
67
+ [live-backend qualification]({% link _advanced/live-backend-qualification.md %}).
@@ -0,0 +1,46 @@
1
+ ---
2
+ layout: default
3
+ title: What's New in 0.2
4
+ parent: Release History
5
+ nav_order: 1
6
+ description: Use the read-only tool factory and backend-aware diagnostics in this release.
7
+ ---
8
+
9
+ # {{ page.title }}
10
+
11
+ {{ page.description }}
12
+
13
+ After reading this guide, you will know:
14
+
15
+ - How to register the read-only tool pair.
16
+ - How to distinguish offline diagnostics from live-backend checks.
17
+ - Where to verify changes locally before upgrading.
18
+
19
+ ## Read-only Tool Factory
20
+
21
+ You can register the read-only tools in one call:
22
+
23
+ ```ruby
24
+ chat.with_tools(*RubyLLM::Browser.tools(context: context, schema: schema))
25
+ ```
26
+
27
+ The host still supplies context and schema. The factory returns Inspect and Extract
28
+ instances and never registers Act. Individual constructors remain supported. Read
29
+ [Inspecting and extracting]({% link _core_features/inspect-extract.md %}) for a complete
30
+ offline example and the validation behavior.
31
+
32
+ ## Backend-aware Diagnostics
33
+
34
+ Doctor adds a `mode` field while preserving `ok`, `summary`, and `checks`. FakeBackend
35
+ reports `offline` mode and skips provider environment and SDK checks. Stagehand
36
+ reports `live` mode and remains blocked. Custom backends no longer receive unrelated
37
+ Stagehand warnings. Read [Checking Configuration]({% link _core_features/diagnostics.md %}).
38
+
39
+ ## Development Verification
40
+
41
+ Use `bin/check` for offline contracts, lint, alternate bundles, and examples. Use
42
+ `bin/check-browser` for desktop/mobile workflows and fixture screenshots. The
43
+ [development guide]({% link _advanced/development-and-testing.md %}) explains setup,
44
+ package verification, and the limits of this evidence.
45
+
46
+ Stagehand and live Act remain unsupported. This release remains offline-only.
data/docs/index.md CHANGED
@@ -35,7 +35,7 @@ features:
35
35
 
36
36
  ## Before You Install
37
37
 
38
- The first release is `0.1.0` and supports only the offline `FakeBackend`. The built-in
38
+ Version `{{ site.version }}` supports only the offline `FakeBackend`. The built-in
39
39
  Stagehand adapter is unsupported until provider egress and confirmed cleanup are
40
40
  qualified. The full live Act protocol is deferred to a later version.
41
41
 
@@ -70,8 +70,7 @@ schema = {
70
70
  "required" => [ "title" ], "additionalProperties" => false
71
71
  }
72
72
 
73
- inspect_tool = RubyLLM::Browser::Inspect.new(context: context)
74
- extract_tool = RubyLLM::Browser::Extract.new(context: context, schema: schema)
73
+ inspect_tool, extract_tool = RubyLLM::Browser.tools(context: context, schema: schema)
75
74
  puts inspect_tool.execute(url: "https://docs.example.com/")[:status] # => ok
76
75
  puts extract_tool.execute(url: "https://docs.example.com/")[:data] # => {"title"=>"Example"}
77
76
  ```
@@ -23,12 +23,15 @@ context = RubyLLM::Browser::Context.new(
23
23
  budget: RubyLLM::Browser::Budget.new(max_seconds: 30, max_steps: 6)
24
24
  )
25
25
 
26
- inspect = RubyLLM::Browser::Inspect.new(context: context)
27
- puts "Inspect: #{inspect.execute(url: "https://docs.example.com/")}"
28
-
29
26
  schema = { "type" => "object", "properties" => { "title" => { "type" => "string" } } }
30
- extract = RubyLLM::Browser::Extract.new(context: context, schema: schema)
27
+ inspect, extract = RubyLLM::Browser.tools(context: context, schema: schema)
28
+ puts "Inspect: #{inspect.execute(url: "https://docs.example.com/")}"
31
29
  puts "Extract: #{extract.execute(url: "https://docs.example.com/", instruction: "the title")}"
32
30
 
33
31
  blocked = inspect.execute(url: "http://169.254.169.254/latest/meta-data")
34
32
  puts "Blocked: #{blocked[:error][:code]}"
33
+
34
+ report = RubyLLM::Browser::Doctor.new(backend: context.backend).report
35
+ raise "offline diagnostic failed" unless report[:ok] && report[:mode] == :offline
36
+ raise "provider checks ran offline" if report[:checks].any? { |check| check[:name] == :credentials }
37
+ puts "Doctor: #{report[:mode]}"
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A host-owned result table and a single-job file queue, using only FakeBackend.
4
+ # This disposable demonstration is not a production queue, controller or migration.
5
+ require "ruby_llm/browser/rails"
6
+ require "logger"
7
+ require "open3"
8
+ require "tmpdir"
9
+
10
+ module BrowserResultsExample
11
+ SCHEMA = { "type" => "object", "properties" => { "title" => { "type" => "string" } },
12
+ "required" => [ "title" ], "additionalProperties" => false }.freeze
13
+ RESOLVER = Object.new
14
+ def RESOLVER.getaddresses(_host) = [ "93.184.216.34" ]
15
+
16
+ class ResultRecord < ActiveRecord::Base
17
+ self.table_name = "example_browser_results"
18
+ self.primary_key = "operation_id"
19
+ end
20
+
21
+ class FileQueue
22
+ def initialize(path) = @path = path
23
+ def enqueue(job) = File.write(@path, JSON.generate(job.serialize))
24
+ def enqueue_at(*) = raise(ArgumentError, "demo queue supports immediate jobs only")
25
+ end
26
+
27
+ module_function
28
+
29
+ def connect(database)
30
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: database, timeout: 5000)
31
+ ActiveRecord::Base.logger = Logger.new(IO::NULL)
32
+ ActiveJob::Base.logger = Logger.new(IO::NULL)
33
+ RubyLLM::Browser::Rails::Schema.create!
34
+ connection = ActiveRecord::Base.connection
35
+ return if connection.table_exists?(ResultRecord.table_name)
36
+
37
+ connection.create_table(ResultRecord.table_name, id: false) do |table|
38
+ table.string :operation_id, primary_key: true, null: false
39
+ table.string :actor_id, null: false
40
+ table.string :run_id, null: false
41
+ table.text :body, null: false
42
+ table.datetime :created_at, null: false
43
+ end
44
+ end
45
+
46
+ def configure(backend: RubyLLM::Browser::FakeBackend.new)
47
+ RubyLLM::Browser::Rails.context_factory = lambda do |actor_id:, run_id:|
48
+ RubyLLM::Browser::Context.new(
49
+ actor_id: actor_id, run_id: run_id, backend: backend,
50
+ source_policy: RubyLLM::Browser::Policy.new(allowed_hosts: [ "docs.example.com" ], resolver: RESOLVER),
51
+ operation_store: RubyLLM::Browser::Rails.operation_store
52
+ )
53
+ end
54
+ RubyLLM::Browser::Rails.result_delivery = lambda do |operation:, result:|
55
+ store_result(operation: operation, payload: result.to_h)
56
+ end
57
+ end
58
+
59
+ # Trusted server API. The worker supplies identity, never a model or request body.
60
+ def store_result(operation:, payload:)
61
+ body = RubyLLM::Browser::Redactor.canonical_json(payload)
62
+ ResultRecord.transaction do
63
+ record = ResultRecord.create_or_find_by!(operation_id: operation.id) do |row|
64
+ row.actor_id = operation.actor_id
65
+ row.run_id = operation.run_id
66
+ row.body = body
67
+ end
68
+ unless [ record.actor_id, record.run_id, record.body ] == [ operation.actor_id, operation.run_id, body ]
69
+ raise RubyLLM::Browser::InvalidInput, "conflicting result delivery"
70
+ end
71
+ end
72
+ end
73
+
74
+ # In an HTTP host, derive actor/run from authenticated records and recheck access.
75
+ # Use the same response for a missing result and an inaccessible result.
76
+ def read_result(operation_id:, actor_id:, run_id:)
77
+ record = ResultRecord.find_by(operation_id: operation_id, actor_id: actor_id, run_id: run_id)
78
+ raise RubyLLM::Browser::NotAuthorized, "result unavailable" unless record
79
+
80
+ JSON.parse(record.body)
81
+ end
82
+
83
+ def worker(database, queue_file)
84
+ connect(database)
85
+ backend = RubyLLM::Browser::FakeBackend.new
86
+ configure(backend: backend)
87
+ ActiveJob::Base.execute(JSON.parse(File.read(queue_file)))
88
+ puts JSON.generate(starts: backend.call_count(:start), closes: backend.call_count(:end_session))
89
+ end
90
+
91
+ def run_demo
92
+ Dir.mktmpdir("browser-results-") do |directory|
93
+ database = File.join(directory, "host.sqlite3")
94
+ queue_file = File.join(directory, "job.json")
95
+ connect(database)
96
+ ActiveJob::Base.queue_adapter = FileQueue.new(queue_file)
97
+ operation = RubyLLM::Browser::Rails::OperationJob.enqueue(
98
+ actor_id: "demo-actor", run_id: "demo-run", kind: :extract,
99
+ url: "https://docs.example.com/", schema: SCHEMA,
100
+ budget: RubyLLM::Browser::Budget.new, idempotency_key: "demo-result"
101
+ )
102
+ # capture3 waits for exit. Execute the serialized job twice in separate workers.
103
+ [ 1, 0 ].each do |expected_starts|
104
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", File.expand_path("../lib", __dir__),
105
+ __FILE__, "worker", database, queue_file)
106
+ raise "worker failed: #{stderr}" unless status.success?
107
+ unless JSON.parse(stdout) == { "starts" => expected_starts, "closes" => expected_starts }
108
+ raise "unexpected browser dispatch"
109
+ end
110
+ end
111
+
112
+ payload = read_result(operation_id: operation.id, actor_id: "demo-actor", run_id: "demo-run")
113
+ raise "lost extraction" unless payload.fetch("data") == { "title" => "Example" } && payload.fetch("status") == "ok"
114
+ raise "lost provenance" unless payload.fetch("locator").fetch("schema_digest") == RubyLLM::Browser::Redactor.digest(SCHEMA)
115
+ puts "RESULT_AFTER_WORKER_EXIT_PASS"
116
+
117
+ [ [ "other-actor", "demo-run" ], [ "demo-actor", "other-run" ] ].each do |actor, run|
118
+ begin
119
+ read_result(operation_id: operation.id, actor_id: actor, run_id: run)
120
+ raise "cross-scope result leak"
121
+ rescue RubyLLM::Browser::NotAuthorized
122
+ # Expected refusal, including after the worker has exited.
123
+ end
124
+ end
125
+ puts "SCOPED_READS_PASS"
126
+ 2.times { store_result(operation: operation, payload: payload) }
127
+ raise "duplicate result rows" unless ResultRecord.count == 1
128
+ puts "IDEMPOTENT_WRITE_PASS"
129
+ ensure
130
+ ActiveRecord::Base.connection_pool.disconnect!
131
+ end
132
+ end
133
+ end
134
+
135
+ if $PROGRAM_NAME == __FILE__
136
+ if ARGV.first == "worker"
137
+ BrowserResultsExample.worker(*ARGV.drop(1))
138
+ else
139
+ BrowserResultsExample.run_demo
140
+ end
141
+ end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Browser
5
- # Read-only preflight diagnostics for a live browser deployment.
5
+ # Read-only configuration diagnostics for an offline or live backend.
6
6
  #
7
7
  # The Doctor never makes a network call, never starts a session and never prints a
8
- # credential. It reports the version pins, the environment overrides that the SDK
9
- # would otherwise honour, and whether the configured backend is qualified to start a
10
- # session. A `:fail` check means live use must not proceed.
8
+ # credential. Common checks report RubyLLM compatibility and backend capabilities.
9
+ # Only Stagehand backends inspect Stagehand's SDK and environment. FakeBackend
10
+ # checks do not load the SDK or inspect provider credentials. A local pass never
11
+ # qualifies a provider's actual guarantees. A `:fail` check blocks use.
11
12
  #
12
13
  # report = RubyLLM::Browser::Doctor.new(backend: backend).report
13
14
  # raise report[:summary] unless report[:ok]
@@ -23,13 +24,15 @@ module RubyLLM
23
24
  @env = env
24
25
  end
25
26
 
26
- # @return [Hash] `{ ok:, summary:, checks: [...] }`, safe to log
27
+ # @return [Hash] `{ mode:, ok:, summary:, checks: [...] }`, safe to log.
28
+ # Mode is `:offline` for FakeBackend in fake mode, `:unconfigured` without
29
+ # a backend, and `:live` otherwise. Existing report keys remain available.
27
30
  def report
28
- checks = [
29
- ruby_llm_check, stagehand_check, egress_check, local_binary_check,
30
- custom_headers_check, base_url_check, credentials_check
31
- ].compact
31
+ checks = [ ruby_llm_check, egress_check, local_binary_check, custom_headers_check ]
32
+ checks.concat([ stagehand_check, base_url_check, credentials_check ]) if backend.is_a?(StagehandBackend)
33
+ checks.compact!
32
34
  {
35
+ mode: mode,
33
36
  ok: checks.none? { |check| check[:status] == :fail },
34
37
  summary: summary_for(checks),
35
38
  checks: checks
@@ -40,6 +43,13 @@ module RubyLLM
40
43
 
41
44
  private
42
45
 
46
+ def mode
47
+ return :unconfigured unless backend
48
+ return :offline if backend.is_a?(FakeBackend) && backend.capabilities.server_mode == :fake
49
+
50
+ :live
51
+ end
52
+
43
53
  def ruby_llm_check
44
54
  version = Gem::Version.new(RubyLLM::VERSION)
45
55
  required = Gem::Version.new("2.0.0")
@@ -72,7 +82,12 @@ module RubyLLM
72
82
  "Stagehand backend is unsupported until provider egress and cleanup are qualified")
73
83
  end
74
84
  if capabilities.enforced_egress?
75
- check(:egress_isolation, :pass, "the backend asserts enforced, browser-side egress isolation")
85
+ detail = if mode == :offline
86
+ "FakeBackend performs no network I/O; live egress is not verified"
87
+ else
88
+ "the backend asserts enforced, browser-side egress isolation"
89
+ end
90
+ check(:egress_isolation, :pass, detail)
76
91
  else
77
92
  check(:egress_isolation, :fail,
78
93
  "the backend does not assert enforced egress isolation; live sessions are refused")
@@ -91,8 +91,14 @@ module RubyLLM
91
91
  max_bytes: max_bytes)
92
92
 
93
93
  # Keep unknown action/start results, even when cleanup later closes a session.
94
- final = settled(operation_id)
94
+ completed = store.fetch!(operation_id)
95
+ final = settled(completed)
95
96
  return :cancelled if final == :cancelled
97
+ # Cancellation can still await cleanup. A reaper or newer worker can also
98
+ # fence this job while the browser call is in flight.
99
+ return :fenced_out unless completed.cancel_requested? || completed.fence == leased.fence
100
+
101
+ deliver(completed, result) unless completed.cancel_requested?
96
102
 
97
103
  result.ok? ? (final || result.to_h) : result.to_h
98
104
  rescue InvalidInput => e
@@ -106,15 +112,21 @@ module RubyLLM
106
112
  # Reports the durable state if the host cancelled or another worker fenced this
107
113
  # one while the operation was running; otherwise returns nil so the engine's own
108
114
  # result is used.
109
- def settled(operation_id)
110
- final = store.fetch(operation_id)
111
- case final&.state
115
+ def settled(operation)
116
+ case operation.state
112
117
  when :cancelled then :cancelled
113
118
  when :cleanup_pending then :cleanup_pending
114
119
  when :unknown then :unknown
115
120
  end
116
121
  end
117
122
 
123
+ def deliver(operation, result)
124
+ RubyLLM::Browser::Rails.result_delivery&.call(operation: operation, result: result)
125
+ rescue StandardError
126
+ # Queue logs may print chained exceptions containing host credentials.
127
+ raise ResultDeliveryError.new(operation: operation, result: result), cause: nil
128
+ end
129
+
118
130
  def run(kind, operation, url:, instruction:, schema:, max_bytes:)
119
131
  context = build_context(operation)
120
132
  unless context.is_a?(Context) && context.operation_store.equal?(store)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Browser
5
+ module Rails
6
+ # The host callback failed after browser execution and cleanup were attempted.
7
+ # Retain these values in host storage to retry delivery without rerunning a job.
8
+ # These are in-process objects, not a durable queue or automatic retry policy.
9
+ class ResultDeliveryError < Error
10
+ CODE = "RESULT_DELIVERY_FAILED"
11
+
12
+ attr_reader :operation, :result
13
+
14
+ def initialize(operation:, result:)
15
+ @operation = operation
16
+ @result = result
17
+ super("host result delivery failed; retry delivery without repeating browser work")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -27,6 +27,7 @@ require_relative "rails/operation_record"
27
27
  require_relative "rails/approval_record"
28
28
  require_relative "rails/active_record_operation_store"
29
29
  require_relative "rails/active_record_approval_store"
30
+ require_relative "rails/result_delivery_error"
30
31
  require_relative "rails/operation_job"
31
32
  require_relative "rails/reaper"
32
33
 
@@ -41,6 +42,20 @@ module RubyLLM
41
42
  # ->(actor_id:, run_id:) { RubyLLM::Browser::Context }, supplied by the host.
42
43
  attr_accessor :context_factory
43
44
 
45
+ # Optional host callback: ->(operation:, result:) { persist_result }.
46
+ # Runs synchronously after execution and cleanup, before the job returns.
47
+ # Raise to report failure; return values are ignored. Duplicate jobs do not
48
+ # redeliver. The host owns persistence, idempotency, access and recovery.
49
+ attr_reader :result_delivery
50
+
51
+ def result_delivery=(callback)
52
+ unless callback.nil? || callback.respond_to?(:call)
53
+ raise InvalidInput, "result_delivery must be callable or nil"
54
+ end
55
+
56
+ @result_delivery = callback
57
+ end
58
+
44
59
  # Returns the current UTC time used by the adapter.
45
60
  def clock
46
61
  @clock ||= -> { Time.now.utc }
@@ -68,6 +83,7 @@ module RubyLLM
68
83
  # Restores process defaults. Primarily a test helper.
69
84
  def reset!
70
85
  @context_factory = nil
86
+ @result_delivery = nil
71
87
  @clock = nil
72
88
  @job_queue = nil
73
89
  @operation_store = nil