openui-rails 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/DEVELOPMENT.md +21 -0
  5. data/README.md +6 -5
  6. data/RELEASING.md +12 -0
  7. data/app/javascript/openui/rails/openui_controller.js +14 -1
  8. data/examples/support_desk/README.md +20 -0
  9. data/examples/support_desk/app/controllers/support_desk/base_controller.rb +37 -0
  10. data/examples/support_desk/app/controllers/support_desk/conversations_controller.rb +47 -0
  11. data/examples/support_desk/app/controllers/support_desk/sessions_controller.rb +19 -0
  12. data/examples/support_desk/app/javascript/support_desk.js +109 -0
  13. data/examples/support_desk/app/models/support_desk/conversation.rb +108 -0
  14. data/examples/support_desk/app/models/support_desk/demo.rb +15 -0
  15. data/examples/support_desk/app/models/support_desk/receipt.rb +6 -0
  16. data/examples/support_desk/app/models/support_desk/ticket.rb +6 -0
  17. data/examples/support_desk/app/models/support_desk/user.rb +6 -0
  18. data/examples/support_desk/app/views/layouts/support_desk/application.html.erb +18 -0
  19. data/examples/support_desk/app/views/support_desk/conversations/index.html.erb +6 -0
  20. data/examples/support_desk/app/views/support_desk/conversations/show.html.erb +16 -0
  21. data/examples/support_desk/boot.rb +21 -0
  22. data/examples/support_desk/config/importmap.rb +4 -0
  23. data/examples/support_desk/config/initializers/openui.rb +33 -0
  24. data/examples/support_desk/config/routes.rb +12 -0
  25. data/examples/support_desk/db/migrate/20261019000000_create_support_desk.rb +33 -0
  26. data/examples/support_desk/install.rb +15 -0
  27. data/lib/generators/openui/idempotency/idempotency_generator.rb +19 -0
  28. data/lib/generators/openui/idempotency/templates/create_openui_action_claims.rb +16 -0
  29. data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +4 -0
  30. data/lib/openui/rails/actions/idempotency/active_record_store.rb +73 -0
  31. data/lib/openui/rails/actions/idempotency/memory_store.rb +2 -2
  32. data/lib/openui/rails/actions.rb +3 -0
  33. data/lib/openui/rails/surface.rb +22 -4
  34. data/lib/openui/rails/testing/idempotency_store_contract.rb +79 -0
  35. data/lib/openui/rails/version.rb +1 -1
  36. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5999111a7f7f05fa709fd9e247a29d54cb817e07d6152caeac3413ac0eb82655
4
- data.tar.gz: 530bcdc1b2c261e49cb8db318f330dc9c31dc7d9fbb9b7e9cc48d8ead04edd2b
3
+ metadata.gz: 1fd5862a42a9eaf7c669e6e94fa38d8aaac1784e4f7efe5340e9cec48756902a
4
+ data.tar.gz: f7ada4e9693c3de0c0d13b05a7cc7692a44b402b291721d430038a68c6047f4c
5
5
  SHA512:
6
- metadata.gz: 4018f00dba918786dc8d3addbb96209fe055c09b84bf1bbae7f595fad3f114cd25a58b54ef0cccb0bac349865871ba98b43fd7fab240448d3639582d7d84b9d3
7
- data.tar.gz: ae6065874f65fe3e37e4133322bf698c4c90a74e4126b2efcc4f3c94df51dac927ea89ec403159d466a84f348d67d29b0a70345b4642f7b08dae64690d07b5f9
6
+ metadata.gz: 0dc08e62c9508a13e11fa3d4995717d6b88be85458d505da3c363bccbfa1b57a1017bc59d72d667ed3d8043eb68f1005cd18b77bc87bae3ac9df0d2fe4683017
7
+ data.tar.gz: 524eb2239e400a15e6e0dee14d98b5d71e0a6f94866e4bfafda48917ea17f55a18a9eda9126cb13d7a7e4e149bd3ecfaa4f755ebac32f4ba2ca99d9b18db1d01
data/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - Optional `Actions::Idempotency::ActiveRecordStore` with atomic database claims,
14
+ ownership fencing, JSON result persistence, and explicit expiry cleanup.
15
+ SQLite is the verified database; memory remains the default.
16
+ - `openui:idempotency` generates the opt-in table and its unique key and expiry
17
+ indexes. Installing the engine alone creates no shared-store table.
18
+ - Reusable Minitest store-conformance checks, independent-process SQLite tests,
19
+ and a fresh-host migration and restart check against the built gem.
20
+
21
+ ### Changed
22
+
23
+ - The support-desk reference uses the shared store while retaining its business
24
+ uniqueness constraint. A shared claim does not guarantee exactly-once effects.
25
+
26
+ ## [0.3.0] - 2026-10-19
27
+
28
+ ### Added
29
+
30
+ - A packaged, opt-in support-ticket reference host with SQLite persistence,
31
+ importmap, HTTP Turbo Streams, real Query/Mutation calls, host-owned
32
+ conversation continuation, and scoped snapshot recovery.
33
+ - `bin/check-reference` verifies the maintained host or a fresh host using only
34
+ the built artifact. Browser checks cover reload, lost envelopes, expired
35
+ identity, cross-tenant access, read-only roles, stale/duplicate actions, CSRF,
36
+ keyboard activation, and mobile layout. The reference host enables CSRF
37
+ itself, including during the documented test-environment review session.
38
+ - `bin/check-release --candidate` validates unpublished candidate metadata
39
+ without weakening the publication gate.
40
+ - `Surface` and `openui_surface` accept persisted `next_sequence` and
41
+ `completed` values to restore stream ordering after a page reload.
42
+
43
+ ### Fixed
44
+
45
+ - Conversation actions preserve the official runtime's user message, form name,
46
+ and form state as validated-by-host action arguments. Strict
47
+ `continue_conversation` schemas need to allow the metadata they consume.
48
+
9
49
  ## [0.2.0] - 2026-10-18
