openui-rails 0.4.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fd5862a42a9eaf7c669e6e94fa38d8aaac1784e4f7efe5340e9cec48756902a
4
- data.tar.gz: f7ada4e9693c3de0c0d13b05a7cc7692a44b402b291721d430038a68c6047f4c
3
+ metadata.gz: 348e7ebd421344dd62f6f5449c09fa477de3374dc1803c51aa8bc1415ad0fcfc
4
+ data.tar.gz: 6b82e02f137a9bff26b5b8c9dfd3e6a6053593e5a75f66b87825486e2d667c67
5
5
  SHA512:
6
- metadata.gz: 0dc08e62c9508a13e11fa3d4995717d6b88be85458d505da3c363bccbfa1b57a1017bc59d72d667ed3d8043eb68f1005cd18b77bc87bae3ac9df0d2fe4683017
7
- data.tar.gz: 524eb2239e400a15e6e0dee14d98b5d71e0a6f94866e4bfafda48917ea17f55a18a9eda9126cb13d7a7e4e149bd3ecfaa4f755ebac32f4ba2ca99d9b18db1d01
6
+ metadata.gz: 6f82e650f49c1b59912959c2e7d3d59d7b0c17088b0fb9dbcc9c97b235910645a5b026834cf509aa4b1915e55e9a2fafe20db57850b3a038dda144f2ce8572f2
7
+ data.tar.gz: 59e46f83487ce218dcd0780d89fc1bbe5a86dd6483d239cffd572effa327b17f130375761cea02ab472865ebd4fa5f8cc6c8587f58dad0e0266bc2a467cec25a
data/CHANGELOG.md CHANGED
@@ -6,6 +6,61 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - A Supported API reference page that names the Ruby methods, settings, tasks,
14
+ events, wire contracts, and runtime pin policy covered by 1.x compatibility.
15
+
16
+ ### Deprecated
17
+
18
+ - `config.library_name` and `config.surface_id_param` have no effect and report
19
+ a deprecation through the new `OpenUI::Rails.deprecator`, registered in
20
+ `Rails.application.deprecators`. They will be removed in 1.0.
21
+
22
+ ### Changed
23
+
24
+ - The action endpoint returns HTTP 500 with code `error` and the fixed message
25
+ `action failed` for server-side gem errors, such as an unserializable result.
26
+ It previously returned 422 with the internal message. Details go to the log.
27
+ - `OpenUI::Rails::RuntimeAsset` is marked internal. Configure runtime assets
28
+ through `Configuration`.
29
+
30
+ ### Fixed
31
+
32
+ - A response that exceeds `stream_byte_limit` no longer drains the provider.
33
+ `Bridge#envelopes`, `Broadcaster.broadcast`, and the RubyLLM adapter stop
34
+ reading after the `stream_too_large` frame. `Writer#terminated?` lets
35
+ callback-based providers do the same.
36
+ - A surface that reconnects while paused for a snapshot, including a Turbo
37
+ cache restoration, emits `openui:snapshot-required` again. It previously
38
+ dropped every later delta without telling the host.
39
+ - An exception raised by the `on_error` hook no longer suppresses the stream's
40
+ error frame or replaces the provider error, which left the surface busy.
41
+ - A surface without a stored `library_version` now checks stream frames against
42
+ the configured library fingerprint instead of accepting any library.
43
+
44
+ ## [0.5.0] - 2026-10-21
45
+
46
+ ### Added
47
+
48
+ - Content-free `action.openui_rails` and `stream.openui_rails` notifications with
49
+ monotonic duration, fixed outcomes, replay status, and stream watermarks.
50
+ - Browser `openui:ready` and `openui:snapshot-applied` events for host-owned
51
+ fallback and recovery, plus stable runtime and network failure codes.
52
+ - Ordinary HTML fallback and explicit reload, scoped snapshot, and fresh sign-in
53
+ controls in the reference host, with keyboard and Turbo restoration checks.
54
+
55
+ ### Fixed
56
+
57
+ - Late action responses cannot report into a reconnected surface. Failed
58
+ runtime scripts are removed so later mounts can retry loading.
59
+ - Unmounted renderer callbacks cannot send actions, tool requests, state, or
60
+ errors through a new connection. A late snapshot cannot dismiss a host's
61
+ authentication or permission failure.
62
+ - Browser failures use events without automatically logging raw error objects.
63
+
9
64
  ## [0.4.0] - 2026-10-20
10
65
 
11
66
  ### Added
@@ -133,7 +188,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
133
188
  - Gem-scoped RubyGems Trusted Publishing through GitHub OIDC, with a protected
134
189
  release environment and verified artifact handoff.
135
190
 
