openui-rails 0.3.0 → 0.5.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: 3d3b9295fd6e8fb6d3b284c0c66d80e3d02f01e7d1c09913f02acfd6ff183273
4
- data.tar.gz: 9e5eb58014af64320ea178b05f63843f617f5fe3fa650e083f411a116a12dff2
3
+ metadata.gz: 9a8e938067e723b80e9efc974e2a2bd19878499bdc459c6d675452fa919584fb
4
+ data.tar.gz: 1a0d9ffaa09afef3c185f21154c56bf78ef8141f15bf4bef8fe1562aa47eb68b
5
5
  SHA512:
6
- metadata.gz: bcbbc65b5a3a3d66839d727e325238643973050b39248695e6623b4f45c7fd343ecea7cf52c8c59f70fe6caf63aace284c677d4cd50211ed4fef73e9fc984da7
7
- data.tar.gz: 53147d0d353adf39e448f3ea82ac1c60dd1f7fee9168573d5802b052a3bd54fbd949fd9ac5b8d591ff134cc627c9204feeb07ffd1289304109b5eedd41ea08c0
6
+ metadata.gz: 0161351030bb99e07eb7fa9c7fa968c4921ea436e76da253c6b1827ac3a55650e0dfc6903d737cd1ae94a71b9ae978ae20642e9ea9aec5dd5aa0bd550ff5eb2c
7
+ data.tar.gz: 6b583705522c3871e981289572701a5c0c62bb6c9a8cb7679c26a6af9cac185937d4401b960c4a653442f08ef08b8b552468e42deeaf7ba45bcca5d78e491005
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - Content-free `action.openui_rails` and `stream.openui_rails` notifications with
14
+ monotonic duration, fixed outcomes, replay status, and stream watermarks.
15
+ - Browser `openui:ready` and `openui:snapshot-applied` events for host-owned
16
+ fallback and recovery, plus stable runtime and network failure codes.
17
+ - Ordinary HTML fallback and explicit reload, scoped snapshot, and fresh sign-in
18
+ controls in the reference host, with keyboard and Turbo restoration checks.
19
+
20
+ ### Fixed
21
+
22
+ - Late action responses cannot report into a reconnected surface. Failed
23
+ runtime scripts are removed so later mounts can retry loading.
24
+ - Unmounted renderer callbacks cannot send actions, tool requests, state, or
25
+ errors through a new connection. A late snapshot cannot dismiss a host's
26
+ authentication or permission failure.
27
+ - Browser failures use events without automatically logging raw error objects.
28
+
29
+ ## [0.4.0] - 2026-10-20
30
+
31
+ ### Added
32
+
33
+ - Optional `Actions::Idempotency::ActiveRecordStore` with atomic database claims,
34
+ ownership fencing, JSON result persistence, and explicit expiry cleanup.
35
+ SQLite is the verified database; memory remains the default.
36
+ - `openui:idempotency` generates the opt-in table and its unique key and expiry
37
+ indexes. Installing the engine alone creates no shared-store table.
38
+ - Reusable Minitest store-conformance checks, independent-process SQLite tests,
39
+ and a fresh-host migration and restart check against the built gem.
40
+
41
+ ### Changed
42
+
43
+ - The support-desk reference uses the shared store while retaining its business
44
+ uniqueness constraint. A shared claim does not guarantee exactly-once effects.
45
+
9
46
  ## [0.3.0] - 2026-10-19
10
47
 
11
48
  ### Added
@@ -116,7 +153,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
116
153
  - Gem-scoped RubyGems Trusted Publishing through GitHub OIDC, with a protected
117
154
  release environment and verified artifact handoff.
118
155
 
