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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -1
- data/CONTRIBUTING.md +1 -1
- data/DEVELOPMENT.md +21 -0
- data/README.md +6 -5
- data/RELEASING.md +12 -0
- data/app/javascript/openui/rails/openui_controller.js +14 -1
- data/examples/support_desk/README.md +20 -0
- data/examples/support_desk/app/controllers/support_desk/base_controller.rb +37 -0
- data/examples/support_desk/app/controllers/support_desk/conversations_controller.rb +47 -0
- data/examples/support_desk/app/controllers/support_desk/sessions_controller.rb +19 -0
- data/examples/support_desk/app/javascript/support_desk.js +109 -0
- data/examples/support_desk/app/models/support_desk/conversation.rb +108 -0
- data/examples/support_desk/app/models/support_desk/demo.rb +15 -0
- data/examples/support_desk/app/models/support_desk/receipt.rb +6 -0
- data/examples/support_desk/app/models/support_desk/ticket.rb +6 -0
- data/examples/support_desk/app/models/support_desk/user.rb +6 -0
- data/examples/support_desk/app/views/layouts/support_desk/application.html.erb +18 -0
- data/examples/support_desk/app/views/support_desk/conversations/index.html.erb +6 -0
- data/examples/support_desk/app/views/support_desk/conversations/show.html.erb +16 -0
- data/examples/support_desk/boot.rb +21 -0
- data/examples/support_desk/config/importmap.rb +4 -0
- data/examples/support_desk/config/initializers/openui.rb +33 -0
- data/examples/support_desk/config/routes.rb +12 -0
- data/examples/support_desk/db/migrate/20261019000000_create_support_desk.rb +33 -0
- data/examples/support_desk/install.rb +15 -0
- data/lib/generators/openui/idempotency/idempotency_generator.rb +19 -0
- data/lib/generators/openui/idempotency/templates/create_openui_action_claims.rb +16 -0
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +4 -0
- data/lib/openui/rails/actions/idempotency/active_record_store.rb +73 -0
- data/lib/openui/rails/actions/idempotency/memory_store.rb +2 -2
- data/lib/openui/rails/actions.rb +3 -0
- data/lib/openui/rails/surface.rb +22 -4
- data/lib/openui/rails/testing/idempotency_store_contract.rb +79 -0
- data/lib/openui/rails/version.rb +1 -1
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1fd5862a42a9eaf7c669e6e94fa38d8aaac1784e4f7efe5340e9cec48756902a
|
|
4
|
+
data.tar.gz: f7ada4e9693c3de0c0d13b05a7cc7692a44b402b291721d430038a68c6047f4c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
26
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
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,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,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
|
|
11
|
-
#
|
|
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]
|
data/lib/openui/rails/actions.rb
CHANGED
|
@@ -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
|
data/lib/openui/rails/surface.rb
CHANGED
|
@@ -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
|
data/lib/openui/rails/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openui-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
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-
|
|
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
|