136
- [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.4.0...main
191
+ [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.9.0...main
192
+ [0.9.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.9.0
193
+ [0.5.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.5.0
137
194
  [0.4.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.4.0
138
195
  [0.3.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.3.0
139
196
  [0.2.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.2.0
data/CONTRIBUTING.md CHANGED
@@ -25,7 +25,7 @@ bundle exec bin/rails test # unit + integration tests
25
25
  bundle exec bin/rails test:system # browser (system) tests, needs Chrome
26
26
  bundle exec rubocop # style (rubocop-rails-omakase)
27
27
  bundle exec gem build openui-rails.gemspec
28
- bundle exec ruby bin/smoke-package openui-rails-0.4.0.gem
28
+ bundle exec ruby bin/smoke-package openui-rails-0.9.0.gem
29
29
  bundle exec bundler-audit check --update
30
30
  bundle exec ruby -Itest test/compatibility/ruby_llm_smoke.rb
31
31
  ```
data/DEVELOPMENT.md CHANGED
@@ -17,7 +17,7 @@ authorizes a live call. Follow this loop for each feature:
17
17
  ```sh
18
18
  bin/check-reference
19
19
  bin/check-reference --runtime tmp/real-runtime
20
- bin/check-reference --gem tmp/package/openui-rails-0.4.0.gem --runtime tmp/real-runtime
20
+ bin/check-reference --gem tmp/package/openui-rails-0.9.0.gem --runtime tmp/real-runtime
21
21
  # Focus one browser scenario:
22
22
  bin/check-reference --runtime tmp/real-runtime --name test_a_lost_turbo_envelope_recovers_from_the_scoped_snapshot
23
23
  ```
data/README.md CHANGED
@@ -32,7 +32,7 @@ The runtime-fetch task also needs npm. RubyLLM is optional.
32
32
 
33
33
  ```ruby
34
34
  # Gemfile
35
- gem "openui-rails", "~> 0.4.0"
35
+ gem "openui-rails", "~> 0.9.0"
36
36
  ```
37
37
 
38
38
  ```sh
@@ -65,15 +65,16 @@ The documentation site contains the full guides and API reference:
65
65
  - [RubyLLM](https://openui-rails.wangyaodi.com/ruby-llm/) and [production setup](https://openui-rails.wangyaodi.com/production/)
66
66
  - [Upgrading](https://openui-rails.wangyaodi.com/upgrading/) and [API reference](https://openui-rails.wangyaodi.com/api-reference/)
67
67
 
68
- Version `0.4.0` adds optional shared replay storage for Rails workers.
69
- See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-4/) for the upgrade.
68
+ Version `0.9.0` freezes the candidate API for 1.0 and fixes stream and recovery defects.
69
+ See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-9/) for the upgrade and
70
+ [Supported API](https://openui-rails.wangyaodi.com/supported-api/) for the compatibility boundary.
70
71
  The API may change during `0.x`; see the
71
72
  [changelog](CHANGELOG.md) and the proposed [path to 1.0](ROADMAP.md).
72
73
  The site's stable channel follows the latest release; [/next/](https://openui-rails.wangyaodi.com/next/)
73
74
  follows `main`.
74
75
 
75
76
  For repository work, use [Contributing](CONTRIBUTING.md),
76
- [Releasing](RELEASING.md), and the [0.3.0 release record](RELEASE_0_3.md).
77
+ [Releasing](RELEASING.md), and the [0.5.0 release record](RELEASE_0_5.md).
77
78
  Report vulnerabilities through the [security policy](SECURITY.md).
78
79
 
79
80
  ## License
@@ -48,20 +48,21 @@ module OpenUI
48
48
  status: :unprocessable_content
49
49
  end
50
50
 
51
+ # Request errors return their own message. Any other gem error is a
52
+ # server-side fault, possibly after the handler ran: log it and return a
53
+ # generic message so configuration details never reach the browser.
51
54
  def render_domain_error(error)
52
- render json: { error: { code: error_code(error), message: error.message } },
53
- status: error_status(error)
55
+ code = error_code(error)
56
+ if code == "error"
57
+ logger&.error("[openui-rails] action failed: #{error.class}: #{error.message}")
58
+ render json: { error: { code: code, message: "action failed" } }, status: :internal_server_error
59
+ else
60
+ render json: { error: { code: code, message: error.message } }, status: error_status(error)
61
+ end
54
62
  end
55
63
 
56
64
  def error_code(error)
57
- case error
58
- when UnknownActionError then "unknown_action"
59
- when UnauthorizedActionError then "unauthorized"
60
- when StaleSurfaceError then "stale_surface"
61
- when ReplayError then "replay"
62
- when InvalidActionError then "invalid_action"
63
- else "error"
64
- end
65
+ Instrumentation.action_error_code(error)
65
66
  end
66
67
 
67
68
  def error_status(error)
@@ -69,7 +70,8 @@ module OpenUI
69
70
  when UnknownActionError then :not_found
70
71
  when UnauthorizedActionError then :forbidden
71
72
  when StaleSurfaceError, ReplayError then :conflict
72
- else :unprocessable_content
73
+ when InvalidActionError then :unprocessable_content
74
+ else :internal_server_error
73
75
  end
74
76
  end
75
77
  end
@@ -12,16 +12,18 @@ function loadRuntime(url, integrity) {
12
12
  script.async = true
13
13
  script.crossOrigin = "anonymous"
14
14
  if (integrity) script.integrity = integrity
15
+ const failed = (message) => {
16
+ script.remove()
17
+ reject(Object.assign(new Error(message), { code: "runtime_load_failed" }))
18
+ }
15
19
  script.addEventListener("load", () => {
16
20
  if (!window.__OpenUI) {
17
- reject(new Error(`OpenUI runtime at ${url} did not expose window.__OpenUI`))
21
+ failed("OpenUI runtime did not expose window.__OpenUI")
18
22
  return
19
23
  }
20
24
  resolve(window.__OpenUI)
21
25
  })
22
- script.addEventListener("error", () =>
23
- reject(new Error(`Failed to load the OpenUI runtime from ${url}`))
24
- )
26
+ script.addEventListener("error", () => failed("Failed to load the OpenUI runtime"))
25
27
  document.head.appendChild(script)
26
28
  })
27
29
  promise.catch(() => runtimePromises.delete(url))
@@ -71,6 +73,12 @@ export default class extends Controller {
71
73
 
72
74
  this.syncBusy()
73
75
  this.renderRuntime(connection)
76
+ // A surface restored from the Turbo cache or reattached while paused for a
77
+ // snapshot would otherwise wait forever: the host abandoned the earlier
78
+ // request on disconnect. Announce it again once sibling controllers connect.
79
+ if (this.state.needsSnapshot) {
80
+ queueMicrotask(() => { if (this.isCurrent(connection)) this.announceSnapshotRequired() })
81
+ }
74
82
  }
75
83
 
76
84
  disconnect() {
@@ -80,6 +88,8 @@ export default class extends Controller {
80
88
  this.abortController?.abort()
81
89
  this.root?.unmount()
82
90
  this.root = null
91
+ this.runtime = null
92
+ this.tools = null
83
93
  this.mountPoint?.remove()
84
94
  this.mountPoint = null
85
95
  }
@@ -88,11 +98,11 @@ export default class extends Controller {
88
98
  try {
89
99
  const runtime = await loadRuntime(this.runtimeUrl, this.runtimeIntegrity)
90
100
  await this.verifyLibrary(runtime.openuiChatLibrary)
91
- if (connection !== this.connection || !this.element.isConnected || !this.mountPoint) return
101
+ if (!this.isCurrent(connection) || !this.mountPoint) return
92
102
  this.runtime = runtime
93
- this.render()
103
+ if (this.render()) this.dispatch("ready", { surfaceId: this.surfaceId })
94
104
  } catch (error) {
95
- if (connection !== this.connection) return
105
+ if (!this.isCurrent(connection)) return
96
106
  this.state.streaming = false
97
107
  this.syncBusy()
98
108
  this.handleError(error)
@@ -104,6 +114,7 @@ export default class extends Controller {
104
114
  if (!this.runtime || !this.mountPoint || !this.connection) return
105
115
  if (!this.withinByteLimit(this.state.source)) return
106
116
  const { React, createRoot, Renderer, openuiChatLibrary } = this.runtime
117
+ const connection = this.connection
107
118
  if (!this.root) this.root = createRoot(this.mountPoint)
108
119
  this.root.render(
109
120
  React.createElement(Renderer, {
@@ -115,12 +126,13 @@ export default class extends Controller {
115
126
  initialState: this.initialState,
116
127
  // The official runtime emits { type, params }. It does not await this
117
128
  // void callback; dispatchAction already emits a structured error.
118
- onAction: (action) => { this.dispatchAction(action).catch(() => {}) },
119
- onStateUpdate: (formState) => this.persistState(formState),
129
+ onAction: (action) => { if (this.isCurrent(connection)) this.dispatchAction(action).catch(() => {}) },
130
+ onStateUpdate: (formState) => { if (this.isCurrent(connection)) this.persistState(formState) },
120
131
  toolProvider: this.toolProvider(),
121
- onError: (error) => this.handleError(error),
132
+ onError: (error) => { if (this.isCurrent(connection)) this.handleError(error) },
122
133
  })
123
134
  )
135
+ return true
124
136
  }
125
137
 
126
138
  async verifyLibrary(library) {
@@ -153,7 +165,10 @@ export default class extends Controller {
153
165
  (envelope.type === "openui.snapshot" && typeof envelope.response !== "string")) {
154
166
  return this.handleError(Object.assign(new Error("Malformed stream envelope"), { code: "invalid_envelope" }))
155
167
  }
156
- if (envelope.library_version && this.libraryVersion && envelope.library_version !== this.libraryVersion) {
168
+ // Without a stored library version, the verified runtime fingerprint is
169
+ // still the only library this mount can render.
170
+ const library = this.libraryVersion || this.element.dataset.openuiLibraryFingerprint
171
+ if (envelope.library_version && library && envelope.library_version !== library) {
157
172
  return this.handleError(Object.assign(new Error("OpenUI library version mismatch"), { code: "library_version_mismatch" }))
158
173
  }
159
174
  if (envelope.type !== "openui.snapshot") {
@@ -203,11 +218,16 @@ export default class extends Controller {
203
218
  this.state.streaming = envelope.streaming === true
204
219
  this.state.completed = !this.state.streaming
205
220
  this.render()
221
+ this.dispatch("snapshot-applied", { surfaceId: this.surfaceId, sequence: envelope.sequence })
206
222
  }
207
223
 
208
224
  requestSnapshot() {
209
225
  if (this.state.needsSnapshot) return
210
226
  this.state.needsSnapshot = true
227
+ this.announceSnapshotRequired()
228
+ }
229
+
230
+ announceSnapshotRequired() {
211
231
  this.dispatch("snapshot-required", { surfaceId: this.surfaceId, sequence: this.state.nextSequence })
212
232
  }
213
233
 
@@ -224,6 +244,8 @@ export default class extends Controller {
224
244
  // --- Actions -------------------------------------------------------------
225
245
 
226
246
  async dispatchAction(payload = {}) {
247
+ const connection = this.connection
248
+ if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
227
249
  const body = {
228
250
  action: payload.action ?? payload.name ?? payload.type,
229
251
  args: this.actionArguments(payload),
@@ -241,6 +263,7 @@ export default class extends Controller {
241
263
  headers: this.headers(),
242
264
  body: JSON.stringify(body),
243
265
  })
266
+ if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
244
267
  if (response.redirected) {
245
268
  throw Object.assign(new Error("Action request was redirected; authentication may have expired"), { code: "unauthorized" })
246
269
  }
@@ -253,8 +276,9 @@ export default class extends Controller {
253
276
  } catch {
254
277
  throw Object.assign(new Error("Action endpoint returned malformed JSON"), { code: "invalid_response" })
255
278
  }
279
+ if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
256
280
  if (!response.ok) {
257
- throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code })
281
+ throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code || "action_failed" })
258
282
  }
259
283
  const result = json?.result
260
284
  if (!result || result.action !== body.action || result.surface_id !== body.surface_id || result.version !== body.version) {
@@ -263,7 +287,7 @@ export default class extends Controller {
263
287
  this.dispatch("action", { surfaceId: this.surfaceId, result })
264
288
  return result
265
289
  } catch (error) {
266
- if (error.name !== "AbortError" || this.connection) this.handleError(error)
290
+ if (this.isCurrent(connection)) this.handleError(error, error.name === "AbortError" ? "request_cancelled" : "network_error")
267
291
  throw error
268
292
  }
269
293
  }
@@ -284,6 +308,7 @@ export default class extends Controller {
284
308
  // Every browser `Query()`/`Mutation()` tool call is routed to the same
285
309
  // server endpoint; the host registry decides what is permitted.
286
310
  toolProvider() {
311
+ const connection = this.connection
287
312
  const reserved = new Set([ "callTool", "then", "__proto__", "constructor", "prototype", "toJSON" ])
288
313
  return this.tools ||= new Proxy(
289
314
  {},
@@ -291,7 +316,10 @@ export default class extends Controller {
291
316
  has: (_target, name) => typeof name === "string" && !reserved.has(name),
292
317
  get: (_target, name) => {
293
318
  if (typeof name !== "string" || reserved.has(name)) return undefined
294
- return (args) => this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
319
+ return (args) => {
320
+ if (!this.isCurrent(connection)) return Promise.reject(new DOMException("Surface disconnected", "AbortError"))
321
+ return this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
322
+ }
295
323
  },
296
324
  }
297
325
  )
@@ -310,17 +338,19 @@ export default class extends Controller {
310
338
  this.element.setAttribute("aria-busy", this.state.streaming ? "true" : "false")
311
339
  }
312
340
 
313
- handleError(error) {
341
+ handleError(error, fallbackCode = "renderer_error") {
314
342
  // Renderer#onError receives an array, including [] after successful parses.
315
343
  if (Array.isArray(error)) {
316
344
  if (error.length === 0) return
317
345
  error = Object.assign(new Error(error.map((entry) => entry.message || String(entry)).join("; ")),
318
346
  { code: "renderer_error" })
319
347
  }
320
- console.error("[openui-rails]", error)
321
- this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message, code: error?.code })
348
+ this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message,
349
+ code: typeof error?.code === "string" ? error.code : fallbackCode })
322
350
  }
323
351
 
352
+ isCurrent(connection) { return !!connection && connection === this.connection && this.element.isConnected }
353
+
324
354
  headers() {
325
355
  const token = document.querySelector("meta[name='csrf-token']")?.content
326
356
  const headers = { "Content-Type": "application/json", Accept: "application/json" }
@@ -6,12 +6,17 @@ OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams. It opts into the
6
6
  Active Record idempotency store and retains a unique business receipt for closing
7
7
  a ticket. Shared claims do not replace that database constraint.
8
8
 
9
+ The plain HTML fallback provides ticket details, an explicit reload, and a fresh
10
+ sign-in. Snapshot recovery is scoped and single-flight. Unknown action outcomes
11
+ are never retried automatically. Browser checks cover failure feedback, keyboard
12
+ recovery, and Turbo restoration as well as the successful workflow.
13
+
9
14
  From the repository:
10
15
 
11
16
  ```sh
12
17
  mkdir -p tmp/package
13
- bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.4.0.gem
14
- bin/check-reference --gem tmp/package/openui-rails-0.4.0.gem --runtime tmp/real-runtime
18
+ bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.9.0.gem
19
+ bin/check-reference --gem tmp/package/openui-rails-0.9.0.gem --runtime tmp/real-runtime
15
20
  ```
16
21
 
17
22
  Fetch the runtime first. See the [complete guide](https://openui-rails.wangyaodi.com/reference-workflow/)
@@ -5,37 +5,68 @@ import EnvelopeController from "openui/rails/openui_envelope_controller"
5
5
 
6
6
  class SupportDeskController extends Controller {
7
7
  connect() {
8
+ this.connection = Symbol("support-desk")
8
9
  this.abort = new AbortController()
9
10
  this.saves = Promise.resolve()
10
11
  this.lastState = null
11
- if (this.surface.dataset.openuiStreaming === "true") this.stream().catch(error => this.failure(error))
12
+ this.runtimeReady = false
13
+ this.recovering = false
14
+ this.failureCode = this.element.dataset.supportDeskFailure || null
15
+ this.beforeCache = () => this.disconnect()
16
+ document.addEventListener("turbo:before-cache", this.beforeCache)
17
+ if (this.surface.dataset.openuiStreaming === "true" && !this.failureCode) {
18
+ const connection = this.connection
19
+ this.stream(connection).catch(error => { if (this.current(connection)) this.failure(error) })
20
+ }
12
21
  }
13
22
 
14
- disconnect() { this.abort.abort() }
23
+ disconnect() {
24
+ this.connection = null
25
+ this.abort?.abort()
26
+ document.removeEventListener("turbo:before-cache", this.beforeCache)
27
+ }
28
+ current(connection) { return !!connection && connection === this.connection && this.element.isConnected }
15
29
  get surface() { return this.element.querySelector("[data-controller='openui']") }
16
30
  get status() { return this.element.querySelector("#desk-status") }
31
+ get fallback() { return this.element.querySelector("#desk-fallback") }
32
+ get recoverButton() { return this.element.querySelector("#desk-recover") }
17
33
 
18
34
  headers() {
19
35
  return { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content }
20
36
  }
21
37
 
22
- async checked(response) {
23
- if (response.redirected || !response.ok) throw new Error(response.status === 401 || response.status === 403 ? "Session expired. Sign in again." : "Request rejected. Reload the conversation.")
38
+ checked(response) {
39
+ if (response.redirected || [401, 403].includes(response.status)) {
40
+ throw Object.assign(new Error("Session or permission rejected"), { code: "unauthorized" })
41
+ }
42
+ if (!response.ok) throw Object.assign(new Error("Request rejected"), {
43
+ code: response.status === 409 ? "stale_surface" : "request_failed"
44
+ })
24
45
  return response
25
46
  }
26
47
 
27
- // Consume newline-delimited Turbo Stream elements as response chunks arrive.
28
- // Authorization and canonical response persistence happen before delivery.
29
- async stream() {
30
- const response = await this.checked(await fetch(this.element.dataset.supportDeskStreamUrl, {
48
+ ready() {
49
+ this.runtimeReady = true
50
+ if (!this.failureCode) {
51
+ this.fallback.hidden = true
52
+ this.surface.inert = false
53
+ }
54
+ }
55
+
56
+ // Persisted canonical output is independent of this disposable connection.
57
+ async stream(connection) {
58
+ const response = await fetch(this.element.dataset.supportDeskStreamUrl, {
31
59
  method: "POST", headers: this.headers(), signal: this.abort.signal, credentials: "same-origin"
32
- }))
60
+ })
61
+ if (!this.current(connection)) return
62
+ this.checked(response)
33
63
  const reader = response.body.getReader()
34
64
  const decoder = new TextDecoder()
35
65
  let pending = ""
36
66
  try {
37
67
  while (true) {
38
68
  const { value, done } = await reader.read()
69
+ if (!this.current(connection)) return
39
70
  pending += decoder.decode(value, { stream: !done })
40
71
  let end
41
72
  while ((end = pending.indexOf("\n")) >= 0) {
@@ -44,26 +75,54 @@ class SupportDeskController extends Controller {
44
75
  }
45
76
  if (done) break
46
77
  }
47
- if (pending.trim()) throw new Error("Incomplete stream. Reload the conversation.")
48
- this.status.textContent = "Response loaded"
78
+ if (pending.trim()) throw new Error("Incomplete stream")
79
+ if (!this.failureCode) this.status.textContent = "Response loaded"
49
80
  } finally {
50
81
  await reader.cancel().catch(() => {})
51
82
  }
52
83
  }
53
84
 
54
85
  async recover() {
55
- if (this.recovering) return
86
+ if (!this.current(this.connection) || this.recovering || ["unauthorized", "csrf_failed"].includes(this.failureCode)) return
87
+ const connection = this.connection
56
88
  this.recovering = true
89
+ this.failure({ code: "sequence_gap" })
90
+ this.recoverButton.disabled = true
91
+ this.recoverButton.setAttribute("aria-busy", "true")
92
+ this.status.textContent = "Restoring saved response"
57
93
  try {
58
- const response = await this.checked(await fetch(this.element.dataset.supportDeskSnapshotUrl, {
94
+ const response = await fetch(this.element.dataset.supportDeskSnapshotUrl, {
59
95
  headers: { Accept: "application/json" }, signal: this.abort.signal, credentials: "same-origin"
60
- }))
61
- const envelope = await response.json()
96
+ })
97
+ if (!this.current(connection)) return
98
+ const envelope = await this.checked(response).json()
99
+ if (!this.current(connection)) return
100
+ this.snapshotApplied = false
62
101
  this.surface.dispatchEvent(new CustomEvent("openui:envelope", { bubbles: true, detail: envelope }))
63
- this.element.dataset.recovered = "true"
64
- this.status.textContent = "Recovered from a missing update"
65
- } catch (error) { this.failure(error) }
66
- finally { this.recovering = false }
102
+ if (!this.snapshotApplied) throw Object.assign(new Error("Snapshot not accepted"), { code: "snapshot_unavailable" })
103
+ } catch (error) {
104
+ if (this.current(connection)) this.failure(error)
105
+ } finally {
106
+ if (this.current(connection)) {
107
+ this.recovering = false
108
+ this.recoverButton.disabled = this.failureCode !== "sequence_gap" && this.failureCode !== "request_failed"
109
+ this.recoverButton.setAttribute("aria-busy", "false")
110
+ }
111
+ }
112
+ }
113
+
114
+ restored() {
115
+ this.snapshotApplied = true
116
+ // A transport snapshot cannot restore identity or permission. Keep that
117
+ // failure visible until the host performs a fresh sign-in or reload.
118
+ if (this.failureCode && !["sequence_gap", "request_failed"].includes(this.failureCode)) return
119
+ this.failureCode = null
120
+ delete this.element.dataset.supportDeskFailure
121
+ this.element.dataset.recovered = "true"
122
+ this.element.querySelector("#desk-error").textContent = ""
123
+ this.status.textContent = "Recovered from a missing update"
124
+ this.surface.inert = false
125
+ if (this.runtimeReady) this.fallback.hidden = true
67
126
  }
68
127
 
69
128
  saveState(event) {
@@ -74,32 +133,58 @@ class SupportDeskController extends Controller {
74
133
  if (this.lastState === serialized) return
75
134
  this.lastState = serialized
76
135
  this.status.textContent = "Saving draft"
136
+ const connection = this.connection, signal = this.abort.signal
77
137
  const version = Number(this.surface.dataset.openuiVersion)
78
138
  this.saves = this.saves.then(async () => {
79
- await this.checked(await fetch(this.element.dataset.supportDeskStateUrl, {
80
- method: "PATCH", headers: this.headers(), credentials: "same-origin", signal: this.abort.signal,
139
+ if (!this.current(connection)) return
140
+ const response = await fetch(this.element.dataset.supportDeskStateUrl, {
141
+ method: "PATCH", headers: this.headers(), credentials: "same-origin", signal,
81
142
  body: JSON.stringify({ state, version })
82
- }))
83
- if (this.lastState === serialized) this.status.textContent = "Draft saved"
84
- }).catch(error => this.failure(error))
143
+ })
144
+ if (!this.current(connection)) return
145
+ this.checked(response)
146
+ if (this.lastState === serialized && !this.failureCode) this.status.textContent = "Draft saved"
147
+ }).catch(error => { if (this.current(connection)) this.failure(error) })
85
148
  }
86
149
 
87
150
  async action(event) {
88
151
  if (!["continue_conversation", "close_ticket"].includes(event.detail.result.action)) return
152
+ const connection = this.connection
89
153
  await this.saves
90
- window.location.reload()
154
+ if (this.current(connection)) window.location.reload()
91
155
  }
92
156
 
93
157
  error(event) {
94
- if (["unauthorized", "csrf_failed"].includes(event.detail.code)) {
95
- this.denied = true
96
- this.failure(new Error("Action not permitted. Sign in again."))
97
- } else if (!this.denied) {
98
- this.failure(new Error(event.detail.message || "Unable to render this response."))
99
- }
158
+ // The runtime can report a secondary parse failure after an action error.
159
+ // Keep the actionable server outcome instead of replacing it with that echo.
160
+ if (this.failureCode && event.detail.code === "renderer_error") return
161
+ this.failure(event.detail)
100
162
  }
163
+
101
164
  failure(error) {
102
- if (error.name !== "AbortError") this.element.querySelector("#desk-error").textContent = error.message
165
+ if (error.name === "AbortError") return
166
+ this.failureCode = error.code || "request_failed"
167
+ this.element.dataset.supportDeskFailure = this.failureCode
168
+ const messages = {
169
+ runtime_load_failed: "Interactive view could not load. Review the ticket details or reload.",
170
+ library_version_mismatch: "The response uses a different library. Reload after correcting setup.",
171
+ provider_rejected: "Generation stopped. Reload the saved conversation before trying again.",
172
+ stream_failed: "Generation stopped. Reload the saved conversation before trying again.",
173
+ stream_cancelled: "Generation stopped. Reload the saved conversation before trying again.",
174
+ stream_too_large: "The response is too large. Reload the saved conversation.",
175
+ stale_surface: "Conversation changed. Reload before submitting another action.",
176
+ unauthorized: "Action not permitted. Sign in again.",
177
+ csrf_failed: "Action not permitted. Sign in again.",
178
+ sequence_gap: "An update is missing. Restore the saved response or reload.",
179
+ snapshot_unavailable: "Saved response cannot be applied. Reload the conversation.",
180
+ renderer_error: "Interactive response could not render. Reload the conversation."
181
+ }
182
+ this.element.querySelector("#desk-error").textContent = messages[this.failureCode] || "Request failed; its outcome may be unknown. Reload canonical state before trying again."
183
+ this.fallback.hidden = false
184
+ this.surface.inert = true
185
+ this.recoverButton.disabled = !["sequence_gap", "request_failed"].includes(this.failureCode)
186
+ this.element.querySelector("#desk-sign-in").hidden = !["unauthorized", "csrf_failed"].includes(this.failureCode)
187
+ this.status.textContent = "Recovery required"
103
188
  }
104
189
  }
105
190
 
@@ -1,6 +1,7 @@
1
1
  <header>
2
2
  <h1>Support Desk</h1>
3
3
  <span><%= current_actor.name %> · <%= current_actor.tenant_id %> tenant</span>
4
+ <%= link_to "Conversations", root_path %>
4
5
  <%= button_to "Sign out", session_path, method: :delete %>
5
6
  </header>
6
7
  <p class="notice">Recorded response, real OpenUI runtime. Your Rails host owns the ticket and conversation.</p>
@@ -8,9 +9,18 @@
8
9
  data-support-desk-stream-url="<%= stream_conversation_path(@conversation) %>"
9
10
  data-support-desk-snapshot-url="<%= snapshot_conversation_path(@conversation) %>"
10
11
  data-support-desk-state-url="<%= state_conversation_path(@conversation) %>"
11
- data-action="openui:state->support-desk#saveState openui:snapshot-required->support-desk#recover openui:action->support-desk#action openui:error->support-desk#error">
12
+ data-action="openui:ready->support-desk#ready openui:snapshot-applied->support-desk#restored openui:state->support-desk#saveState openui:snapshot-required->support-desk#recover openui:action->support-desk#action openui:error->support-desk#error">
12
13
  <%= openui_surface(surface: @conversation.surface, label: "Support ticket") %>
13
14
  <div id="desk-envelopes" hidden></div>
14
15
  <p role="status" id="desk-status">Ready</p>
15
16
  <p role="alert" id="desk-error"></p>
17
+ <aside id="desk-fallback" class="notice" aria-labelledby="desk-fallback-title">
18
+ <h2 id="desk-fallback-title">Ticket details and recovery</h2>
19
+ <p><%= @conversation.ticket.title %> · <%= @conversation.ticket.status %></p>
20
+ <p>If the interactive view is unavailable, review these saved details. Reloading does not resubmit your last action.</p>
21
+ <%= link_to "Reload conversation", conversation_path(@conversation), data: { turbo: false } %>
22
+ <%= link_to "Sign in again", root_path, id: "desk-sign-in", data: { turbo: false }, hidden: true %>
23
+ <button id="desk-recover" type="button" disabled aria-busy="false" aria-describedby="desk-error"
24
+ data-action="support-desk#recover">Restore saved response</button>
25
+ </aside>
16
26
  </section>
@@ -78,8 +78,16 @@ module OpenUI
78
78
  #
79
79
  # @return [Result]
80
80
  #
81
- # @raise [OpenUI::Rails::Error] on any failed gate, before side effects
81
+ # Emits one content-free action.openui_rails outcome notification.
82
+ # Failed gates run before the handler. Handler and storage failures can
83
+ # occur after a business effect; hosts must reconcile that outcome.
84
+ #
85
+ # @raise [OpenUI::Rails::Error] on a failed gate or domain operation
82
86
  def call(payload, actor: nil, surface: nil, tenant: nil, context: {})
87
+ started = Instrumentation.now
88
+ outcome = "rejected"
89
+ replayed = false
90
+ code = nil
83
91
  submission = Submission.parse(payload)
84
92
  definition = @registry.fetch(submission.action)
85
93
 
@@ -102,11 +110,22 @@ module OpenUI
102
110
  authorize!(definition, invocation)
103
111
 
104
112
  replayed, stored, key, token = claim_idempotency(submission, invocation)
105
- return result(submission, stored, replayed: true) if replayed
113
+ if replayed
114
+ outcome = "replayed"
115
+ return result(submission, stored, replayed: true)
116
+ end
106
117
 
118
+ outcome = "failed"
107
119
  value = definition.handler.call(invocation)
108
120
  @idempotency_store.record(key, value, claim_token: token)
121
+ outcome = "succeeded"
109
122
  result(submission, value, replayed: false)
123
+ rescue StandardError => error
124
+ code = Instrumentation.action_error_code(error)
125
+ outcome = "failed" unless error.is_a?(Error)
126
+ raise
127
+ ensure
128
+ Instrumentation.publish("action", started: started, outcome: outcome, code: code, replayed: replayed == true)
110
129
  end
111
130
 
112
131
  private
@@ -17,6 +17,10 @@ module OpenUI
17
17
  module RubyLLM
18
18
  STRATEGIES = %i[complete ask].freeze
19
19
 
20
+ # Internal signal that aborts the provider stream after a terminal frame.
21
+ Halt = Class.new(StandardError)
22
+ private_constant :Halt
23
+
20
24
  class << self
21
25
  # Stream a RubyLLM completion, yielding ordered {Stream::Envelope}s.
22
26
  #
@@ -47,6 +51,7 @@ module OpenUI
47
51
 
48
52
  begin
49
53
  consumer_failed = false
54
+ halted = false
50
55
  deliver = lambda do |envelope|
51
56
  begin
52
57
  block.call(envelope)
@@ -60,10 +65,17 @@ module OpenUI
60
65
  next if text.nil?
61
66
 
62
67
  writer.push(text).each(&deliver)
68
+ # A terminal frame (the byte limit) has already been delivered.
69
+ # Raise out of the provider callback so it stops generating.
70
+ if writer.terminated?
71
+ halted = true
72
+ raise Halt
73
+ end
63
74
  end
64
75
  writer.finish.each(&deliver)
65
76
  rescue StandardError => e
66
77
  raise if consumer_failed
78
+ return writer if halted
67
79
 
68
80
  envelope = writer.error_for(e, code: error_code_for(e))
69
81
  block.call(envelope) if envelope
@@ -29,8 +29,8 @@ module OpenUI
29
29
  DEFAULT_ACTIONS_ENDPOINT = "/openui-rails/actions"
30
30
 
31
31
  attr_reader :transport, :runtime_assets
32
- attr_accessor :library_spec_path, :prompt_path, :library_name,
33
- :actions_endpoint, :surface_id_param,
32
+ attr_accessor :library_spec_path, :prompt_path,
33
+ :actions_endpoint,
34
34
  :stream_byte_limit, :pending_byte_limit, :idempotency_ttl,
35
35
  :authorize_action, :on_error, :action_registry, :surface_resolver, :actor_resolver,
36
36
  :runtime_version, :runtime_url, :runtime_integrity, :local_runtime_path,
@@ -62,6 +62,32 @@ module OpenUI
62
62
  @local_runtime_path = "/openui"
63
63
  end
64
64
 
65
+ # @deprecated Has no effect: the runtime library comes from the loaded
66
+ # bundle and its manifest. Removed in 1.0.
67
+ def library_name
68
+ deprecated_setting(:library_name)
69
+ @library_name
70
+ end
71
+
72
+ # @deprecated Has no effect. Removed in 1.0.
73
+ def library_name=(value)
74
+ deprecated_setting(:library_name)
75
+ @library_name = value
76
+ end
77
+
78
+ # @deprecated Has no effect: action requests always use the `surface_id`
79
+ # key. Removed in 1.0.
80
+ def surface_id_param
81
+ deprecated_setting(:surface_id_param)
82
+ @surface_id_param
83
+ end
84
+
85
+ # @deprecated Has no effect. Removed in 1.0.
86
+ def surface_id_param=(value)
87
+ deprecated_setting(:surface_id_param)
88
+ @surface_id_param = value
89
+ end
90
+
65
91
  # Set the preferred host stream transport.
66
92
  #
67
93
  # @param value [Symbol] `:turbo`, `:action_cable`, or `:none`
@@ -243,6 +269,12 @@ module OpenUI
243
269
  PromptBuilder.load(self) if prompt_path
244
270
  true
245
271
  end
272
+
273
+ private
274
+
275
+ def deprecated_setting(name)
276
+ OpenUI::Rails.deprecator.warn("config.#{name} has no effect and will be removed; delete it from your initializer")
277
+ end
246
278
  end
247
279
 
248
280
  class << self
@@ -27,6 +27,10 @@ module OpenUI
27
27
  ::Rails.autoloaders.main.inflector.inflect("openui" => "OpenUI")
28
28
  end
29
29
 
30
+ initializer "openui_rails.deprecator" do |app|
31
+ app.deprecators[:openui_rails] = OpenUI::Rails.deprecator
32
+ end
33
+
30
34
  # Expose the surface helper to every host view.
31
35
  initializer "openui_rails.view_helper" do
32
36
  ActiveSupport.on_load(:action_view) do
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module OpenUI
6
+ module Rails
7
+ # Internal publisher for the documented, content-free notification contract.
8
+ module Instrumentation # :nodoc:
9
+ def self.now
10
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ end
12
+
13
+ def self.publish(name, started:, **metadata)
14
+ # Do not wrap application code in instrument's block: Active Support
15
+ # would attach the raw exception and its message if that code raised.
16
+ ActiveSupport::Notifications.instrument("#{name}.openui_rails",
17
+ **metadata, duration_ms: (now - started) * 1000)
18
+ rescue StandardError
19
+ # Observability must not change a business result or mask its exception.
20
+ nil
21
+ end
22
+
23
+ def self.action_error_code(error)
24
+ case error
25
+ when UnknownActionError then "unknown_action"
26
+ when UnauthorizedActionError then "unauthorized"
27
+ when StaleSurfaceError then "stale_surface"
28
+ when ReplayError then "replay"
29
+ when InvalidActionError then "invalid_action"
30
+ when Error then "error"
31
+ else "internal_error"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,14 +2,15 @@
2
2
 
3
3
  module OpenUI
4
4
  module Rails
5
- # Resolves the pinned OpenUI browser runtime and the integrity metadata the
6
- # mount element advertises.
5
+ # Internal: resolves the pinned OpenUI browser runtime and the integrity
6
+ # metadata the mount element advertises. Configure assets through
7
+ # {Configuration}; this module is not part of the supported API.
7
8
  #
8
9
  # The runtime is version-pinned. Self-hosted (the default) points at a file
9
10
  # the install generator vendored into the host app; the external strategy
10
11
  # requires an explicit, exact-version URL so no floating CDN range is used
11
12
  # in production.
12
- module RuntimeAsset
13
+ module RuntimeAsset # :nodoc:
13
14
  PINNED_VERSION = "0.2.1"
14
15
  FILE_PREFIX = "openui-browser-bundle"
15
16
 
@@ -20,8 +20,12 @@ module OpenUI
20
20
  @needs_snapshot = false
21
21
  end
22
22
 
23
- # @return [Symbol] one of :applied, :duplicate, :snapshot, :completed,
24
- # :error, :ignored
23
+ # Apply one envelope for this message.
24
+ #
25
+ # @param envelope [Envelope]
26
+ #
27
+ # @return [Symbol] one of :applied, :duplicate, :gap, :snapshot,
28
+ # :completed, :error, :ignored. After :gap, only a snapshot applies.
25
29
  def apply(envelope)
26
30
  return :ignored unless envelope.message_id == message_id
27
31
  unless envelope.snapshot?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "openui/rails/instrumentation"
4
+
3
5
  module OpenUI
4
6
  module Rails
5
7
  module Stream
@@ -37,7 +39,8 @@ module OpenUI
37
39
 
38
40
  # Yield ordered envelopes for every chunk from a pull-based source.
39
41
  # Provider errors become a structured error envelope. Exceptions raised
40
- # by the consumer block propagate to the caller.
42
+ # by the consumer block propagate to the caller. Pulling stops once the
43
+ # writer terminates, so an oversized response does not drain the source.
41
44
  #
42
45
  # @param source [#each] yields String chunks
43
46
  # @yieldparam envelope [Envelope]
@@ -58,6 +61,7 @@ module OpenUI
58
61
  # Transport/consumer failures are not provider failures. Let the
59
62
  # host retry or report them without recursively broadcasting errors.
60
63
  envelopes.each { |envelope| yield envelope }
64
+ return if writer.terminated?
61
65
  end
62
66
  writer.finish.each { |envelope| yield envelope }
63
67
  end
@@ -107,8 +111,15 @@ module OpenUI
107
111
  @total_bytes = 0
108
112
  @pending = +""
109
113
  @terminated = false
114
+ @started = Instrumentation.now
110
115
  end
111
116
 
117
+ # Whether a completion or error envelope has ended this stream. Stop
118
+ # reading the provider once it is true; later pushes are ignored.
119
+ #
120
+ # @return [Boolean]
121
+ def terminated? = @terminated
122
+
112
123
  # Append a chunk and return a delta once the pending threshold is reached.
113
124
  # A chunk that exceeds the total stream limit returns an error envelope.
114
125
  #
@@ -131,7 +142,8 @@ module OpenUI
131
142
  [ flush_delta ]
132
143
  end
133
144
 
134
- # Flush pending text and emit the completion envelope once.
145
+ # Flush pending text and emit the completion envelope once. A terminal
146
+ # stream.openui_rails notification describes creation, not delivery.
135
147
  #
136
148
  # @return [Array<Envelope>] the final delta, if any, and completion
137
149
  def finish
@@ -144,10 +156,12 @@ module OpenUI
144
156
  )
145
157
  @sequence += 1
146
158
  @terminated = true
159
+ notify_termination("completed")
147
160
  envelopes
148
161
  end
149
162
 
150
163
  # Structured, PII-free error envelope for a provider/consumer failure.
164
+ # Calls `on_error` first; an exception raised by that hook is ignored.
151
165
  #
152
166
  # @param error [Exception]
153
167
  #
@@ -157,7 +171,7 @@ module OpenUI
157
171
  def error_for(error, code: "stream_failed")
158
172
  return nil if @terminated
159
173
 
160
- @on_error&.call(error, message_id: @message_id)
174
+ notify_error(error)
161
175
 
162
176
  message = "stream failed (#{error.class})"
163
177
  message = "stream cancelled" if code == "stream_cancelled"
@@ -184,8 +198,22 @@ module OpenUI
184
198
  @sequence += 1
185
199
  @terminated = true
186
200
  @pending.clear
201
+ notify_termination(code == "stream_cancelled" ? "cancelled" : "failed", code)
187
202
  envelope
188
203
  end
204
+
205
+ # A failing host hook must not suppress the terminal frame or replace
206
+ # the provider error, matching notification subscriber isolation.
207
+ def notify_error(error)
208
+ @on_error&.call(error, message_id: @message_id)
209
+ rescue StandardError
210
+ nil
211
+ end
212
+
213
+ def notify_termination(outcome, code = nil)
214
+ Instrumentation.publish("stream", started: @started, outcome: outcome,
215
+ code: code, sequence: @sequence - 1, bytes: @total_bytes)
216
+ end
189
217
  end
190
218
  end
191
219
  end
@@ -24,6 +24,7 @@ module OpenUI
24
24
  attr_reader :transport, :message_id, :library_version
25
25
 
26
26
  # Broadcast every envelope produced by an enumerable source of chunks.
27
+ # Iteration stops after a terminal envelope, such as a byte-limit error.
27
28
  #
28
29
  # @param source [#each] yields String chunks
29
30
  # @param options [Hash] arguments forwarded to the constructor
@@ -31,7 +32,10 @@ module OpenUI
31
32
  # @return [Broadcaster]
32
33
  def self.broadcast(source, **options)
33
34
  broadcaster = new(**options)
34
- source.each { |chunk| broadcaster.push(chunk) }
35
+ source.each do |chunk|
36
+ broadcaster.push(chunk)
37
+ break if broadcaster.writer.terminated?
38
+ end
35
39
  broadcaster.finish
36
40
  end
37
41
 
@@ -1,5 +1,5 @@
1
1
  module OpenUI
2
2
  module Rails
3
- VERSION = "0.4.0"
3
+ VERSION = "0.9.0"
4
4
  end
5
5
  end
data/lib/openui/rails.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/deprecation"
3
4
  require "openui/rails/version"
4
5
 
5
6
  module OpenUI
@@ -10,10 +11,18 @@ module OpenUI
10
11
  # into a Rails engine and keeps Rails authoritative for auth, tenancy and
11
12
  # persistence.
12
13
  module Rails
14
+ # Deprecator for APIs removed in openui-rails 1.0. The engine registers it
15
+ # in `Rails.application.deprecators`, so host deprecation settings apply.
16
+ #
17
+ # @return [ActiveSupport::Deprecation]
18
+ def self.deprecator
19
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "openui-rails")
20
+ end
13
21
  end
14
22
  end
15
23
 
16
24
  require "openui/rails/errors"
25
+ require "openui/rails/instrumentation"
17
26
  require "openui/rails/json_value"
18
27
  require "openui/rails/library_manifest"
19
28
  require "openui/rails/prompt_builder"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Wang
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -104,6 +104,7 @@ files:
104
104
  - lib/openui/rails/doctor.rb
105
105
  - lib/openui/rails/engine.rb
106
106
  - lib/openui/rails/errors.rb
107
+ - lib/openui/rails/instrumentation.rb
107
108
  - lib/openui/rails/json_value.rb
108
109
  - lib/openui/rails/library_manifest.rb
109
110
  - lib/openui/rails/message.rb