119
- [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.3.0...main
156
+ [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.5.0...main
157
+ [0.5.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.5.0
158
+ [0.4.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.4.0
120
159
  [0.3.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.3.0
121
160
  [0.2.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.2.0
122
161
  [0.1.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.1.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.3.0.gem
28
+ bundle exec ruby bin/smoke-package openui-rails-0.5.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.3.0.gem --runtime tmp/real-runtime
20
+ bin/check-reference --gem tmp/package/openui-rails-0.5.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
@@ -22,8 +22,8 @@ or renderer.
22
22
  - One allowlisted action endpoint with authorization, validation, and replay protection.
23
23
  - An optional RubyLLM streaming adapter.
24
24
 
25
- Your app supplies scoped record lookup, stream subscriptions, snapshot recovery,
26
- and shared idempotency storage when running multiple processes.
25
+ Your app supplies scoped record lookup, stream subscriptions, and snapshot
26
+ recovery. Multi-process apps can opt into the SQLite shared idempotency store.
27
27
 
28
28
  ## Get started
29
29
 
@@ -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.3.0"
35
+ gem "openui-rails", "~> 0.5.0"
36
36
  ```
37
37
 
38
38
  ```sh
@@ -65,15 +65,15 @@ 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.3.0` adds a persisted reference workflow and stream restoration.
69
- See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-3/) for the upgrade.
68
+ Version `0.5.0` adds outcome diagnostics and host-owned recovery examples.
69
+ See [What's New](https://openui-rails.wangyaodi.com/whats-new-in-0-5/) for the upgrade.
70
70
  The API may change during `0.x`; see the
71
71
  [changelog](CHANGELOG.md) and the proposed [path to 1.0](ROADMAP.md).
72
72
  The site's stable channel follows the latest release; [/next/](https://openui-rails.wangyaodi.com/next/)
73
73
  follows `main`.
74
74
 
75
75
  For repository work, use [Contributing](CONTRIBUTING.md),
76
- [Releasing](RELEASING.md), and the [0.2.0 release record](RELEASE_0_2.md).
76
+ [Releasing](RELEASING.md), and the [0.4.0 release record](RELEASE_0_4.md).
77
77
  Report vulnerabilities through the [security policy](SECURITY.md).
78
78
 
79
79
  ## License
@@ -54,14 +54,7 @@ module OpenUI
54
54
  end
55
55
 
56
56
  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
57
+ Instrumentation.action_error_code(error)
65
58
  end
66
59
 
67
60
  def error_status(error)
@@ -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))
@@ -80,6 +82,8 @@ export default class extends Controller {
80
82
  this.abortController?.abort()
81
83
  this.root?.unmount()
82
84
  this.root = null
85
+ this.runtime = null
86
+ this.tools = null
83
87
  this.mountPoint?.remove()
84
88
  this.mountPoint = null
85
89
  }
@@ -88,11 +92,11 @@ export default class extends Controller {
88
92
  try {
89
93
  const runtime = await loadRuntime(this.runtimeUrl, this.runtimeIntegrity)
90
94
  await this.verifyLibrary(runtime.openuiChatLibrary)
91
- if (connection !== this.connection || !this.element.isConnected || !this.mountPoint) return
95
+ if (!this.isCurrent(connection) || !this.mountPoint) return
92
96
  this.runtime = runtime
93
- this.render()
97
+ if (this.render()) this.dispatch("ready", { surfaceId: this.surfaceId })
94
98
  } catch (error) {
95
- if (connection !== this.connection) return
99
+ if (!this.isCurrent(connection)) return
96
100
  this.state.streaming = false
97
101
  this.syncBusy()
98
102
  this.handleError(error)
@@ -104,6 +108,7 @@ export default class extends Controller {
104
108
  if (!this.runtime || !this.mountPoint || !this.connection) return
105
109
  if (!this.withinByteLimit(this.state.source)) return
106
110
  const { React, createRoot, Renderer, openuiChatLibrary } = this.runtime
111
+ const connection = this.connection
107
112
  if (!this.root) this.root = createRoot(this.mountPoint)
108
113
  this.root.render(
109
114
  React.createElement(Renderer, {
@@ -115,12 +120,13 @@ export default class extends Controller {
115
120
  initialState: this.initialState,
116
121
  // The official runtime emits { type, params }. It does not await this
117
122
  // void callback; dispatchAction already emits a structured error.
118
- onAction: (action) => { this.dispatchAction(action).catch(() => {}) },
119
- onStateUpdate: (formState) => this.persistState(formState),
123
+ onAction: (action) => { if (this.isCurrent(connection)) this.dispatchAction(action).catch(() => {}) },
124
+ onStateUpdate: (formState) => { if (this.isCurrent(connection)) this.persistState(formState) },
120
125
  toolProvider: this.toolProvider(),
121
- onError: (error) => this.handleError(error),
126
+ onError: (error) => { if (this.isCurrent(connection)) this.handleError(error) },
122
127
  })
123
128
  )
129
+ return true
124
130
  }
125
131
 
126
132
  async verifyLibrary(library) {
@@ -203,6 +209,7 @@ export default class extends Controller {
203
209
  this.state.streaming = envelope.streaming === true
204
210
  this.state.completed = !this.state.streaming
205
211
  this.render()
212
+ this.dispatch("snapshot-applied", { surfaceId: this.surfaceId, sequence: envelope.sequence })
206
213
  }
207
214
 
208
215
  requestSnapshot() {
@@ -224,6 +231,8 @@ export default class extends Controller {
224
231
  // --- Actions -------------------------------------------------------------
225
232
 
226
233
  async dispatchAction(payload = {}) {
234
+ const connection = this.connection
235
+ if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
227
236
  const body = {
228
237
  action: payload.action ?? payload.name ?? payload.type,
229
238
  args: this.actionArguments(payload),
@@ -241,6 +250,7 @@ export default class extends Controller {
241
250
  headers: this.headers(),
242
251
  body: JSON.stringify(body),
243
252
  })
253
+ if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
244
254
  if (response.redirected) {
245
255
  throw Object.assign(new Error("Action request was redirected; authentication may have expired"), { code: "unauthorized" })
246
256
  }
@@ -253,8 +263,9 @@ export default class extends Controller {
253
263
  } catch {
254
264
  throw Object.assign(new Error("Action endpoint returned malformed JSON"), { code: "invalid_response" })
255
265
  }
266
+ if (!this.isCurrent(connection)) throw new DOMException("Surface disconnected", "AbortError")
256
267
  if (!response.ok) {
257
- throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code })
268
+ throw Object.assign(new Error(json?.error?.message || "action failed"), { code: json?.error?.code || "action_failed" })
258
269
  }
259
270
  const result = json?.result
260
271
  if (!result || result.action !== body.action || result.surface_id !== body.surface_id || result.version !== body.version) {
@@ -263,7 +274,7 @@ export default class extends Controller {
263
274
  this.dispatch("action", { surfaceId: this.surfaceId, result })
264
275
  return result
265
276
  } catch (error) {
266
- if (error.name !== "AbortError" || this.connection) this.handleError(error)
277
+ if (this.isCurrent(connection)) this.handleError(error, error.name === "AbortError" ? "request_cancelled" : "network_error")
267
278
  throw error
268
279
  }
269
280
  }
@@ -284,6 +295,7 @@ export default class extends Controller {
284
295
  // Every browser `Query()`/`Mutation()` tool call is routed to the same
285
296
  // server endpoint; the host registry decides what is permitted.
286
297
  toolProvider() {
298
+ const connection = this.connection
287
299
  const reserved = new Set([ "callTool", "then", "__proto__", "constructor", "prototype", "toJSON" ])
288
300
  return this.tools ||= new Proxy(
289
301
  {},
@@ -291,7 +303,10 @@ export default class extends Controller {
291
303
  has: (_target, name) => typeof name === "string" && !reserved.has(name),
292
304
  get: (_target, name) => {
293
305
  if (typeof name !== "string" || reserved.has(name)) return undefined
294
- return (args) => this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
306
+ return (args) => {
307
+ if (!this.isCurrent(connection)) return Promise.reject(new DOMException("Surface disconnected", "AbortError"))
308
+ return this.dispatchAction({ action: name, args: args ?? {} }).then((result) => result.value)
309
+ }
295
310
  },
296
311
  }
297
312
  )
@@ -310,17 +325,19 @@ export default class extends Controller {
310
325
  this.element.setAttribute("aria-busy", this.state.streaming ? "true" : "false")
311
326
  }
312
327
 
313
- handleError(error) {
328
+ handleError(error, fallbackCode = "renderer_error") {
314
329
  // Renderer#onError receives an array, including [] after successful parses.
315
330
  if (Array.isArray(error)) {
316
331
  if (error.length === 0) return
317
332
  error = Object.assign(new Error(error.map((entry) => entry.message || String(entry)).join("; ")),
318
333
  { code: "renderer_error" })
319
334
  }
320
- console.error("[openui-rails]", error)
321
- this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message, code: error?.code })
335
+ this.dispatch("error", { surfaceId: this.surfaceId, message: error?.message,
336
+ code: typeof error?.code === "string" ? error.code : fallbackCode })
322
337
  }
323
338
 
339
+ isCurrent(connection) { return !!connection && connection === this.connection && this.element.isConnected }
340
+
324
341
  headers() {
325
342
  const token = document.querySelector("meta[name='csrf-token']")?.content
326
343
  const headers = { "Content-Type": "application/json", Accept: "application/json" }
@@ -2,14 +2,21 @@
2
2
 
3
3
  This opt-in reference belongs to the host application, outside the engine's
4
4
  public API. It uses synthetic accounts, recorded generation, the real pinned
5
- OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams.
5
+ OpenUI runtime, SQLite, importmap, and HTTP Turbo Streams. It opts into the
6
+ Active Record idempotency store and retains a unique business receipt for closing
7
+ a ticket. Shared claims do not replace that database constraint.
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.
6
13
 
7
14
  From the repository:
8
15
 
9
16
  ```sh
10
17
  mkdir -p tmp/package
11
- bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.3.0.gem
12
- bin/check-reference --gem tmp/package/openui-rails-0.3.0.gem --runtime tmp/real-runtime
18
+ bundle exec gem build openui-rails.gemspec --output tmp/package/openui-rails-0.5.0.gem
19
+ bin/check-reference --gem tmp/package/openui-rails-0.5.0.gem --runtime tmp/real-runtime
13
20
  ```
14
21
 
15
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>
@@ -8,6 +8,7 @@ OpenUI::Rails.configure do |config|
8
8
  config.surface_resolver = ->(id, actor:, **) {
9
9
  SupportDesk::Conversation.find_by(id: id, user_id: actor.id, tenant_id: actor.tenant_id) if actor
10
10
  }
11
+ config.idempotency_store = OpenUI::Rails::Actions::Idempotency::ActiveRecordStore.new
11
12
  config.idempotency_scope = ->(invocation) { [ invocation.actor.tenant_id, invocation.actor.id ] }
12
13
  config.authorize_action = ->(invocation) {
13
14
  actor, conversation = invocation.actor, invocation.surface
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module OpenUI
6
+ # Generate the opt-in shared action-claim table without changing configuration.
7
+ class IdempotencyGenerator < ::ActiveRecord::Generators::Base
8
+ namespace "openui:idempotency"
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Create the optional OpenUI shared idempotency store migration."
11
+ # This generator has no model-name argument.
12
+ argument :name, type: :string, default: "openui_action_claims", hide: true
13
+
14
+ # Write the optional, reversible host migration.
15
+ def create_store_migration
16
+ migration_template "create_openui_action_claims.rb", "db/migrate/create_openui_action_claims.rb"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateOpenUIActionClaims < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :openui_action_claims do |t|
6
+ t.string :key, null: false
7
+ t.string :digest
8
+ t.string :claim_token, null: false
9
+ t.boolean :completed, null: false, default: false
10
+ t.text :result_json
11
+ t.datetime :expires_at, null: false, precision: 6
12
+ end
13
+ add_index :openui_action_claims, :key, unique: true
14
+ add_index :openui_action_claims, :expires_at
15
+ end
16
+ end
@@ -14,6 +14,10 @@ OpenUI::Rails.configure do |config|
14
14
  config.runtime_assets = :self_hosted
15
15
  # config.runtime_integrity = "sha384-..."
16
16
 
17
+ # Optional shared SQLite claims: first run openui:idempotency and db:migrate.
18
+ # See the Shared Idempotency Storage guide for JSON results and crash limits.
19
+ # config.idempotency_store = OpenUI::Rails::Actions::Idempotency::ActiveRecordStore.new
20
+
17
21
  # Authorize every action/tool call. This fails closed: with no authorizer an
18
22
  # action is denied. Return truthy to permit.
19
23
  # config.authorize_action = ->(invocation) do
@@ -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
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module OpenUI
8
+ module Rails
9
+ module Actions
10
+ module Idempotency
11
+ # Optional shared action store. Verified with SQLite on Rails 8.0/8.1.
12
+ # Install its table with `rails generate openui:idempotency` and db:migrate.
13
+ # JSON values replay across workers; object keys become strings. TTL runs
14
+ # from claim creation, not completion. Never assumes exactly-once effects.
15
+ class ActiveRecordStore
16
+ class Entry < ::ActiveRecord::Base # :nodoc:
17
+ self.table_name = "openui_action_claims"
18
+ end
19
+ private_constant :Entry
20
+
21
+ # @param model [Class] dedicated Active Record model, optionally using another database
22
+ # @param clock [#call] shared wall clock returning UTC Time values
23
+ def initialize(model: Entry, clock: -> { Time.now.utc })
24
+ @model = model
25
+ @clock = clock
26
+ end
27
+
28
+ # Atomically insert or replace an expired claim. Returns the same
29
+ # [status, result, stored_digest, token] contract as MemoryStore.
30
+ # A changed digest is returned to the dispatcher, which rejects it.
31
+ def claim(key, ttl:, digest: nil)
32
+ raise ConfigurationError, "idempotency ttl must be positive" unless ttl.is_a?(Numeric) && ttl.finite? && ttl > 0
33
+
34
+ loop do
35
+ now = @clock.call
36
+ attributes = { key: key, digest: digest, claim_token: SecureRandom.hex(16),
37
+ completed: false, result_json: nil, expires_at: now + ttl }
38
+ inserted = @model.insert_all([ attributes ], unique_by: :key, returning: %w[id])
39
+ return [ :claimed, nil, digest, attributes[:claim_token] ] if inserted.any?
40
+
41
+ replaced = @model.where(key: key).where("expires_at <= ?", now).update_all(attributes.except(:key))
42
+ return [ :claimed, nil, digest, attributes[:claim_token] ] if replaced == 1
43
+
44
+ # Cleanup can delete the expired row between SQL statements.
45
+ entry = @model.find_by(key: key)
46
+ next unless entry
47
+
48
+ value = entry.completed ? JSON.parse(entry.result_json) : nil
49
+ return [ entry.completed ? :replay : :pending, value, entry.digest, entry.claim_token ]
50
+ end
51
+ end
52
+
53
+ # Record only while this token owns a live claim. JSON encoding rejects
54
+ # unsupported Ruby objects before writing; it never invokes Marshal.
55
+ def record(key, result, claim_token:)
56
+ json = JSON.generate(JsonValue.copy(result))
57
+ @model.where(key: key, claim_token: claim_token).where("expires_at > ?", @clock.call)
58
+ .update_all(result_json: json, completed: true)
59
+ nil
60
+ rescue ArgumentError, JSON::GeneratorError => error
61
+ raise ConfigurationError, "idempotency result must be a JSON value: #{error.message}"
62
+ end
63
+
64
+ # Delete expired claims, returning the number removed. Call from a host
65
+ # maintenance task; expiry of the requested key never needs this sweep.
66
+ def delete_expired
67
+ @model.where("expires_at <= ?", @clock.call).delete_all
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -7,8 +7,8 @@ module OpenUI
7
7
  module Actions
8
8
  module Idempotency
9
9
  # Default, single-process idempotency store. Hosts that run multiple
10
- # processes should inject a shared store (e.g. Redis or the database)
11
- # that implements the same contract.
10
+ # processes can opt into ActiveRecordStore on SQLite
11
+ # or inject another shared adapter implementing the same contract.
12
12
  #
13
13
  # Contract:
14
14
  # claim(key, ttl:, digest:) -> [status, result, digest, claim_token]
@@ -13,6 +13,9 @@ module OpenUI
13
13
  # a host authorizer, stale-surface protection and idempotency, shared by
14
14
  # both component actions and browser tool calls.
15
15
  module Actions
16
+ module Idempotency
17
+ autoload :ActiveRecordStore, "openui/rails/actions/idempotency/active_record_store"
18
+ end
16
19
  end
17
20
  end
18
21
  end
@@ -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
@@ -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
@@ -107,6 +109,7 @@ module OpenUI
107
109
  @total_bytes = 0
108
110
  @pending = +""
109
111
  @terminated = false
112
+ @started = Instrumentation.now
110
113
  end
111
114
 
112
115
  # Append a chunk and return a delta once the pending threshold is reached.
@@ -131,7 +134,8 @@ module OpenUI
131
134
  [ flush_delta ]
132
135
  end
133
136
 
134
- # Flush pending text and emit the completion envelope once.
137
+ # Flush pending text and emit the completion envelope once. A terminal
138
+ # stream.openui_rails notification describes creation, not delivery.
135
139
  #
136
140
  # @return [Array<Envelope>] the final delta, if any, and completion
137
141
  def finish
@@ -144,6 +148,7 @@ module OpenUI
144
148
  )
145
149
  @sequence += 1
146
150
  @terminated = true
151
+ notify_termination("completed")
147
152
  envelopes
148
153
  end
149
154
 
@@ -184,8 +189,14 @@ module OpenUI
184
189
  @sequence += 1
185
190
  @terminated = true
186
191
  @pending.clear
192
+ notify_termination(code == "stream_cancelled" ? "cancelled" : "failed", code)
187
193
  envelope
188
194
  end
195
+
196
+ def notify_termination(outcome, code = nil)
197
+ Instrumentation.publish("stream", started: @started, outcome: outcome,
198
+ code: code, sequence: @sequence - 1, bytes: @total_bytes)
199
+ end
189
200
  end
190
201
  end
191
202
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Testing
6
+ # Reusable Minitest contract for idempotency adapters. Include this module
7
+ # in a test case that defines build_store and advance_store_clock(seconds).
8
+ # Each test needs an empty store and a controllable shared clock.
9
+ module IdempotencyStoreContract
10
+ def test_claim_distinguishes_pending_from_completed
11
+ store = build_store
12
+ status, value, digest, token = store.claim("key", ttl: 60, digest: "original")
13
+ assert_equal :claimed, status
14
+ assert_nil value
15
+ assert_equal "original", digest
16
+ refute_empty token
17
+ assert_equal [ :pending, nil, "original", token ], store.claim("key", ttl: 60, digest: "changed")
18
+ store.record("key", "saved", claim_token: token)
19
+ assert_equal [ :replay, "saved", "original", token ], store.claim("key", ttl: 60, digest: "changed")
20
+ end
21
+
22
+ def test_completed_nil_and_false_replay
23
+ store = build_store
24
+ [ nil, false ].each_with_index do |value, index|
25
+ key = "nullable-#{index}"
26
+ token = store.claim(key, ttl: 60, digest: "d").last
27
+ store.record(key, value, claim_token: token)
28
+ assert_equal [ :replay, value, "d", token ], store.claim(key, ttl: 60, digest: "d")
29
+ end
30
+ end
31
+
32
+ def test_json_results_round_trip
33
+ store = build_store
34
+ value = { "list" => [ 1, 2.5, true, false, nil, "你好" ], "nested" => { "ok" => true } }
35
+ token = store.claim("key", ttl: 60, digest: "d").last
36
+ store.record("key", value, claim_token: token)
37
+ assert_equal value, store.claim("key", ttl: 60, digest: "d")[1]
38
+ end
39
+
40
+ def test_expired_owner_cannot_record
41
+ store = build_store
42
+ token = store.claim("key", ttl: 10, digest: "old").last
43
+ advance_store_clock(10)
44
+ store.record("key", "late", claim_token: token)
45
+ status, value, _, replacement = store.claim("key", ttl: 60, digest: "new")
46
+ assert_equal :claimed, status
47
+ assert_nil value
48
+ refute_equal token, replacement
49
+ end
50
+
51
+ def test_replaced_owner_cannot_overwrite
52
+ store = build_store
53
+ old = store.claim("key", ttl: 10, digest: "old").last
54
+ advance_store_clock(11)
55
+ token = store.claim("key", ttl: 60, digest: "new").last
56
+ store.record("key", "new result", claim_token: token)
57
+ store.record("key", "old result", claim_token: old)
58
+ assert_equal [ :replay, "new result", "new", token ], store.claim("key", ttl: 60, digest: "new")
59
+ end
60
+
61
+ def test_wrong_token_and_unknown_key_do_not_complete_a_claim
62
+ store = build_store
63
+ token = store.claim("key", ttl: 60, digest: "d").last
64
+ store.record("missing", "value", claim_token: token)
65
+ store.record("key", "value", claim_token: "wrong")
66
+ assert_equal :pending, store.claim("key", ttl: 60, digest: "d").first
67
+ assert_equal :claimed, store.claim("missing", ttl: 60, digest: "d").first
68
+ end
69
+
70
+ def test_ttl_must_be_positive_and_finite
71
+ store = build_store
72
+ [ 0, -1, Float::INFINITY, Float::NAN, "60", nil ].each do |ttl|
73
+ assert_raises(OpenUI::Rails::ConfigurationError) { store.claim("key", ttl: ttl, digest: "d") }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  module OpenUI
2
2
  module Rails
3
- VERSION = "0.3.0"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
data/lib/openui/rails.rb CHANGED
@@ -14,6 +14,7 @@ module OpenUI
14
14
  end
15
15
 
16
16
  require "openui/rails/errors"
17
+ require "openui/rails/instrumentation"
17
18
  require "openui/rails/json_value"
18
19
  require "openui/rails/library_manifest"
19
20
  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.3.0
4
+ version: 0.5.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-19 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
@@ -80,12 +80,15 @@ files:
80
80
  - examples/support_desk/config/routes.rb
81
81
  - examples/support_desk/db/migrate/20261019000000_create_support_desk.rb
82
82
  - examples/support_desk/install.rb
83
+ - lib/generators/openui/idempotency/idempotency_generator.rb
84
+ - lib/generators/openui/idempotency/templates/create_openui_action_claims.rb
83
85
  - lib/generators/openui/install/install_generator.rb
84
86
  - lib/generators/openui/install/templates/config/initializers/openui.rb.tt
85
87
  - lib/generators/openui/install/templates/config/openui/README.md
86
88
  - lib/openui/rails.rb
87
89
  - lib/openui/rails/actions.rb
88
90
  - lib/openui/rails/actions/dispatcher.rb
91
+ - lib/openui/rails/actions/idempotency/active_record_store.rb
89
92
  - lib/openui/rails/actions/idempotency/memory_store.rb
90
93
  - lib/openui/rails/actions/invocation.rb
91
94
  - lib/openui/rails/actions/registry.rb
@@ -101,6 +104,7 @@ files:
101
104
  - lib/openui/rails/doctor.rb
102
105
  - lib/openui/rails/engine.rb
103
106
  - lib/openui/rails/errors.rb
107
+ - lib/openui/rails/instrumentation.rb
104
108
  - lib/openui/rails/json_value.rb
105
109
  - lib/openui/rails/library_manifest.rb
106
110
  - lib/openui/rails/message.rb
@@ -116,6 +120,7 @@ files:
116
120
  - lib/openui/rails/stream/transports/memory.rb
117
121
  - lib/openui/rails/stream/transports/turbo_streams.rb
118
122
  - lib/openui/rails/surface.rb
123
+ - lib/openui/rails/testing/idempotency_store_contract.rb
119
124
  - lib/openui/rails/version.rb
120
125
  - lib/openui/rails/view_helper.rb
121
126
  - lib/tasks/openui.rake