10
50
 
11
51
  ### Added
@@ -93,6 +133,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
93
133
  - Gem-scoped RubyGems Trusted Publishing through GitHub OIDC, with a protected
94
134
  release environment and verified artifact handoff.
95
135
 
96
- [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.2.0...main
136
+ [Unreleased]: https://github.com/yorzi/openui-rails/compare/v0.4.0...main
137
+ [0.4.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.4.0
138
+ [0.3.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.3.0
97
139
  [0.2.0]: https://github.com/yorzi/openui-rails/releases/tag/v0.2.0
98
140
  [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.2.0.gem
28
+ bundle exec ruby bin/smoke-package openui-rails-0.4.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
@@ -12,6 +12,27 @@ authorizes a live call. Follow this loop for each feature:
12
12
  5. Commit a completed unit with its actual verification results. Stop any
13
13
  task-owned service before finishing.
14
14
 
15
+ ## Reference workflow
16
+
17
+ ```sh
18
+ bin/check-reference
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
21
+ # Focus one browser scenario:
22
+ bin/check-reference --runtime tmp/real-runtime --name test_a_lost_turbo_envelope_recovers_from_the_scoped_snapshot
23
+ ```
24
+
25
+ The first command verifies reference-host transactions in a temporary SQLite
26
+ database. The browser option uses the real renderer and screenshot evidence.
27
+ The gem option generates an isolated consumer using the example packaged in
28
+ that artifact; it imports no dummy-app fixtures. `bin/check --runtime` includes
29
+ this consumer flow. CI runs the host transaction tests in its Ruby/Rails matrix
30
+ and the artifact browser flow in the system job.
31
+
32
+ Add `--host tmp/support-desk` with `--gem` to retain a synthetic review host.
33
+ It refuses existing directories and starts no persistent service. Follow the
34
+ site's Reference Workflow guide for its explicit loopback review command.
35
+
15
36
  ## Checks
16
37
 
17
38
  ```sh
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.2.0"
35
+ gem "openui-rails", "~> 0.4.0"
36
36
  ```
37
37
 
38
38
  ```sh
@@ -65,14 +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.2.0` adds default-library preparation and setup diagnostics.
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.
69
70
  The API may change during `0.x`; see the
70
71
  [changelog](CHANGELOG.md) and the proposed [path to 1.0](ROADMAP.md).
71
72
  The site's stable channel follows the latest release; [/next/](https://openui-rails.wangyaodi.com/next/)
72
73
  follows `main`.
73
74
 
74
75
  For repository work, use [Contributing](CONTRIBUTING.md),
75
- [Releasing](RELEASING.md), and the dated [0.1.0 audit](AUDIT.md).
76
+ [Releasing](RELEASING.md), and the [0.3.0 release record](RELEASE_0_3.md).
76
77
  Report vulnerabilities through the [security policy](SECURITY.md).
77
78
 
78
79
  ## License
data/RELEASING.md CHANGED
@@ -34,6 +34,13 @@ and a 16-token output cap. See [AUDIT.md](AUDIT.md) for the result and limits.
34
34
 
35
35
  ## Prepare the candidate
36
36
 
37
+ During development, keep `released: false`, document delivered work under
38
+ `Unreleased`, and run `ruby bin/check-release --candidate`. This confirms
39
+ candidate metadata without authorizing or satisfying the publication gate.
40
+ The candidate must pass the full verification and built-gem reference flow.
41
+ After publication is authorized, complete the release metadata steps below.
42
+
43
+
37
44
  1. Set the version in `lib/openui/rails/version.rb`.
38
45
  2. Add a dated `## [x.y.z] - YYYY-MM-DD` changelog section.
39
46
  Set the same version and `released: true` in `docs/_config.yml`, update
@@ -86,6 +93,11 @@ and both channels include the matching setup and upgrading guides. Record
86
93
  the workflow, published gem checksum, and hosted verification in a dated
87
94
  release record. Keep earlier local validation evidence unchanged.
88
95
 
96
+ A successful Pages job alone does not prove the hosted version changed.
97
+ If the stable site still serves the previous release, compare its contents with
98
+ the build log. Push a documentation update or rerun the docs workflow, then
99
+ verify the actual installation command and version selector again.
100
+
89
101
  ## Recovery
90
102
 
91
103
  Prefer a patch release for a defect. If a published version is unsafe, the owner
@@ -226,7 +226,7 @@ export default class extends Controller {
226
226
  async dispatchAction(payload = {}) {
227
227
  const body = {
228
228
  action: payload.action ?? payload.name ?? payload.type,
229
- args: payload.args ?? payload.params ?? {},
229
+ args: this.actionArguments(payload),
230
230
  surface_id: this.surfaceId,
231
231
  version: this.version,
232
232
  idempotency_key: payload.idempotency_key ?? this.newIdempotencyKey(),
@@ -268,6 +268,19 @@ export default class extends Controller {
268
268
  }
269
269
  }
270
270
 
271
+ // The official renderer places conversation metadata beside params. Keep
272
+ // it in the allowlisted action's args so the host can validate and persist it.
273
+ actionArguments(payload) {
274
+ const args = payload.args ?? payload.params ?? {}
275
+ if ((payload.action ?? payload.name ?? payload.type) !== "continue_conversation") return args
276
+ if (typeof args !== "object" || Array.isArray(args)) return args
277
+ const metadata = {}
278
+ if (payload.humanFriendlyMessage !== undefined) metadata.message = payload.humanFriendlyMessage
279
+ if (payload.formState !== undefined) metadata.form_state = payload.formState
280
+ if (payload.formName !== undefined) metadata.form_name = payload.formName
281
+ return { ...args, ...metadata }
282
+ }
283
+
271
284
  // Every browser `Query()`/`Mutation()` tool call is routed to the same
272
285
  // server endpoint; the host registry decides what is permitted.
273
286
  toolProvider() {
@@ -0,0 +1,20 @@
1
+ # Support Desk reference host
2
+
3
+ This opt-in reference belongs to the host application, outside the engine's
4
+ public API. It uses synthetic accounts, recorded generation, the real pinned
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
+ From the repository:
10
+
11
+ ```sh
12
+ 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
15
+ ```
16
+
17
+ Fetch the runtime first. See the [complete guide](https://openui-rails.wangyaodi.com/reference-workflow/)
18
+ for setup, retained-host review, failure checks, and evidence limits. The
19
+ reference is not enabled by requiring the gem. Keep the demo loopback-only;
20
+ replace its synthetic login before adapting it to a real application.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class BaseController < ::ApplicationController
5
+ protect_from_forgery with: :exception
6
+ layout "support_desk/application"
7
+ before_action :local_demo_only
8
+ before_action :authenticate
9
+ rescue_from ActiveRecord::RecordNotFound do
10
+ render json: { error: { code: "not_found", message: "inaccessible conversation" } }, status: :not_found
11
+ end
12
+ rescue_from OpenUI::Rails::Error do |error|
13
+ status = error.is_a?(OpenUI::Rails::StaleSurfaceError) ? :conflict : :unprocessable_content
14
+ code = error.is_a?(OpenUI::Rails::StaleSurfaceError) ? "stale_surface" : "invalid_state"
15
+ render json: { error: { code: code, message: "request rejected" } }, status: status
16
+ end
17
+
18
+ private
19
+
20
+ def current_actor
21
+ @current_actor ||= User.find_by(id: session[:support_desk_user_id])
22
+ end
23
+ helper_method :current_actor
24
+
25
+ def local_demo_only
26
+ head :forbidden unless [ "127.0.0.1", "::1" ].include?(request.remote_ip)
27
+ end
28
+
29
+ def authenticate
30
+ render json: { error: { code: "unauthorized", message: "sign in to the local demo" } }, status: :unauthorized unless current_actor
31
+ end
32
+
33
+ def conversation
34
+ @conversation ||= Conversation.find_by!(id: params[:id], user_id: current_actor.id, tenant_id: current_actor.tenant_id)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class ConversationsController < BaseController
5
+ # The state endpoint uses an explicit top-level JSON contract.
6
+ wrap_parameters false
7
+ skip_before_action :authenticate, only: :index
8
+
9
+ def index
10
+ end
11
+
12
+ def show
13
+ conversation
14
+ end
15
+
16
+ def stream
17
+ frames = conversation.generate!
18
+ transport = OpenUI::Rails::Stream::Transports::TurboStreams.new("unused", target: "desk-envelopes")
19
+ response.headers["Content-Type"] = "text/vnd.turbo-stream.html; charset=utf-8"
20
+ response.headers["Cache-Control"] = "no-store"
21
+ self.response_body = Enumerator.new do |body|
22
+ frames.each do |frame|
23
+ body << %(<turbo-stream action="append" target="desk-envelopes"><template>#{transport.element(frame)}</template></turbo-stream>\n)
24
+ end
25
+ end
26
+ end
27
+
28
+ def snapshot
29
+ if conversation.response.empty?
30
+ head :conflict
31
+ else
32
+ render json: conversation.snapshot.to_h
33
+ end
34
+ end
35
+
36
+ def state
37
+ record = conversation
38
+ body = request.request_parameters
39
+ unless (body.keys - %w[state version]).empty? && body["version"].is_a?(Integer) && body["version"] >= 0
40
+ raise OpenUI::Rails::InvalidActionError, "invalid state request"
41
+ end
42
+
43
+ record.save_form!(body["state"], expected_version: body["version"])
44
+ head :no_content
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class SessionsController < BaseController
5
+ skip_before_action :authenticate
6
+
7
+ def create
8
+ user = User.find_by!(name: params[:name])
9
+ reset_session
10
+ session[:support_desk_user_id] = user.id
11
+ redirect_to conversation_path(Conversation.find_by!(user_id: user.id))
12
+ end
13
+
14
+ def destroy
15
+ reset_session
16
+ redirect_to root_path
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,109 @@
1
+ import { Application, Controller } from "@hotwired/stimulus"
2
+ import "@hotwired/turbo-rails"
3
+ import OpenUIController from "openui/rails/openui_controller"
4
+ import EnvelopeController from "openui/rails/openui_envelope_controller"
5
+
6
+ class SupportDeskController extends Controller {
7
+ connect() {
8
+ this.abort = new AbortController()
9
+ this.saves = Promise.resolve()
10
+ this.lastState = null
11
+ if (this.surface.dataset.openuiStreaming === "true") this.stream().catch(error => this.failure(error))
12
+ }
13
+
14
+ disconnect() { this.abort.abort() }
15
+ get surface() { return this.element.querySelector("[data-controller='openui']") }
16
+ get status() { return this.element.querySelector("#desk-status") }
17
+
18
+ headers() {
19
+ return { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content }
20
+ }
21
+
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.")
24
+ return response
25
+ }
26
+
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, {
31
+ method: "POST", headers: this.headers(), signal: this.abort.signal, credentials: "same-origin"
32
+ }))
33
+ const reader = response.body.getReader()
34
+ const decoder = new TextDecoder()
35
+ let pending = ""
36
+ try {
37
+ while (true) {
38
+ const { value, done } = await reader.read()
39
+ pending += decoder.decode(value, { stream: !done })
40
+ let end
41
+ while ((end = pending.indexOf("\n")) >= 0) {
42
+ window.Turbo.renderStreamMessage(pending.slice(0, end))
43
+ pending = pending.slice(end + 1)
44
+ }
45
+ if (done) break
46
+ }
47
+ if (pending.trim()) throw new Error("Incomplete stream. Reload the conversation.")
48
+ this.status.textContent = "Response loaded"
49
+ } finally {
50
+ await reader.cancel().catch(() => {})
51
+ }
52
+ }
53
+
54
+ async recover() {
55
+ if (this.recovering) return
56
+ this.recovering = true
57
+ try {
58
+ const response = await this.checked(await fetch(this.element.dataset.supportDeskSnapshotUrl, {
59
+ headers: { Accept: "application/json" }, signal: this.abort.signal, credentials: "same-origin"
60
+ }))
61
+ const envelope = await response.json()
62
+ 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 }
67
+ }
68
+
69
+ saveState(event) {
70
+ const note = event.detail.state?.reply?.note
71
+ if (!note || typeof note.value !== "string") return
72
+ const state = { reply: { note: { value: note.value, componentType: "Input" } } }
73
+ const serialized = JSON.stringify(state)
74
+ if (this.lastState === serialized) return
75
+ this.lastState = serialized
76
+ this.status.textContent = "Saving draft"
77
+ const version = Number(this.surface.dataset.openuiVersion)
78
+ 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,
81
+ body: JSON.stringify({ state, version })
82
+ }))
83
+ if (this.lastState === serialized) this.status.textContent = "Draft saved"
84
+ }).catch(error => this.failure(error))
85
+ }
86
+
87
+ async action(event) {
88
+ if (!["continue_conversation", "close_ticket"].includes(event.detail.result.action)) return
89
+ await this.saves
90
+ window.location.reload()
91
+ }
92
+
93
+ 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
+ }
100
+ }
101
+ failure(error) {
102
+ if (error.name !== "AbortError") this.element.querySelector("#desk-error").textContent = error.message
103
+ }
104
+ }
105
+
106
+ const application = Application.start()
107
+ application.register("support-desk", SupportDeskController)
108
+ application.register("openui", OpenUIController)
109
+ application.register("openui-envelope", EnvelopeController)
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ # Host-owned state and business transactions, outside the gem's public API.
5
+ class Conversation < ActiveRecord::Base
6
+ belongs_to :user
7
+ belongs_to :ticket
8
+
9
+ def message_id = "desk-#{id}-turn-#{version}"
10
+
11
+ def surface
12
+ OpenUI::Rails::Surface.new(
13
+ id: id, version: version, message_id: message_id, response: response,
14
+ library_version: OpenUI::Rails.configuration.library_fingerprint,
15
+ next_sequence: next_sequence, completed: response.present?, streaming: response.empty?, context: form_state
16
+ )
17
+ end
18
+
19
+ # Persist canonical output before delivery. A disconnect cannot erase it.
20
+ # This deterministic fixture substitutes for a provider, not for the renderer.
21
+ def generate!
22
+ with_lock do
23
+ return [ snapshot ] if response.present?
24
+
25
+ controls = if ticket.reload.status == "closed"
26
+ 'TextContent("Ticket is closed")'
27
+ else
28
+ 'Buttons([Button("Close ticket", @Run(close))])'
29
+ end
30
+ source = <<~LANG
31
+ ticket = Query("ticket_lookup", {}, {title: "Loading ticket", status: "loading"})
32
+ close = Mutation("close_ticket", {})
33
+ note = FormControl("Private note", Input("note", "Write a note"))
34
+ replyButtons = Buttons([Button("Continue conversation", @ToAssistant("Review this ticket"))])
35
+ reply = Form("reply", replyButtons, [note])
36
+ controls = #{controls}
37
+ root = Card([CardHeader("Support ticket"), TextContent(ticket.title), TextContent(ticket.status), TextContent("Conversation turns: #{messages.length}"), reply, controls])
38
+ LANG
39
+ config = OpenUI::Rails::Configuration.new
40
+ config.pending_byte_limit = 1
41
+ frames = OpenUI::Rails::Stream::Bridge.new(message_id: message_id,
42
+ library_version: OpenUI::Rails.configuration.library_fingerprint, configuration: config).envelopes(source.lines).to_a
43
+ update!(response: source, next_sequence: frames.last.sequence + 1)
44
+ frames
45
+ end
46
+ end
47
+
48
+ def snapshot
49
+ OpenUI::Rails::Stream::Envelope.snapshot(message_id: message_id,
50
+ sequence: next_sequence - 1, response: response,
51
+ library_version: OpenUI::Rails.configuration.library_fingerprint)
52
+ end
53
+
54
+ def self.valid_form_state?(state)
55
+ state.is_a?(Hash) &&
56
+ (state.keys - [ "reply" ]).empty? && state.values.all? { |fields|
57
+ fields.is_a?(Hash) && (fields.keys - [ "note" ]).empty? && fields.values.all? { |field|
58
+ field.is_a?(Hash) && (field.keys - %w[value componentType]).empty? &&
59
+ field["value"].is_a?(String) && field["value"].bytesize <= 2000 &&
60
+ (!field.key?("componentType") || field["componentType"] == "Input")
61
+ }
62
+ } && JSON.generate(state).bytesize <= 4096
63
+ end
64
+
65
+ def save_form!(state, expected_version:)
66
+ raise OpenUI::Rails::InvalidActionError, "invalid note state" unless self.class.valid_form_state?(state)
67
+
68
+ with_lock do
69
+ check_version!(expected_version)
70
+ update!(form_state: state)
71
+ end
72
+ end
73
+
74
+ def close_ticket!(invocation)
75
+ with_lock do
76
+ check_version!(invocation.submission.version)
77
+ raise OpenUI::Rails::InvalidActionError, "ticket is already closed" if ticket.reload.status == "closed"
78
+
79
+ Receipt.create!(ticket_id: ticket_id, operation: "close")
80
+ ticket.update!(status: "closed")
81
+ advance!
82
+ end
83
+ { "version" => version, "status" => "closed" }
84
+ end
85
+
86
+ def continue!(invocation)
87
+ with_lock do
88
+ check_version!(invocation.submission.version)
89
+ raise OpenUI::Rails::InvalidActionError, "demo turn limit reached" if messages.length >= 20
90
+
91
+ update!(messages: messages + [ { "role" => "user", "text" => invocation.args.fetch("message") } ],
92
+ form_state: invocation.args.fetch("form_state", form_state))
93
+ advance!
94
+ end
95
+ { "version" => version }
96
+ end
97
+
98
+ private
99
+
100
+ def check_version!(expected)
101
+ raise OpenUI::Rails::StaleSurfaceError, "conversation changed; reload it" unless version == expected
102
+ end
103
+
104
+ def advance!
105
+ update!(version: version + 1, response: "", next_sequence: 0)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class Demo
5
+ def self.seed!
6
+ [ [ "Alice", "north", "agent" ], [ "Bob", "south", "agent" ], [ "Reader", "north", "reader" ] ].each do |name, tenant, role|
7
+ user = User.find_or_create_by!(name: name) { |record| record.tenant_id = tenant; record.role = role }
8
+ next if Conversation.exists?(user_id: user.id)
9
+
10
+ ticket = Ticket.create!(tenant_id: tenant, title: "#{tenant.capitalize} delivery question")
11
+ Conversation.create!(user: user, ticket: ticket, tenant_id: tenant)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class Receipt < ActiveRecord::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class Ticket < ActiveRecord::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk # :nodoc:
4
+ class User < ActiveRecord::Base
5
+ end
6
+ end
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Support Desk · OpenUI Rails reference</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= openui_stylesheet_tag %>
8
+ <%= javascript_importmap_tags "support_desk" %>
9
+ <style>
10
+ body { margin: 0; padding: 24px; font: 16px/1.6 system-ui; background: #f6f7f9; color: #17202a; }
11
+ main { max-width: 800px; margin: auto; } h1 { font-size: 28px; } button { cursor: pointer; }
12
+ header { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
13
+ .notice { padding: 12px; background: #fff3cf; border-radius: 8px; }
14
+ [role="alert"] { color: #a11; } .openui-surface { background: white; padding: 16px; border-radius: 12px; }
15
+ </style>
16
+ </head>
17
+ <body><main><%= yield %></main></body>
18
+ </html>
@@ -0,0 +1,6 @@
1
+ <h1>Support Desk reference host</h1>
2
+ <p class="notice">Synthetic accounts and tickets. Local demo only. No model provider is called.</p>
3
+ <p>Sign in to stream a ticket, save a private draft, continue the conversation, and close the ticket.</p>
4
+ <% %w[Alice Bob Reader].each do |name| %>
5
+ <%= button_to "Sign in as #{name}", session_path, params: { name: name } %>
6
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <header>
2
+ <h1>Support Desk</h1>
3
+ <span><%= current_actor.name %> · <%= current_actor.tenant_id %> tenant</span>
4
+ <%= button_to "Sign out", session_path, method: :delete %>
5
+ </header>
6
+ <p class="notice">Recorded response, real OpenUI runtime. Your Rails host owns the ticket and conversation.</p>
7
+ <section data-controller="support-desk"
8
+ data-support-desk-stream-url="<%= stream_conversation_path(@conversation) %>"
9
+ data-support-desk-snapshot-url="<%= snapshot_conversation_path(@conversation) %>"
10
+ 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
+ <%= openui_surface(surface: @conversation.surface, label: "Support ticket") %>
13
+ <div id="desk-envelopes" hidden></div>
14
+ <p role="status" id="desk-status">Ready</p>
15
+ <p role="alert" id="desk-error"></p>
16
+ </section>
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Explicitly loaded by the reference host. Never loaded by require "openui/rails".
4
+ module SupportDesk # :nodoc:
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace SupportDesk
7
+ engine_name "support_desk"
8
+ config.root = File.expand_path(__dir__)
9
+
10
+ # The documented local review uses RAILS_ENV=test. Keep the reference's
11
+ # form tags and the shared action endpoint protected in that environment too.
12
+ initializer "support_desk.csrf", before: "action_controller.set_configs" do |app|
13
+ app.config.action_controller.allow_forgery_protection = true
14
+ end
15
+
16
+ initializer "support_desk.assets", before: "importmap" do |app|
17
+ app.config.assets.paths << root.join("app/javascript")
18
+ app.config.importmap.paths << root.join("config/importmap.rb")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,4 @@
1
+ pin "support_desk", to: "support_desk.js"
2
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
3
+ pin "@hotwired/stimulus", to: "stimulus.min.js"
4
+ pin_all_from OpenUI::Rails::Engine.root.join("app/javascript/openui"), under: "openui"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ OpenUI::Rails.configure do |config|
4
+ library = ENV.fetch("OPENUI_REFERENCE_LIBRARY_DIR") { Rails.root.join("config/openui") }
5
+ config.library_spec_path = File.join(library, "library.spec.json")
6
+ config.prompt_path = File.join(library, "library.prompt.txt")
7
+ config.actor_resolver = ->(controller) { SupportDesk::User.find_by(id: controller.session[:support_desk_user_id]) }
8
+ config.surface_resolver = ->(id, actor:, **) {
9
+ SupportDesk::Conversation.find_by(id: id, user_id: actor.id, tenant_id: actor.tenant_id) if actor
10
+ }
11
+ config.idempotency_store = OpenUI::Rails::Actions::Idempotency::ActiveRecordStore.new
12
+ config.idempotency_scope = ->(invocation) { [ invocation.actor.tenant_id, invocation.actor.id ] }
13
+ config.authorize_action = ->(invocation) {
14
+ actor, conversation = invocation.actor, invocation.surface
15
+ actor && conversation && conversation.user_id == actor.id && conversation.tenant_id == actor.tenant_id &&
16
+ (invocation.action == "ticket_lookup" || actor.role == "agent")
17
+ }
18
+ end
19
+ OpenUI::Rails.actions.register "ticket_lookup", schema: {}, handler: ->(invocation) {
20
+ ticket = invocation.surface.ticket
21
+ { "title" => ticket.title, "status" => ticket.status }
22
+ }
23
+ OpenUI::Rails.actions.register "close_ticket", schema: {}, handler: ->(invocation) {
24
+ invocation.surface.close_ticket!(invocation)
25
+ }
26
+ OpenUI::Rails.actions.register "continue_conversation",
27
+ schema: ->(args) {
28
+ (args.keys - %w[message context form_name form_state]).empty? &&
29
+ args["message"].is_a?(String) && args["message"].bytesize.between?(1, 1000) &&
30
+ (!args.key?("context") || args["context"].is_a?(String) && args["context"].bytesize <= 1000) &&
31
+ (!args.key?("form_name") || args["form_name"] == "reply") &&
32
+ (!args.key?("form_state") || SupportDesk::Conversation.valid_form_state?(args["form_state"]))
33
+ }, handler: ->(invocation) { invocation.surface.continue!(invocation) }
@@ -0,0 +1,12 @@
1
+ SupportDesk::Engine.routes.draw do
2
+ root "conversations#index"
3
+ post "/session", to: "sessions#create"
4
+ delete "/session", to: "sessions#destroy"
5
+ resources :conversations, only: [ :show ] do
6
+ member do
7
+ post :stream
8
+ get :snapshot
9
+ patch :state
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSupportDesk < ActiveRecord::Migration[8.0] # :nodoc:
4
+ def change
5
+ create_table :support_desk_users do |t|
6
+ t.string :name, null: false
7
+ t.string :tenant_id, null: false
8
+ t.string :role, null: false
9
+ end
10
+ add_index :support_desk_users, :name, unique: true
11
+ create_table :support_desk_tickets do |t|
12
+ t.string :tenant_id, null: false
13
+ t.string :title, null: false
14
+ t.string :status, null: false, default: "open"
15
+ end
16
+ create_table :support_desk_conversations do |t|
17
+ t.references :user, null: false, foreign_key: { to_table: :support_desk_users }
18
+ t.references :ticket, null: false, foreign_key: { to_table: :support_desk_tickets }
19
+ t.string :tenant_id, null: false
20
+ t.integer :version, null: false, default: 0
21
+ t.text :response, null: false, default: ""
22
+ t.integer :next_sequence, null: false, default: 0
23
+ t.json :form_state, null: false, default: {}
24
+ t.json :messages, null: false, default: []
25
+ end
26
+ create_table :support_desk_receipts do |t|
27
+ t.references :ticket, null: false, foreign_key: { to_table: :support_desk_tickets }
28
+ t.string :operation, null: false
29
+ end
30
+ # Domain-level guard survives a process restart and idempotency-store expiry.
31
+ add_index :support_desk_receipts, [ :ticket_id, :operation ], unique: true
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Explicit reference-host wiring, not a generator and not a general installer.
4
+ require "fileutils"
5
+ root = File.realpath(ARGV.fetch(0, Dir.pwd))
6
+ application = File.join(root, "config/application.rb")
7
+ source = File.read(application)
8
+ marker = "Bundler.require(*Rails.groups)"
9
+ abort "expected a generated full-stack Rails host" unless source.include?(marker)
10
+ line = 'require File.join(Gem.loaded_specs.fetch("openui-rails").full_gem_path, "examples/support_desk/boot")'
11
+ File.write(application, source.sub(marker, "#{marker}\n#{line}")) unless source.include?(line)
12
+ routes = File.join(root, "config/routes.rb")
13
+ route = 'Rails.application.routes.draw { mount SupportDesk::Engine => "/support" }'
14
+ File.open(routes, "a") { |file| file.puts route } unless File.read(routes).include?("SupportDesk::Engine")
15
+ puts "Reference host wired. Install migrations, prepare the default library/runtime, and seed synthetic records as documented."
@@ -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
@@ -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
@@ -8,10 +8,10 @@ module OpenUI
8
8
  # opaque identifier plus the current revision and the source to render.
9
9
  # A surface can be built explicitly or coerced from a host record that
10
10
  # exposes `id` (and, optionally, `version`, `library_version`, `response`,
11
- # `context`, `message_id`).
11
+ # `context`, `message_id`, `next_sequence`, `completed`).
12
12
  class Surface
13
13
  # Surface values copied from an explicit object, hash, or host record.
14
- ATTRIBUTES = %i[id version library_version response context streaming message_id].freeze
14
+ ATTRIBUTES = %i[id version library_version response context streaming message_id next_sequence completed].freeze
15
15
 
16
16
  attr_reader(*ATTRIBUTES)
17
17
 
@@ -72,15 +72,28 @@ module OpenUI
72
72
  #
73
73
  # @param message_id [String, nil] stream identity; defaults to id
74
74
  #
75
+ # @param next_sequence [Integer] next expected envelope sequence after persisted output
76
+ #
77
+ # @param completed [Boolean] ignore later non-snapshot envelopes for a terminal response
78
+ #
75
79
  # @return [Surface]
76
80
  def initialize(id:, version: 0, library_version: nil, response: nil, context: nil,
77
- streaming: false, message_id: nil)
81
+ streaming: false, message_id: nil, next_sequence: 0, completed: false)
78
82
  raise ArgumentError, "surface id is required" if id.nil? || id.to_s.empty?
79
83
  unless version.nil? || (version.is_a?(Integer) && version >= 0)
80
84
  raise ArgumentError, "surface version must be a non-negative Integer"
81
85
  end
82
86
  raise ArgumentError, "surface response must be a String" unless response.nil? || response.is_a?(String)
83
87
 
88
+ unless next_sequence.is_a?(Integer) && next_sequence.between?(0, 9_007_199_254_740_991)
89
+ raise ArgumentError, "next_sequence must be a non-negative JavaScript-safe Integer"
90
+ end
91
+ unless [ true, false ].include?(completed) && !(completed && streaming)
92
+ raise ArgumentError, "completed must be boolean and cannot coexist with streaming"
93
+ end
94
+
95
+ @next_sequence = next_sequence
96
+ @completed = completed
84
97
  @id = id.to_s.dup.freeze
85
98
  @version = version || 0
86
99
  @library_version = library_version&.to_s&.dup&.freeze
@@ -102,7 +115,8 @@ module OpenUI
102
115
  def to_h
103
116
  {
104
117
  id: id, version: version, library_version: library_version,
105
- response: response, context: context, streaming: streaming, message_id: message_id
118
+ response: response, context: context, streaming: streaming, message_id: message_id,
119
+ next_sequence: next_sequence, completed: completed
106
120
  }
107
121
  end
108
122
 
@@ -124,10 +138,14 @@ module OpenUI
124
138
  #
125
139
  # @return [Hash] HTML data attributes with string values
126
140
  def to_mount_data(stream: false, actions_endpoint:, initial_state: nil)
141
+ raise ArgumentError, "completed surfaces cannot be mounted as streaming" if completed && stream
142
+
127
143
  {
128
144
  controller: "openui",
129
145
  openui_surface_id: id,
130
146
  openui_message_id: message_id,
147
+ openui_next_sequence: next_sequence.to_s,
148
+ openui_completed: completed.to_s,
131
149
  openui_version: version.to_s,
132
150
  openui_library_version: library_version,
133
151
  openui_streaming: (streaming? || stream) ? "true" : "false",
@@ -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.2.0"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
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.2.0
4
+ version: 0.4.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-18 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -61,12 +61,34 @@ files:
61
61
  - app/javascript/openui/rails/openui_controller.js
62
62
  - app/javascript/openui/rails/openui_envelope_controller.js
63
63
  - config/routes.rb
64
+ - examples/support_desk/README.md
65
+ - examples/support_desk/app/controllers/support_desk/base_controller.rb
66
+ - examples/support_desk/app/controllers/support_desk/conversations_controller.rb
67
+ - examples/support_desk/app/controllers/support_desk/sessions_controller.rb
68
+ - examples/support_desk/app/javascript/support_desk.js
69
+ - examples/support_desk/app/models/support_desk/conversation.rb
70
+ - examples/support_desk/app/models/support_desk/demo.rb
71
+ - examples/support_desk/app/models/support_desk/receipt.rb
72
+ - examples/support_desk/app/models/support_desk/ticket.rb
73
+ - examples/support_desk/app/models/support_desk/user.rb
74
+ - examples/support_desk/app/views/layouts/support_desk/application.html.erb
75
+ - examples/support_desk/app/views/support_desk/conversations/index.html.erb
76
+ - examples/support_desk/app/views/support_desk/conversations/show.html.erb
77
+ - examples/support_desk/boot.rb
78
+ - examples/support_desk/config/importmap.rb
79
+ - examples/support_desk/config/initializers/openui.rb
80
+ - examples/support_desk/config/routes.rb
81
+ - examples/support_desk/db/migrate/20261019000000_create_support_desk.rb
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
64
85
  - lib/generators/openui/install/install_generator.rb
65
86
  - lib/generators/openui/install/templates/config/initializers/openui.rb.tt
66
87
  - lib/generators/openui/install/templates/config/openui/README.md
67
88
  - lib/openui/rails.rb
68
89
  - lib/openui/rails/actions.rb
69
90
  - lib/openui/rails/actions/dispatcher.rb
91
+ - lib/openui/rails/actions/idempotency/active_record_store.rb
70
92
  - lib/openui/rails/actions/idempotency/memory_store.rb
71
93
  - lib/openui/rails/actions/invocation.rb
72
94
  - lib/openui/rails/actions/registry.rb
@@ -97,6 +119,7 @@ files:
97
119
  - lib/openui/rails/stream/transports/memory.rb
98
120
  - lib/openui/rails/stream/transports/turbo_streams.rb
99
121
  - lib/openui/rails/surface.rb
122
+ - lib/openui/rails/testing/idempotency_store_contract.rb
100
123
  - lib/openui/rails/version.rb
101
124
  - lib/openui/rails/view_helper.rb
102
125
  - lib/tasks/openui.rake