rails_tool_views 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba277c8c403681504a1c5c2d154df3c7814952735ca5a10ba6981f7c50109a2d
4
+ data.tar.gz: a6ebf8a7b47065ffaf8585d5f57b2aff864d181fe0e7deb0085c26500b640ef4
5
+ SHA512:
6
+ metadata.gz: 276b0f741dc507376c153e900a0b20468e64cf55f66073be587eb4a0d4705bc0d8a4b2d75b5531382e80011a0bf193510acb143fbe2e6864028881422509e461
7
+ data.tar.gz: e216675b6918e72cdbaf75a4404e7729bac2186260cbc06a500a8130c7cacaa1312d2e436bcbe085b159534a60adc6c1898673319fd718b456fab32ddd9a8f38
data/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-10-18
4
+
5
+ First public release. Earlier version labels below describe unpublished development milestones.
6
+
7
+ - Register trusted Rails Partials and ViewComponents with closed schemas and explicit policies.
8
+ - Persist and restore scoped Surfaces; execute authorized actions with revisions, transactions and durable replay receipts.
9
+ - Render JSON/Turbo responses, accessible action forms and bounded validation feedback.
10
+ - Add optional RubyLLM presentation and durable host-managed continuation without a core AI dependency.
11
+ - Install the engine into Rails 7.2 and 8.1 on Ruby 3.3 or later; one `rails_tool_views` identity throughout.
12
+ - Publish versioned guides and an API reference on GitHub Pages; release gems through GitHub OIDC.
13
+ - Require patched CGI, ERB, Resolv, URI and Zlib gems so Ruby 3.3 default gems cannot silently satisfy the runtime dependencies. Test the Rails 7.2 line on patched 7.2.3.2.
14
+ - MySQL, live-provider behavior, independent security review and external adoption remain unverified.
15
+
16
+ ## 0.3.0.pre.v3 — single identity, 2026-10-18 (unpublished)
17
+
18
+ Breaking for 0.2 development hosts; see the Upgrading guide and ADR 006.
19
+
20
+ - Removed: the `ToolViews` constant, the `toolviews`, `tool_views`, `tool_views/core` and `tool_views/adapters/ruby_llm` entrypoints, the `tool_views:install` generator, the `tool_views_*` view helpers and the `:tool_views` route-proxy fallback.
21
+ - Changed: tables and custom indexes use the `rails_tool_views_` prefix; the interaction/execution index has an explicit 44-byte name so it fits PostgreSQL. The install migration is `CreateRailsToolViews` and the initializer `config/initializers/rails_tool_views.rb`.
22
+ - Changed: I18n keys move to `rails_tool_views.*`, notifications to `*.rails_tool_views`, the `Rails.error` source to `rails_tool_views`, Surface DOM ids to `rails_tool_views_surface_<id>`, CSS classes to `rails-tool-views-*`, the Stimulus asset to `rails_tool_views_controller.js` (`railsToolViewsController`, identifier `rails-tool-views`) and delivery keys to `rails_tool_views:interaction:<id>`.
23
+ - Unchanged: component contracts and digests, Surface IDs, revisions, serialized data, error codes and replay behavior.
24
+
25
+ ## Unreleased — audit hardening, 2026-10-17
26
+
27
+ Fixed: the bundled migration created the five-column presentation unique index with unbounded `varchar` columns, so `CREATE TABLE` failed on MySQL/InnoDB under utf8mb4 (5 x 255 x 4 = 5100 bytes against a 3072-byte limit). Identifiers are now bounded to 150 bytes, so the widest index needs 3000 bytes. Changed: stored actor, tenant, context, adapter, call and idempotency references are byte-capped in `RailsToolViews::Data.identifier`; a longer value is rejected (`INVALID_SCOPE`, `INVALID_CONTEXT`, `INVALID_CORRELATION`, `INVALID_IDEMPOTENCY_KEY`) instead of overflowing a column. Descriptions and versions keep the wider 255-byte cap. Fixed: a non-binary database collation could resolve another actor's or tenant's Surface, another correlation's presentation, or another idempotency key's action receipt; every lookup now confirms a byte-exact match before it stands in for the request. Added regression tests for index-key width, the identifier cap, and collation-safe identity matching. No data migration: existing rows are unchanged and the runtime bound is enforced in code.
28
+
29
+ ## Unreleased — audit hardening and component authoring, 2026-10-13
30
+
31
+ Fixed: nested schemas that were untyped, empty or arrays without `items` registered successfully and accepted arbitrary JSON inside a closed object; they are now rejected with `INVALID_SCHEMA`. Fixed: a native form for an action without arguments was rejected with `INVALID_ARGUMENTS` because it posts no `arguments` field. Changed: log redaction is scoped to the Engine's controller and models; installing the gem no longer adds `props`, `state`, `payload`, `result`, `arguments` and `values` to the host's `filter_parameters`, which had redacted matching host parameters and columns. Changed: renderer and continuation-adapter exceptions are reported to `Rails.error`, logged, and emitted as `surface.render_failed` / `agent_failed` events instead of being swallowed; `surface.rendered` fires only for a successful render. Added: `message:` on `ToolViews::Error` and `ValidationError`, I18n lookup under `tool_views.errors.*`, and overridable Engine copy under `tool_views.surface.*`. Changed: the status line no longer interprets a `selected` state key; handlers set `state["message"]`. Removed from core: the reference host's `ALREADY_SUBMITTED`, `ALREADY_ACCEPTED` and `REVIEW_REQUIRED` wording, now in its own locale file. No database change.
32
+
33
+ Added: `tool_views_action_form`, `tool_views_action_button` and `tool_views_action_fields` view helpers. Fixed: Rails' own `form.submit` and `form.button` were rejected as `UNKNOWN_FIELD`; `commit` and `button` are now accepted and ignored. Added: a ViewComponent whose initializer names `surface:`, `state:` or `error:` receives them and can be interactive. Added: `config.parent_controller` and `config.delivery_lease`. Changed: without Turbo, a committed HTML action redirects back to the same-host embedding page instead of the Engine's bare Surface fragment. Changed: a database deadlock, serialization failure or lock timeout returns `CONFLICT` instead of an unhandled error. Verified locally on PostgreSQL 17 (`gemfiles/postgres.gemfile`, `DATABASE_URL`); CI job added, not yet run.
34
+
35
+ ## 0.2.0.pre.v1 — 2026-10-11 (unpublished)
36
+
37
+ Host-owned customer form, scoped support workflows and versioned result review reference demos (UC-02..05). Add bounded validation feedback with retained form values, authorized HTML error rendering, host ApplicationController inheritance and general action announcements. Demo services independently enforce role/tenant/domain versions, human review before a local outbox, immutable accepted artifacts, authorized export and durable continuation receipts. No new gem database tables or core RubyLLM dependency. Local matrix, packaged install, privacy, recovery and browser evidence: docs/evidence/V1_VALIDATION.md. Manual review and release gates remain open.
38
+
39
+ ## 0.1.0.pre.v0 — 2026-10-10 (unpublished)
40
+
41
+ Strict registry and trusted Partial/ViewComponent rendering; scoped ActiveRecord Surfaces; atomic authorized actions with revision/replay receipts; JSON/Turbo controller and optional Stimulus focus/loading support; optional RubyLLM present_ui and structured continuation; installer and offline ChoiceCard demo. Local integration/security/concurrency/package/browser verification is recorded in docs/evidence/VALIDATION.md. Release and adoption gates remain open.
42
+
43
+ ## Unreleased — first host integration fixes (2026-10-17)
44
+
45
+ - Fix: without Turbo and without a same-host referrer, a committed HTML action redirected through a hard-coded `tool_views` route proxy. Under the installer's `rails_tool_views` mount it raised `NameError` (500). It now uses the Engine's own route helper and keeps any mount path and name.
46
+ - Add: `rails_tool_views_component_name` / `tool_views_component_name`. The Surface's accessible name is looked up under `tool_views.components.<key>` on every render and falls back to the registered description.
47
+ - Docs: renderers and parent-controller filters run inside the Engine's controller and call host routes through `main_app`.
48
+ - No database change.
49
+
50
+ ## Unreleased — RailsToolViews identity (2026-10-16)
51
+
52
+ - Rename the package to `rails_tool_views` and the canonical namespace to `RailsToolViews`.
53
+ - Preserve legacy Ruby requires/constants/helpers/mounts, database and wire identifiers.
54
+ - Add independent packaged-host and pre-rename persisted-data compatibility verification. No RubyGems release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # RailsToolViews
2
+
3
+ **Turn trusted Rails views into persistent, interactive tools.**
4
+
5
+ Register an existing Partial or ViewComponent, present it with validated data, and let users act through authorized Rails handlers. Each view keeps its state across refreshes. Your application owns authentication, tenant boundaries and business records.
6
+
7
+ The core runs without an AI provider. An optional RubyLLM adapter lets agents present registered components and continue after a user action. RailsToolViews does not generate executable UI code.
8
+
9
+ [Documentation](https://rails-tool-views.wangyaodi.com) · [RubyGems](https://rubygems.org/gems/rails_tool_views) · [API reference](https://rails-tool-views.wangyaodi.com/api/)
10
+
11
+ Version **0.1.0** is the first public release.
12
+
13
+ ## Try the workbench
14
+
15
+ Requires Ruby 3.3+ and Bundler. From this checkout:
16
+
17
+ ```sh
18
+ bundle install
19
+ bin/demo
20
+ ```
21
+
22
+ Open **http://127.0.0.1:3100**. Stop with `Ctrl-C` when finished.
23
+
24
+ | Workflow | What to try |
25
+ | --- | --- |
26
+ | Component playground | Present a Partial, chat card or ViewComponent. Save a choice and refresh to restore it. |
27
+ | Customer intake | Submit an invalid email, correct it and create a customer with field-level feedback. |
28
+ | Support workspace | Find a ticket, inspect its account, review a draft reply and confirm the local outbox. |
29
+ | Content review | Edit, regenerate, accept a version and download its text export. |
30
+
31
+ Switch synthetic identities to explore two tenants and two roles. Fixtures run locally, with no provider calls or external messages. The identity switch is a testing aid, not production authentication. See the [demo walkthrough](docs/site/_getting_started/demo.md) for test scenarios and continuation failure/retry.
32
+
33
+ ## Add it to a Rails application
34
+
35
+ Add the gem:
36
+
37
+ ```ruby
38
+ # Gemfile
39
+ gem "rails_tool_views", "~> 0.1.0"
40
+ ```
41
+
42
+ ```sh
43
+ bundle install
44
+ bin/rails generate rails_tool_views:install
45
+ bin/rails db:migrate
46
+ ```
47
+
48
+ Follow [Getting Started](docs/site/_getting_started/getting-started.md) to configure authenticated scope resolution and register a component. Once registered, present and render it:
49
+
50
+ ```ruby
51
+ scope = RailsToolViews::Scope.new(actor: current_user.id.to_s, tenant: current_account.id.to_s)
52
+ surface = RailsToolViews.present(:choice_card, props: approved_props, scope: scope)
53
+ ```
54
+
55
+ ```erb
56
+ <%= rails_tool_views_surface(surface, scope: scope) %>
57
+ ```
58
+
59
+ Component and action policies deny by default. The host must authorize domain records and allowed values independently of proposed props. External side effects require host idempotency or an outbox. Read [Security](SECURITY.md) before integration.
60
+
61
+ ## Documentation
62
+
63
+ Read the [searchable guides](https://rails-tool-views.wangyaodi.com). Preview locally with `docs/bin/serve.sh`; setup and build instructions are in [docs/README.md](docs/README.md).
64
+
65
+ - [Getting Started](docs/site/_getting_started/getting-started.md): installation and a working first component.
66
+ - [Configuration](docs/site/_getting_started/configuration.md): host identity and engine settings.
67
+ - [Core guides](docs/site/_core_features): components, surfaces, actions and validation.
68
+ - [Advanced guides](docs/site/_advanced): optional RubyLLM, continuation and production boundaries.
69
+ - [API reference](docs/site/_reference/api-reference.md): generated Ruby API documentation.
70
+ - [Upgrading](docs/site/_reference/upgrading.md): moving an unpublished development host to the first public version.
71
+
72
+ The dummy host in [`test/dummy`](test/dummy) contains the complete runnable examples and is excluded from the gem runtime. OddlyDock is a planned reference host, not a dependency.
73
+
74
+ ## Development
75
+
76
+ ```sh
77
+ bundle exec rake test zeitwerk:check
78
+ bundle exec ruby scripts/package_smoke.rb
79
+ # Optional adapter tests:
80
+ BUNDLE_GEMFILE=gemfiles/adapter.gemfile bundle install
81
+ BUNDLE_GEMFILE=gemfiles/adapter.gemfile bundle exec rake test:adapter
82
+ ```
83
+
84
+ Tests use offline responses and block TCP connections. Supported dependency bounds are in [the gemspec](rails_tool_views.gemspec); tested combinations and limitations are recorded in [validation evidence](docs/evidence). MySQL and live-provider behavior remain unverified. See [the release checklist](docs/operations/02_RELEASE_CHECKLIST.md) for remaining release gates.
85
+
86
+ ## Project status and attribution
87
+
88
+ Released under the [MIT license](LICENSE.txt).
89
+
90
+ RailsToolViews is an unofficial community project. Rails marks belong to David Heinemeier Hansson, with exclusive licensing to the Rails Foundation. Framework names identify compatibility; neither the owner nor the Foundation endorses or is affiliated with this project. See the [Rails trademark policy](https://rubyonrails.org/trademarks).
data/SECURITY.md ADDED
@@ -0,0 +1,11 @@
1
+ # Security
2
+
3
+ The 0.1.x release line receives security fixes. This project has not received independent security review. Report suspected vulnerabilities through [GitHub private vulnerability reporting](https://github.com/yorzi/rails_tool_views/security/advisories/new). Do not post secrets, customer data, raw payloads or exploit instructions in a public issue. The maintainer reviews reports on a best-effort basis; no response-time guarantee is offered.
4
+
5
+ The host must resolve actor and tenant from authenticated server state, provide deny-by-default component/action policies, authorize domain records independently of props, keep CSRF enabled and project data before persistence. RailsToolViews rejects unregistered renderers/actions, extra parameters, client policy text, forged sources/targets, stale revisions and mismatched replays. The only extra parameters tolerated are `commit` and `button`, which Rails' submit helpers post; their values are never read. The non-Turbo redirect after a committed action follows `Referer` only to the same host. Native escaping depends on trusted host templates. Registration rejects any nested schema that is untyped, empty, open or an array without `items`, so a closed props or arguments object cannot be reopened from the inside. Engine-scoped request and SQL bind redaction and metadata-only notifications reduce logging exposure without altering host logs; host SQL/SDK logging and retention still need review.
6
+
7
+ The gem stores approved display props/state and structured interaction values. It does not store transcripts, API credentials or raw business-record dumps by itself. Hosts control export/deletion and must not place sensitive data into these fields. Database handlers share Surface's transaction; external calls require host idempotency/outbox. Agent deliveries use a lease and stable delivery key, with explicit retry; a host must deduplicate across uncertain remote outcomes.
8
+
9
+ Supported transports are JSON and Turbo. ActionCable is absent. No executable model-generated HTML, JS, Ruby, arbitrary remote schema resolution or URL fetch is supported. Security coverage maps SEC-01..18 in the acceptance matrix; channel-specific checks remain N/A while broadcasting is disabled.
10
+
11
+ Mounted controllers inherit the host's ApplicationController; review inherited authentication filters and helpers. ValidationError returns bounded approved field feedback, so handlers must avoid returning private values. Engine request logs mask every field except routing metadata, and Engine models redact their JSON columns in SQL binds and `#inspect`; custom host attributes/SDK logs require separate review. Renderer and adapter exceptions are host exceptions: they are passed to `Rails.error` and logged with class and message, so host code must not put secrets in exception messages. V1 query/resolver/dispatcher independently authorize current role, tenant and associations, and compare host versions across Surfaces. The synthetic identity selector and local reply outbox are reference fixtures, not production authentication or external delivery.
@@ -0,0 +1,41 @@
1
+ // Host imports and registers this factory with its own Stimulus application.
2
+ // No provider, generated code, or browser-owned policy participates in actions.
3
+ export default function railsToolViewsController(Controller) {
4
+ return class extends Controller {
5
+ static targets = ["status", "error"];
6
+ // The server-rendered Surface supplies translated copy; these are fallbacks.
7
+ static values = {
8
+ revision: Number,
9
+ saving: { type: String, default: "Saving changes…" },
10
+ failed: { type: String, default: "Changes could not be saved. Review the message and try again." }
11
+ };
12
+ connect() {
13
+ this.start = () => {
14
+ this.element.setAttribute("aria-busy", "true");
15
+ if (this.hasStatusTarget) this.statusTarget.textContent = this.savingValue;
16
+ };
17
+ this.end = (event) => {
18
+ this.element.removeAttribute("aria-busy");
19
+ if (!event.detail.success && this.hasStatusTarget) this.statusTarget.textContent = this.failedValue;
20
+ };
21
+ this.beforeStream = (event) => {
22
+ const stream = event.target;
23
+ if (stream.getAttribute("target") !== this.element.id) return;
24
+ const incoming = stream.querySelector("template")?.content.firstElementChild;
25
+ const revision = Number(incoming?.getAttribute("data-rails-tool-views-revision-value"));
26
+ if (revision && revision < this.revisionValue) event.preventDefault();
27
+ };
28
+ this.element.ownerDocument.addEventListener("turbo:before-stream-render", this.beforeStream);
29
+ this.element.addEventListener("turbo:submit-start", this.start);
30
+ this.element.addEventListener("turbo:submit-end", this.end);
31
+ // Replacement happens after a committed result; move focus to its announcement.
32
+ if (this.hasErrorTarget) this.errorTarget.focus({ preventScroll: true });
33
+ else if (this.revisionValue > 1 && this.hasStatusTarget) this.statusTarget.focus({ preventScroll: true });
34
+ }
35
+ disconnect() {
36
+ this.element.ownerDocument.removeEventListener("turbo:before-stream-render", this.beforeStream);
37
+ this.element.removeEventListener("turbo:submit-start", this.start);
38
+ this.element.removeEventListener("turbo:submit-end", this.end);
39
+ }
40
+ };
41
+ }
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # Preserve the host's authentication methods, filters and view helpers.
4
+ class SurfacesController < RailsToolViews.parent_controller
5
+ # Request logs for these routes keep routing metadata only. Everything else,
6
+ # including unknown injected fields, is masked here rather than in the host's
7
+ # global filter list, which would also redact the host's own parameters.
8
+ LOGGED_PARAMETERS = %w[controller action id interaction_id format action_name base_revision idempotency_key source_component target].freeze
9
+ UNLOGGED_PARAMETER = /\A(?!(?:#{LOGGED_PARAMETERS.join('|')})\z)/
10
+ # Rails' own form and submit helpers post these names; their values are never read.
11
+ FORM_FURNITURE = %w[authenticity_token commit button].freeze
12
+
13
+ protect_from_forgery with: :exception
14
+ wrap_parameters false
15
+ before_action :limit_payload
16
+ helper RailsToolViews::SurfacesHelper
17
+ rescue_from RailsToolViews::Error, with: :reject_request
18
+ rescue_from ActionController::InvalidAuthenticityToken do
19
+ reject_request(Error.new("INVALID_CSRF", status: 403))
20
+ end
21
+
22
+ def show
23
+ surface = RailsToolViews.restore(params[:id], scope: current_scope)
24
+ respond_to do |format|
25
+ format.json { render json: surface.snapshot }
26
+ format.html { render html: RailsToolViews.render(surface, view_context: view_context, scope: current_scope) }
27
+ end
28
+ end
29
+
30
+ def act
31
+ reject_unknown_fields(%w[action action_name arguments base_revision idempotency_key source_component target])
32
+ result = RailsToolViews.dispatch(params[:id], scope: current_scope, action: input["action"] || input["action_name"],
33
+ # A native form for an action without arguments cannot post an empty object.
34
+ arguments: input.fetch("arguments", {}),
35
+ base_revision: parse_revision(input["base_revision"]), idempotency_key: input["idempotency_key"],
36
+ source_component: input["source_component"], target: input["target"])
37
+ if result[:interaction_id] && RailsToolViews.configuration.agent_adapter && RailsToolViews.configuration.auto_deliver
38
+ RailsToolViews.deliver(result[:interaction_id], scope: current_scope)
39
+ end
40
+ respond_with_surface(result)
41
+ end
42
+
43
+ def retry_agent
44
+ reject_unknown_fields(%w[])
45
+ surface = RailsToolViews.restore(params[:id], scope: current_scope)
46
+ interaction = surface.agent_interactions.find_by(id: params[:interaction_id])
47
+ raise Error.new("NOT_FOUND", status: 404) unless interaction
48
+ RailsToolViews.deliver(interaction.id, scope: current_scope, retry_failed: true)
49
+ respond_with_surface(status: "completed", surface_id: surface.id, revision: surface.revision, interaction_id: interaction.id)
50
+ end
51
+
52
+ private
53
+ # Runs before Rails reads the filtered parameters for its request log.
54
+ def process_action(*)
55
+ request.set_header("action_dispatch.parameter_filter", Array(request.get_header("action_dispatch.parameter_filter")) + [UNLOGGED_PARAMETER])
56
+ super
57
+ end
58
+ def current_scope
59
+ @current_scope ||= RailsToolViews.configuration.scope_resolver&.call(self)
60
+ raise Error.new("NOT_ALLOWED", status: 403) unless @current_scope.is_a?(Scope)
61
+ @current_scope
62
+ end
63
+ def limit_payload
64
+ raise Error.new("PAYLOAD_TOO_LARGE", status: 413) if request.content_length.to_i > RailsToolViews.configuration.max_payload_bytes || request.raw_post.to_s.bytesize > RailsToolViews.configuration.max_payload_bytes
65
+ end
66
+ def reject_unknown_fields(allowed)
67
+ extra = input.keys - allowed - FORM_FURNITURE
68
+ raise Error.new("UNKNOWN_FIELD") unless extra.empty?
69
+ raise Error.new("UNKNOWN_FIELD") if input.key?("action") && input.key?("action_name")
70
+ end
71
+ def input
72
+ @input ||= Data.bounded(request.request_parameters)
73
+ end
74
+ def parse_revision(value)
75
+ return value if value.is_a?(Integer)
76
+ raise Error.new("INVALID_REVISION") unless value.is_a?(String) && value.match?(/\A[1-9][0-9]{0,9}\z/)
77
+ value.to_i
78
+ end
79
+ def respond_with_surface(result)
80
+ surface = RailsToolViews.restore(result[:surface_id], scope: current_scope)
81
+ interaction = result[:interaction_id] && surface.agent_interactions.find_by(id: result[:interaction_id])
82
+ response = result.merge(current_revision: surface.revision)
83
+ response[:agent_status] = interaction.status if interaction
84
+ response[:status] = "pending" if interaction && %w[pending delivering].include?(interaction.status)
85
+ status = response[:status] == "pending" ? 202 : 200
86
+ respond_to do |format|
87
+ format.json { render json: response, status: status }
88
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(surface.dom_id, RailsToolViews.render(surface, view_context: view_context, scope: current_scope)), status: status }
89
+ # Without Turbo, return to the page that embeds the Surface; it re-renders saved state.
90
+ # The Engine's own helper keeps whatever mount path and name the host chose.
91
+ format.html { redirect_back_or_to surface_path(surface), allow_other_host: false, status: :see_other }
92
+ end
93
+ end
94
+ def reject_request(error)
95
+ respond_to do |format|
96
+ format.json { render json: error.to_h, status: error.status }
97
+ format.turbo_stream do
98
+ # Failed authorization never renders props, and supplied targets never reach Turbo.
99
+ begin
100
+ surface = RailsToolViews.restore(params[:id], scope: current_scope)
101
+ html = RailsToolViews.render(surface, view_context: view_context, scope: current_scope, error: error)
102
+ render turbo_stream: turbo_stream.replace(surface.dom_id, html), status: error.status
103
+ rescue RailsToolViews::Error
104
+ render plain: error.message, status: error.status
105
+ end
106
+ end
107
+ format.html do
108
+ begin
109
+ surface = RailsToolViews.restore(params[:id], scope: current_scope)
110
+ render html: RailsToolViews.render(surface, view_context: view_context, scope: current_scope, error: error), status: error.status
111
+ rescue RailsToolViews::Error
112
+ render partial: "rails_tool_views/surfaces/error", locals: { error: error }, status: error.status
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # Rails view helpers for authorized rendering and action forms.
4
+ module SurfacesHelper
5
+ # Engine-owned copy. Translate or reword any of it under rails_tool_views.surface.<key>.
6
+ TEXT = {
7
+ ready: "View ready.", saved: "Changes saved.", revision: "Saved revision %{revision}",
8
+ saving: "Saving changes…", save_failed: "Changes could not be saved. Review the message and try again.",
9
+ agent_pending: "AI follow-up pending.", agent_completed: "AI follow-up completed.",
10
+ agent_failed: "Your changes were saved, but the AI follow-up could not complete.",
11
+ agent_unavailable: "AI follow-up unavailable.", agent_retry: "Retry AI follow-up"
12
+ }.freeze
13
+
14
+ # Renders the Surface with a fresh scope and optional field feedback.
15
+ def rails_tool_views_surface(surface, scope:, error: nil)
16
+ RailsToolViews.render(surface, view_context: self, scope: scope, error: error)
17
+ end
18
+
19
+ def rails_tool_views_text(key, **values)
20
+ t(key, scope: %i[rails_tool_views surface], default: TEXT.fetch(key), **values)
21
+ end
22
+
23
+ # The accessible name of a registered component: the translation under
24
+ # rails_tool_views.components.<key>, or its registered description. Looked up
25
+ # per render, so it follows the request's locale.
26
+ def rails_tool_views_component_name(key)
27
+ t(key.to_s, scope: %i[rails_tool_views components], default: RailsToolViews.registry.fetch(key).description)
28
+ end
29
+
30
+ # Form for one registered action of this Surface. Fields built from the
31
+ # yielded builder are named +arguments[...]+, ids are unique per Surface, and
32
+ # the action, revision, replay key and source fields are filled in.
33
+ def rails_tool_views_action_form(surface, action, **options, &block)
34
+ fields = rails_tool_views_action_fields(surface, action)
35
+ form_with(url: rails_tool_views_routes.surface_actions_path(surface), method: :post, scope: :arguments, namespace: surface.dom_id, **options) do |form|
36
+ safe_join([fields, (capture(form, &block) if block)])
37
+ end
38
+ end
39
+
40
+ # One-button form for an action whose arguments, if any, are fixed.
41
+ def rails_tool_views_action_button(surface, action, label, arguments: {}, form: {}, **button)
42
+ rails_tool_views_action_form(surface, action, **form) do |builder|
43
+ safe_join(arguments.map { |name, value| builder.hidden_field(name, value: value, id: nil) } << builder.button(label, name: nil, **button))
44
+ end
45
+ end
46
+
47
+ # The routing fields alone, for a form the host builds itself.
48
+ def rails_tool_views_action_fields(surface, action)
49
+ key = action.to_s
50
+ unless RailsToolViews.registry.fetch(surface.component_key).actions.key?(key)
51
+ raise ArgumentError, "#{surface.component_key} does not register the action #{key.inspect}"
52
+ end
53
+ safe_join({ action_name: key, base_revision: surface.revision, idempotency_key: SecureRandom.uuid, source_component: surface.component_key }
54
+ .map { |name, value| hidden_field_tag(name, value, id: nil) })
55
+ end
56
+
57
+ def rails_tool_views_body(surface, definition, error: nil) # :nodoc:
58
+ html = if definition.partial
59
+ render(partial: definition.partial, locals: { props: surface.props, state: surface.state, surface: surface, error: error })
60
+ else
61
+ context = { surface: surface, state: surface.state, error: error }.slice(*definition.context)
62
+ render(definition.component.new(**surface.props.symbolize_keys, **context))
63
+ end
64
+ RailsToolViews.notify("surface.rendered", surface_id: surface.id, component: definition.key, revision: surface.revision)
65
+ html
66
+ rescue StandardError => failure
67
+ # A host renderer may reject deliberately; anything else is a host bug to report.
68
+ rejection = [failure, failure.cause].find { |candidate| candidate.is_a?(RailsToolViews::Error) }
69
+ RailsToolViews.report(failure, "surface.render_failed", surface_id: surface.id, component: definition.key) unless rejection
70
+ render(partial: "rails_tool_views/surfaces/error", formats: [:html], locals: { error: rejection || RailsToolViews::Error.new("RENDER_FAILED", status: 422) })
71
+ end
72
+
73
+ def rails_tool_views_routes # :nodoc:
74
+ view = respond_to?(:rails_tool_views) || respond_to?(:main_app) ? self : helpers
75
+ view.respond_to?(:rails_tool_views) ? view.rails_tool_views : view.main_app.rails_tool_views
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # A committed action receipt used to replay requests without repeating handlers.
4
+ class ActionExecution < ActiveRecord::Base
5
+ self.table_name = "rails_tool_views_action_executions"
6
+ belongs_to :surface
7
+ has_one :agent_interaction, dependent: :destroy
8
+ self.filter_attributes += %i[result]
9
+ attribute :result, RedactedJson.new
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # A durable continuation request delivered after its action commits.
4
+ class AgentInteraction < ActiveRecord::Base
5
+ self.table_name = "rails_tool_views_agent_interactions"
6
+ belongs_to :surface
7
+ belongs_to :action_execution
8
+ self.filter_attributes += %i[payload]
9
+ attribute :payload, RedactedJson.new
10
+ # Returns the stable host deduplication key across delivery retries.
11
+ def delivery_key
12
+ "rails_tool_views:interaction:#{id}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # A durable acknowledgment correlating one scoped tool call with a Surface.
4
+ class Presentation < ActiveRecord::Base
5
+ self.table_name = "rails_tool_views_presentations"
6
+ belongs_to :surface
7
+ self.filter_attributes += %i[result]
8
+ attribute :result, RedactedJson.new
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # JSON column type whose database bind hides itself from SQL logs. Rails
4
+ # filters binds by attribute name alone, so listing `state` or `result` in the
5
+ # host-wide filter would also redact every host column that shares the name.
6
+ class RedactedJson < ActiveRecord::Type::Json
7
+ class Bind < String
8
+ def inspect
9
+ "[FILTERED]"
10
+ end
11
+ def as_json(*)
12
+ "[FILTERED]"
13
+ end
14
+ end
15
+ def serialize(value)
16
+ json = super
17
+ json && Bind.new(json)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ # A persisted UI snapshot whose read and action access is checked by RailsToolViews.
4
+ class Surface < ActiveRecord::Base
5
+ self.table_name = "rails_tool_views_surfaces"
6
+ self.filter_attributes += %i[props state]
7
+ attribute :props, RedactedJson.new, default: -> { {} }
8
+ attribute :state, RedactedJson.new, default: -> { {} }
9
+ has_many :presentations, dependent: :destroy
10
+ has_many :action_executions, dependent: :destroy
11
+ has_many :agent_interactions, dependent: :destroy
12
+ before_create { self.id ||= SecureRandom.uuid }
13
+ validates :actor_ref, :tenant_ref, :component_key, :component_digest, presence: true
14
+ # Returns the stable browser target identifier.
15
+ def dom_id
16
+ "rails_tool_views_surface_#{id}"
17
+ end
18
+ # Returns the UI projection; it is not a permission grant.
19
+ def snapshot
20
+ { id: id, component: component_key, component_digest: component_digest, props: props, state: state, revision: revision }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1 @@
1
+ <div class="rails-tool-views-error" role="alert" tabindex="-1" data-error-code="<%= error.code %>"><%= error.message %></div>
@@ -0,0 +1,19 @@
1
+ <section id="<%= surface.dom_id %>" class="rails-tool-views-surface" data-controller="rails-tool-views" data-rails-tool-views-revision-value="<%= surface.revision %>" data-rails-tool-views-saving-value="<%= rails_tool_views_text(:saving) %>" data-rails-tool-views-failed-value="<%= rails_tool_views_text(:save_failed) %>" aria-label="<%= rails_tool_views_component_name(definition.key) %>" aria-describedby="<%= surface.dom_id %>_status">
2
+ <%= rails_tool_views_body(surface, definition, error: error) %>
3
+ <% if error %>
4
+ <p id="<%= surface.dom_id %>_error" role="alert" tabindex="-1" data-rails-tool-views-target="error"><%= error.message %></p>
5
+ <% end %>
6
+ <p id="<%= surface.dom_id %>_status" role="status" aria-live="polite" tabindex="-1" data-rails-tool-views-target="status">
7
+ <%= surface.state["message"].is_a?(String) ? surface.state["message"] : rails_tool_views_text(surface.revision > 1 ? :saved : :ready) %>
8
+ </p>
9
+ <% interaction = surface.agent_interactions.order(:id).last %>
10
+ <% if interaction && interaction.status != "skipped" %>
11
+ <p role="status"><%= rails_tool_views_text({ "pending" => :agent_pending, "delivering" => :agent_pending, "completed" => :agent_completed, "failed" => :agent_failed }.fetch(interaction.status, :agent_unavailable)) %></p>
12
+ <% if interaction.status == "failed" %>
13
+ <%= form_with url: rails_tool_views_routes.surface_continuation_path(surface, interaction_id: interaction.id), method: :post do %>
14
+ <button type="submit"><%= rails_tool_views_text(:agent_retry) %></button>
15
+ <% end %>
16
+ <% end %>
17
+ <% end %>
18
+ <p class="rails-tool-views-revision"><%= rails_tool_views_text(:revision, revision: surface.revision) %></p>
19
+ </section>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ RailsToolViews::Engine.routes.draw do
2
+ get "surfaces/:id", to: "surfaces#show", as: :surface
3
+ post "surfaces/:id/actions", to: "surfaces#act", as: :surface_actions
4
+ post "surfaces/:id/continuations/:interaction_id", to: "surfaces#retry_agent", as: :surface_continuation
5
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ class CreateRailsToolViews < ActiveRecord::Migration[7.2]
3
+ # Identifier columns are bounded to 150 bytes in RailsToolViews::Data, and the
4
+ # value is stored in a byte-limited column. This keeps every index key within
5
+ # utf8mb4's 3072-byte limit on MySQL/InnoDB: the widest index is the five
6
+ # presentation columns below (5 * 150 * 4 = 3000 bytes).
7
+ def change
8
+ create_table :rails_tool_views_surfaces, id: :string do |t|
9
+ t.string :actor_ref, null: false, limit: 150
10
+ t.string :tenant_ref, null: false, limit: 150
11
+ t.string :context_ref, limit: 150
12
+ t.string :component_key, null: false
13
+ t.string :component_digest, null: false
14
+ t.integer :contract_version, default: 1, null: false
15
+ t.json :props, null: false
16
+ t.json :state, null: false
17
+ t.integer :revision, default: 1, null: false
18
+ t.integer :lock_version, default: 0, null: false
19
+ t.timestamps
20
+ end
21
+ add_index :rails_tool_views_surfaces, [:tenant_ref, :actor_ref]
22
+ create_table :rails_tool_views_presentations do |t|
23
+ t.references :surface, type: :string, foreign_key: { to_table: :rails_tool_views_surfaces }, null: false
24
+ t.string :actor_ref, null: false, limit: 150
25
+ t.string :tenant_ref, null: false, limit: 150
26
+ t.string :adapter, null: false, limit: 150
27
+ t.string :call_id, null: false, limit: 150
28
+ t.string :context_ref, null: false, limit: 150
29
+ t.string :request_digest, null: false
30
+ t.json :result, null: false
31
+ t.timestamps
32
+ end
33
+ add_index :rails_tool_views_presentations, [:tenant_ref, :actor_ref, :adapter, :call_id, :context_ref], unique: true, name: "index_rails_tool_views_call_scope"
34
+ create_table :rails_tool_views_action_executions do |t|
35
+ t.references :surface, type: :string, foreign_key: { to_table: :rails_tool_views_surfaces }, null: false
36
+ t.string :actor_ref, null: false, limit: 150
37
+ t.string :action_key, null: false
38
+ t.string :idempotency_key, null: false, limit: 150
39
+ t.string :request_digest, null: false
40
+ t.integer :base_revision, null: false
41
+ t.json :result, null: false
42
+ t.timestamps
43
+ end
44
+ add_index :rails_tool_views_action_executions, [:surface_id, :idempotency_key], unique: true, name: "index_rails_tool_views_action_replay"
45
+ create_table :rails_tool_views_agent_interactions do |t|
46
+ t.references :surface, type: :string, foreign_key: { to_table: :rails_tool_views_surfaces }, null: false
47
+ t.references :action_execution, foreign_key: { to_table: :rails_tool_views_action_executions }, null: false, index: { unique: true, name: "index_rails_tool_views_interaction_execution" }
48
+ t.json :payload, null: false
49
+ t.string :status, null: false, default: "pending"
50
+ t.string :error_code
51
+ t.datetime :claimed_at
52
+ t.integer :attempts, null: false, default: 0
53
+ t.timestamps
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+ require "rails/generators"
3
+ require "rails/generators/migration"
4
+ module RailsToolViews
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+ source_root File.expand_path("templates", __dir__)
9
+ def self.next_migration_number(dirname)
10
+ next_migration_number = current_migration_number(dirname) + 1
11
+ [Time.now.utc.strftime("%Y%m%d%H%M%S"), format("%014d", next_migration_number)].max
12
+ end
13
+ def install
14
+ migration_template "create_rails_tool_views.rb", "db/migrate/create_rails_tool_views.rb"
15
+ template "rails_tool_views.rb", "config/initializers/rails_tool_views.rb"
16
+ route 'mount RailsToolViews::Engine => "/rails_tool_views", as: :rails_tool_views'
17
+ end
18
+ end
19
+ end
20
+ end