rails_tool_views 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba277c8c403681504a1c5c2d154df3c7814952735ca5a10ba6981f7c50109a2d
4
- data.tar.gz: a6ebf8a7b47065ffaf8585d5f57b2aff864d181fe0e7deb0085c26500b640ef4
3
+ metadata.gz: 5f0b45e8093744ab3053cb621e1b8118ef6c618b98f907e8e88b9b16f5b36fba
4
+ data.tar.gz: '05959be24f98969c883260f0b06cef6815d812393bf7c250d0d1dea1cc552e7b'
5
5
  SHA512:
6
- metadata.gz: 276b0f741dc507376c153e900a0b20468e64cf55f66073be587eb4a0d4705bc0d8a4b2d75b5531382e80011a0bf193510acb143fbe2e6864028881422509e461
7
- data.tar.gz: e216675b6918e72cdbaf75a4404e7729bac2186260cbc06a500a8130c7cacaa1312d2e436bcbe085b159534a60adc6c1898673319fd718b456fab32ddd9a8f38
6
+ metadata.gz: 57ab0c782013453cc510f47402fcdae2b934a06fe69760b45411466b543c851d7b8b84e653bcef7824f3267f97319783e712754b7c321b9bd844826645f94afe
7
+ data.tar.gz: 2d741cf1b44e389bb3cc7a00bcf1de0da70ef3377077950fed1c4a166d9a62d7ac3ae339550779616b2c414ed23b8612531b2d2fda01f10e462cbc40d7ba6b5b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-10-19
4
+
5
+ - Opt-in scoped `purge_closed` preview/apply with deadlines, bounded batches, atomic receipt cleanup and durable correlation tombstones. Host business records stay intact.
6
+
7
+ - Add explicit host-authorized `close` and `abandon`, with read-only closed rendering, preserved exact action replay and first-termination retention deadlines.
8
+ - Add lifecycle snapshot metadata and an additive migration. Stop old workers before upgrading; rollback refuses once a Surface was closed.
9
+ - Coordinate delivery claims and lifecycle changes through the Surface lock while keeping callbacks outside transactions. Quarantine ambiguous lease takeovers and legacy multiple-attempt histories from cleanup.
10
+
11
+ ## [0.2.0] - 2026-10-18
12
+
13
+ - Add `rails_tool_views:component NAME` for a Partial, closed registration schemas and a Minitest scaffold. Component and action policies deny by default. Existing paths, unsafe names and symlink escapes are refused before writing.
14
+ - Render complete Engine HTML read/error pages with accessible document structure, authorized correction fields and a saved-view reload link. Embedded helpers and JSON/Turbo retain their formats.
15
+ - Fix redirects after correcting a non-Turbo error when the referrer is a POST-only Engine route.
16
+ - Add real Chrome E2E tests, including JavaScript-disabled mobile correction, fixture screenshots and a browser CI job. Keep browser dependencies in a development-only bundle.
17
+ - Exercise the installed gem and generated scaffold in a temporary Rails host, both with developer dependencies and an isolated network-resolved GEM_HOME.
18
+ - No database migration or new runtime dependency.
19
+
3
20
  ## [0.1.0] - 2026-10-18
4
21
 
5
22
  First public release. Earlier version labels below describe unpublished development milestones.
data/README.md CHANGED
@@ -1,90 +1,34 @@
1
1
  # RailsToolViews
2
2
 
3
- **Turn trusted Rails views into persistent, interactive tools.**
3
+ Turn trusted Rails Partials and ViewComponents into persistent, interactive tools. Your Rails application owns authentication, permissions and business data. RubyLLM integration is optional.
4
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.
5
+ [Documentation](https://rails-tool-views.wangyaodi.com) · [Getting started](https://rails-tool-views.wangyaodi.com/getting-started/) · [API](https://rails-tool-views.wangyaodi.com/api/) · [Roadmap](ROADMAP.md)
6
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.
7
+ ## Install
8
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
- ```
9
+ Requires Ruby 3.3 or later and a patched Rails release.
41
10
 
42
11
  ```sh
43
- bundle install
12
+ bundle add rails_tool_views
44
13
  bin/rails generate rails_tool_views:install
45
14
  bin/rails db:migrate
46
15
  ```
47
16
 
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
- ```
17
+ Continue with [Getting Started](https://rails-tool-views.wangyaodi.com/getting-started/) to configure authenticated scope and register your first component. Component and action policies deny access until you configure them.
58
18
 
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.
19
+ ## Try the demo
60
20
 
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
21
+ From this checkout:
75
22
 
76
23
  ```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
24
+ bundle install
25
+ bin/demo
82
26
  ```
83
27
 
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.
28
+ Open http://127.0.0.1:3100 and stop with Ctrl-C. The demo covers choices, customer forms, support and content review using offline fixtures. See the [walkthrough](https://rails-tool-views.wangyaodi.com/demo/).
85
29
 
86
- ## Project status and attribution
30
+ ## Contribute
87
31
 
88
- Released under the [MIT license](LICENSE.txt).
32
+ Read [Contributing](CONTRIBUTING.md) for development and documentation commands. Report vulnerabilities through the [private security channel](SECURITY.md).
89
33
 
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).
34
+ Released under the [MIT license](LICENSE.txt). RailsToolViews is an unofficial community project, without affiliation or endorsement. Rails marks belong to David Heinemeier Hansson and are exclusively licensed to the Rails Foundation; see the [trademark policy](https://rubyonrails.org/trademarks).
@@ -23,7 +23,7 @@ module RailsToolViews
23
23
  surface = RailsToolViews.restore(params[:id], scope: current_scope)
24
24
  respond_to do |format|
25
25
  format.json { render json: surface.snapshot }
26
- format.html { render html: RailsToolViews.render(surface, view_context: view_context, scope: current_scope) }
26
+ format.html { render_surface_page(surface) }
27
27
  end
28
28
  end
29
29
 
@@ -88,9 +88,29 @@ module RailsToolViews
88
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
89
  # Without Turbo, return to the page that embeds the Surface; it re-renders saved state.
90
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 }
91
+ format.html { redirect_after_action(surface) }
92
92
  end
93
93
  end
94
+ def redirect_after_action(surface)
95
+ # After correcting a full-page error, Referer can be a POST-only Engine
96
+ # route. Redirect to the readable Surface instead of GET /actions (404).
97
+ referrer_path = begin
98
+ URI.parse(request.referer.to_s).path.to_s
99
+ rescue URI::InvalidURIError
100
+ ""
101
+ end
102
+ if referrer_path.start_with?("#{surface_path(surface)}/")
103
+ redirect_to surface_path(surface), status: :see_other
104
+ else
105
+ redirect_back_or_to surface_path(surface), allow_other_host: false, status: :see_other
106
+ end
107
+ end
108
+ def render_surface_page(surface, error: nil)
109
+ html = surface && RailsToolViews.render(surface, view_context: view_context, scope: current_scope, error: error)
110
+ render template: "rails_tool_views/surfaces/page", layout: "rails_tool_views/application",
111
+ locals: { surface_html: html, reload_path: surface && surface_path(surface), error: error },
112
+ status: error ? error.status : :ok
113
+ end
94
114
  def reject_request(error)
95
115
  respond_to do |format|
96
116
  format.json { render json: error.to_h, status: error.status }
@@ -107,9 +127,9 @@ module RailsToolViews
107
127
  format.html do
108
128
  begin
109
129
  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
130
+ render_surface_page(surface, error: error)
111
131
  rescue RailsToolViews::Error
112
- render partial: "rails_tool_views/surfaces/error", locals: { error: error }, status: error.status
132
+ render_surface_page(nil, error: error)
113
133
  end
114
134
  end
115
135
  end
@@ -4,6 +4,9 @@ module RailsToolViews
4
4
  module SurfacesHelper
5
5
  # Engine-owned copy. Translate or reword any of it under rails_tool_views.surface.<key>.
6
6
  TEXT = {
7
+ page_title: "Interactive view", page_error_title: "Unable to complete this request", reload: "Reload saved view",
8
+ closed_title: "View closed.", abandoned_title: "View abandoned.", closed_help: "Saved actions are read-only. Start a new view to continue.",
9
+ agent_abandoned: "AI follow-up abandoned by the application.",
7
10
  ready: "View ready.", saved: "Changes saved.", revision: "Saved revision %{revision}",
8
11
  saving: "Saving changes…", save_failed: "Changes could not be saved. Review the message and try again.",
9
12
  agent_pending: "AI follow-up pending.", agent_completed: "AI follow-up completed.",
@@ -55,6 +58,7 @@ module RailsToolViews
55
58
  end
56
59
 
57
60
  def rails_tool_views_body(surface, definition, error: nil) # :nodoc:
61
+ return render(partial: "rails_tool_views/surfaces/closed", formats: [:html], locals: { surface: surface }) if surface.closed?
58
62
  html = if definition.partial
59
63
  render(partial: definition.partial, locals: { props: surface.props, state: surface.state, surface: surface, error: error })
60
64
  else
@@ -6,6 +6,8 @@ module RailsToolViews
6
6
  belongs_to :surface
7
7
  belongs_to :action_execution
8
8
  self.filter_attributes += %i[payload]
9
+ # Rails 7.2 cannot infer a SQLite boolean default written by Rails 8.1.
10
+ attribute :delivery_uncertain, :boolean, default: false
9
11
  attribute :payload, RedactedJson.new
10
12
  # Returns the stable host deduplication key across delivery retries.
11
13
  def delivery_key
@@ -15,9 +15,25 @@ module RailsToolViews
15
15
  def dom_id
16
16
  "rails_tool_views_surface_#{id}"
17
17
  end
18
+ # True once the host closes or abandons the view. Closed views cannot reopen.
19
+ def closed?
20
+ closed_at.present?
21
+ end
22
+ # True when payload cleanup has retained only the durable tombstone.
23
+ def purged?
24
+ purged_at.present?
25
+ end
26
+ # Returns the lifecycle independently of domain record or delivery status.
27
+ def lifecycle_status
28
+ return "purged" if purged?
29
+ return "abandoned" if abandoned_at
30
+ closed? ? "closed" : "active"
31
+ end
18
32
  # Returns the UI projection; it is not a permission grant.
19
33
  def snapshot
20
- { id: id, component: component_key, component_digest: component_digest, props: props, state: state, revision: revision }
34
+ { id: id, component: component_key, component_digest: component_digest, props: props, state: state, revision: revision,
35
+ lifecycle: { status: lifecycle_status, closed_at: closed_at&.iso8601(6),
36
+ retain_until: retain_until&.iso8601(6), purged_at: purged_at&.iso8601(6) } }
21
37
  end
22
38
  end
23
39
  end
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="<%= I18n.locale %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= rails_tool_views_text(error ? :page_error_title : :page_title) %></title>
7
+ <%= csrf_meta_tags %>
8
+ <style>
9
+ .rails-tool-views-page { box-sizing: border-box; max-width: 48rem; margin: 2rem auto; padding: 0 1rem; font: 1rem/1.6 system-ui, sans-serif; overflow-wrap: anywhere; }
10
+ .rails-tool-views-page * { box-sizing: border-box; }
11
+ .rails-tool-views-page input, .rails-tool-views-page textarea, .rails-tool-views-page select { max-width: 100%; font: inherit; }
12
+ .rails-tool-views-page label { display: block; }
13
+ .rails-tool-views-page fieldset { min-width: 0; }
14
+ .rails-tool-views-page button, .rails-tool-views-page input[type=submit] { padding: .5rem 1rem; font: inherit; cursor: pointer; }
15
+ .rails-tool-views-page [role=alert], .rails-tool-views-page [aria-invalid=true] { color: #a51d1d; }
16
+ .rails-tool-views-page :focus-visible { outline: 3px solid #215dc4; outline-offset: 3px; }
17
+ </style>
18
+ </head>
19
+ <body class="rails-tool-views-page">
20
+ <main aria-labelledby="rails-tool-views-page-title">
21
+ <%= yield %>
22
+ </main>
23
+ </body>
24
+ </html>
@@ -0,0 +1,2 @@
1
+ <h2><%= rails_tool_views_text(surface.abandoned_at ? :abandoned_title : :closed_title) %></h2>
2
+ <p><%= rails_tool_views_text(:closed_help) %></p>
@@ -8,7 +8,7 @@
8
8
  </p>
9
9
  <% interaction = surface.agent_interactions.order(:id).last %>
10
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>
11
+ <p role="status"><%= rails_tool_views_text({ "pending" => :agent_pending, "delivering" => :agent_pending, "completed" => :agent_completed, "failed" => :agent_failed, "abandoned" => :agent_abandoned }.fetch(interaction.status, :agent_unavailable)) %></p>
12
12
  <% if interaction.status == "failed" %>
13
13
  <%= form_with url: rails_tool_views_routes.surface_continuation_path(surface, interaction_id: interaction.id), method: :post do %>
14
14
  <button type="submit"><%= rails_tool_views_text(:agent_retry) %></button>
@@ -0,0 +1,9 @@
1
+ <h1 id="rails-tool-views-page-title"><%= rails_tool_views_text(error ? :page_error_title : :page_title) %></h1>
2
+ <% if surface_html %>
3
+ <%= surface_html %>
4
+ <% elsif error %>
5
+ <%= render partial: "rails_tool_views/surfaces/error", locals: { error: error } %>
6
+ <% end %>
7
+ <% if error && reload_path %>
8
+ <p><%= link_to rails_tool_views_text(:reload), reload_path %></p>
9
+ <% end %>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ class AddRailsToolViewsLifecycle < ActiveRecord::Migration[7.2]
3
+ def up
4
+ add_column :rails_tool_views_surfaces, :closed_at, :datetime
5
+ add_column :rails_tool_views_surfaces, :abandoned_at, :datetime
6
+ add_column :rails_tool_views_surfaces, :retain_until, :datetime
7
+ add_column :rails_tool_views_surfaces, :purged_at, :datetime
8
+ add_index :rails_tool_views_surfaces, [:tenant_ref, :actor_ref, :closed_at], name: "index_rails_tool_views_retention_scope"
9
+ add_column :rails_tool_views_agent_interactions, :delivery_uncertain, :boolean, default: false, null: false
10
+ # Old rows do not distinguish explicit failed retries from lease takeovers.
11
+ # Preserve uncertainty instead of assuming an old callback has stopped.
12
+ execute "UPDATE rails_tool_views_agent_interactions SET delivery_uncertain = #{connection.quote(true)} WHERE attempts > 1 OR status = 'delivering'"
13
+ end
14
+
15
+ def down
16
+ if select_value("SELECT COUNT(*) FROM rails_tool_views_surfaces WHERE closed_at IS NOT NULL").to_i.positive?
17
+ raise ActiveRecord::IrreversibleMigration, "Closed or purged Surfaces cannot be safely reopened by an older runtime. Restore a pre-upgrade backup instead."
18
+ end
19
+ remove_column :rails_tool_views_agent_interactions, :delivery_uncertain
20
+ remove_index :rails_tool_views_surfaces, name: "index_rails_tool_views_retention_scope"
21
+ %i[closed_at abandoned_at retain_until purged_at].each { |column| remove_column :rails_tool_views_surfaces, column }
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ require "rails/generators"
3
+
4
+ module RailsToolViews
5
+ module Generators
6
+ # Generates a trusted Partial definition with closed schemas and denied policies.
7
+ class ComponentGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+ argument :name, type: :string, required: true, desc: "Flat snake_case component key"
10
+ desc "Generate a Partial, registration and Minitest scaffold. Policies deny access until configured."
11
+
12
+ # Preflights all destinations, then writes the registration, Partial and test.
13
+ def create_component
14
+ unless name.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
15
+ raise Thor::Error, "Use a flat snake_case name of 1 to 64 characters, starting with a letter."
16
+ end
17
+ outputs = {
18
+ "registration.rb" => "config/rails_tool_views/components/#{name}.rb",
19
+ "partial.html.erb" => "app/views/rails_tool_views/components/_#{name}.html.erb",
20
+ "component_test.rb" => "test/rails_tool_views/components/#{name}_test.rb"
21
+ }
22
+ # Preflight every output before writing any file. Even --force must not
23
+ # destroy host code or follow a symlink outside the destination root.
24
+ root = File.realpath(destination_root)
25
+ outputs.each_value do |relative|
26
+ path = File.join(root, relative)
27
+ if File.exist?(path) || File.symlink?(path)
28
+ raise Thor::Error, "Destination exists: #{relative}. Resolve the conflict before generating."
29
+ end
30
+ parent = File.dirname(path)
31
+ parent = File.dirname(parent) until File.exist?(parent) || File.symlink?(parent)
32
+ begin
33
+ real = File.realpath(parent)
34
+ rescue Errno::ENOENT, Errno::ELOOP
35
+ raise Thor::Error, "Invalid destination parent: #{relative}"
36
+ end
37
+ unless File.directory?(real) && (real == root || real.start_with?(root + File::SEPARATOR))
38
+ raise Thor::Error, "Destination escapes the application: #{relative}"
39
+ end
40
+ end
41
+ outputs.each { |source, target| template(source, target) }
42
+ say "Load config/rails_tool_views/components/*.rb inside your shared to_prepare block after reset_registry!."
43
+ say "Replace BOTH denied policies with host authorization before presenting this component."
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ require "test_helper"
3
+
4
+ class <%= name.camelize %>ComponentTest < ActiveSupport::TestCase
5
+ test "<%= name %> denies presentation until host policies are configured" do
6
+ error = assert_raises(RailsToolViews::Error) do
7
+ RailsToolViews.present(:<%= name %>, props: { title: "Review" },
8
+ scope: RailsToolViews::Scope.new(actor: "test-user", tenant: "test-tenant"))
9
+ end
10
+ assert_equal "NOT_ALLOWED", error.code
11
+ end
12
+
13
+ # Replace the denial test when you configure host policies. Add an allowed
14
+ # actor/tenant, a denied actor/tenant, action validation and persisted-state tests.
15
+ end
@@ -0,0 +1,6 @@
1
+ <h2><%%= props.fetch("title") %></h2>
2
+ <%%= rails_tool_views_action_form(surface, :set_value) do |form| %>
3
+ <%%= form.label :value, "Value" %>
4
+ <%%= form.text_field :value, value: state["value"], maxlength: 120 %>
5
+ <%%= form.submit "Save" %>
6
+ <%% end %>
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ # Load from the host's shared to_prepare block. Do not reset the registry here.
3
+ RailsToolViews.register(:<%= name %>,
4
+ description: "<%= name.humanize %>",
5
+ partial: "rails_tool_views/components/<%= name %>",
6
+ schema: {
7
+ type: "object", additionalProperties: false, required: ["title"],
8
+ properties: { title: { type: "string", maxLength: 120 } }
9
+ },
10
+ # Replace with host authorization using access.actor and access.tenant.
11
+ policy: ->(_access) { false },
12
+ actions: {
13
+ set_value: {
14
+ schema: {
15
+ type: "object", additionalProperties: false, required: ["value"],
16
+ properties: { value: { type: "string", maxLength: 120 } }
17
+ },
18
+ # Recheck host authorization and trusted allowed values on every action.
19
+ policy: ->(_access) { false },
20
+ handler: ->(access) {
21
+ RailsToolViews::Transition.new(state: { value: access.arguments.fetch("value") })
22
+ }
23
+ }
24
+ })
@@ -12,6 +12,7 @@ module RailsToolViews
12
12
  end
13
13
  def install
14
14
  migration_template "create_rails_tool_views.rb", "db/migrate/create_rails_tool_views.rb"
15
+ migration_template "add_rails_tool_views_lifecycle.rb", "db/migrate/add_rails_tool_views_lifecycle.rb"
15
16
  template "rails_tool_views.rb", "config/initializers/rails_tool_views.rb"
16
17
  route 'mount RailsToolViews::Engine => "/rails_tool_views", as: :rails_tool_views'
17
18
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ class AddRailsToolViewsLifecycle < ActiveRecord::Migration[7.2]
3
+ def up
4
+ add_column :rails_tool_views_surfaces, :closed_at, :datetime
5
+ add_column :rails_tool_views_surfaces, :abandoned_at, :datetime
6
+ add_column :rails_tool_views_surfaces, :retain_until, :datetime
7
+ add_column :rails_tool_views_surfaces, :purged_at, :datetime
8
+ add_index :rails_tool_views_surfaces, [:tenant_ref, :actor_ref, :closed_at], name: "index_rails_tool_views_retention_scope"
9
+ add_column :rails_tool_views_agent_interactions, :delivery_uncertain, :boolean, default: false, null: false
10
+ # Old rows do not distinguish explicit failed retries from lease takeovers.
11
+ # Preserve uncertainty instead of assuming an old callback has stopped.
12
+ execute "UPDATE rails_tool_views_agent_interactions SET delivery_uncertain = #{connection.quote(true)} WHERE attempts > 1 OR status = 'delivering'"
13
+ end
14
+
15
+ def down
16
+ if select_value("SELECT COUNT(*) FROM rails_tool_views_surfaces WHERE closed_at IS NOT NULL").to_i.positive?
17
+ raise ActiveRecord::IrreversibleMigration, "Closed or purged Surfaces cannot be safely reopened by an older runtime. Restore a pre-upgrade backup instead."
18
+ end
19
+ remove_column :rails_tool_views_agent_interactions, :delivery_uncertain
20
+ remove_index :rails_tool_views_surfaces, name: "index_rails_tool_views_retention_scope"
21
+ %i[closed_at abandoned_at retain_until purged_at].each { |column| remove_column :rails_tool_views_surfaces, column }
22
+ end
23
+ end
@@ -9,4 +9,5 @@ RailsToolViews.configure do |config|
9
9
  # config.parent_controller = "::ApplicationController"
10
10
  end
11
11
  # Register trusted components and explicit policies in Rails.application.config.to_prepare.
12
- # Read the gem README for a complete ChoiceCard definition and optional adapter.
12
+ # Generate a safe scaffold: bin/rails generate rails_tool_views:component review_card
13
+ # See https://rails-tool-views.wangyaodi.com/component-generator/ for registration and policies.
@@ -13,6 +13,11 @@ module RailsToolViews
13
13
  MESSAGES = {
14
14
  "NOT_FOUND" => "This view is unavailable.", "NOT_ALLOWED" => "You do not have permission to use this view.",
15
15
  "CONFLICT" => "This view changed. Refresh to continue.", "INVALID_ARGUMENTS" => "Check the submitted values and try again.",
16
+ "SURFACE_CLOSED" => "This view is closed. Its saved actions cannot be changed.",
17
+ "SURFACE_PURGED" => "This view has been cleaned up and cannot be restored.",
18
+ "LIFECYCLE_BUSY" => "This view has unfinished or uncertain delivery. Resolve it before closing.",
19
+ "INVALID_RETENTION_REQUEST" => "Choose an explicit past cutoff and a batch size from 1 to 1000.",
20
+ "INVALID_RETENTION_WINDOW" => "Choose a future retention deadline or keep this view indefinitely.",
16
21
  "VALIDATION_FAILED" => "Check the highlighted fields and try again.",
17
22
  "INCOMPATIBLE_COMPONENT" => "This view's contract changed. Ask the application owner to migrate or recreate it.",
18
23
  "RENDER_FAILED" => "This view cannot be rendered. Your saved state is preserved.",
@@ -249,7 +254,7 @@ module RailsToolViews
249
254
  # Process-local host hooks, request limits, and delivery settings.
250
255
  class Configuration
251
256
  attr_accessor :scope_resolver, :agent_adapter, :max_payload_bytes, :max_depth, :max_catalog_components, :max_actions_per_minute, :auto_deliver,
252
- :parent_controller, :delivery_lease
257
+ :parent_controller, :delivery_lease, :retention_policy
253
258
  # Initializes conservative request limits with no host identity or agent adapter.
254
259
  def initialize
255
260
  @max_payload_bytes, @max_depth, @max_catalog_components, @max_actions_per_minute = 32_768, 16, 20, 60
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ class << self
4
+ # Closes a view after host authorization. Outstanding continuation work blocks
5
+ # closure. nil retain_until keeps data indefinitely; the first closure wins.
6
+ def close(id, scope:, retain_until: nil)
7
+ finish_surface(id, scope: scope, retain_until: retain_until, abandon: false)
8
+ end
9
+
10
+ # Explicitly abandons pending/failed continuation work before closing a view.
11
+ # In-flight or historically uncertain delivery is never abandoned implicitly.
12
+ def abandon(id, scope:, retain_until: nil)
13
+ finish_surface(id, scope: scope, retain_until: retain_until, abandon: true)
14
+ end
15
+
16
+ # Scrubs eligible closed payloads in one bounded batch, preserving replay
17
+ # tombstones. Requires an explicit scope, cutoff and host retention policy.
18
+ # Defaults to a read-only preview. Each applied Surface commits atomically.
19
+ def purge_closed(scope:, closed_before:, batch_size: 100, dry_run: true)
20
+ check_scope!(scope)
21
+ valid_time = closed_before.is_a?(Time) || closed_before.is_a?(ActiveSupport::TimeWithZone)
22
+ unless valid_time && closed_before <= Time.current && batch_size.is_a?(Integer) && batch_size.between?(1, 1000) && [true, false].any? { |v| dry_run.equal?(v) }
23
+ raise Error.new("INVALID_RETENTION_REQUEST")
24
+ end
25
+ policy = configuration.retention_policy
26
+ raise Error.new("NOT_ALLOWED", status: 403) unless policy.respond_to?(:call)
27
+ now = Time.current
28
+ candidates = Surface.where(actor_ref: scope.actor, tenant_ref: scope.tenant, purged_at: nil)
29
+ .where("closed_at < ? AND retain_until <= ?", closed_before, now).order(:closed_at, :id).limit(batch_size).pluck(:id)
30
+ counts = { examined: 0, eligible: 0, purged: 0, protected: 0, dry_run: dry_run }
31
+ candidates.each do |id|
32
+ surface = Surface.find_by(id: id)
33
+ next unless surface
34
+ surface.with_lock do
35
+ counts[:examined] += 1
36
+ eligible = exact_match?(surface, actor_ref: scope.actor, tenant_ref: scope.tenant) &&
37
+ !surface.purged? && surface.closed? && surface.closed_at < closed_before &&
38
+ surface.retain_until && surface.retain_until <= now
39
+ if eligible
40
+ request = access(scope, :purge, surface: surface, props: surface.props, context_ref: surface.context_ref)
41
+ eligible = policy.call(request) == true && !protected_interactions?(surface, abandon: false)
42
+ end
43
+ unless eligible
44
+ counts[:protected] += 1
45
+ next
46
+ end
47
+ counts[:eligible] += 1
48
+ next if dry_run
49
+ # The Surface lock excludes action commits, delivery claims and other
50
+ # cleanup workers. Only known terminal children are removed together.
51
+ surface.agent_interactions.delete_all
52
+ surface.action_executions.delete_all
53
+ surface.update!(props: {}, state: {}, context_ref: nil, purged_at: now)
54
+ counts[:purged] += 1
55
+ end
56
+ end
57
+ counts
58
+ end
59
+
60
+ private
61
+ def finish_surface(id, scope:, retain_until:, abandon:)
62
+ validate_retention_deadline!(retain_until, future: false)
63
+ surface = restore(id, scope: scope)
64
+ surface.with_lock do
65
+ ensure_not_purged!(surface)
66
+ component = compatible_component!(surface)
67
+ component.authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
68
+ component.authorize!(access(scope, abandon ? :abandon : :close, surface: surface, props: surface.props, context_ref: surface.context_ref))
69
+ next if surface.closed?
70
+ validate_retention_deadline!(retain_until, future: true)
71
+ interactions = surface.agent_interactions
72
+ raise Error.new("LIFECYCLE_BUSY", status: 409) if protected_interactions?(surface, abandon: abandon)
73
+ if abandon
74
+ interactions.where(status: %w[pending failed]).update_all(status: "abandoned", claimed_at: nil, error_code: nil, updated_at: Time.current)
75
+ end
76
+ now = Time.current
77
+ surface.update!(closed_at: now, abandoned_at: abandon ? now : nil, retain_until: retain_until, revision: surface.revision + 1)
78
+ end
79
+ surface
80
+ end
81
+ def protected_interactions?(surface, abandon:)
82
+ interactions = surface.agent_interactions
83
+ allowed = abandon ? %w[completed skipped pending failed abandoned] : %w[completed skipped abandoned]
84
+ interactions.where(delivery_uncertain: true).exists? || interactions.where.not(status: allowed).exists?
85
+ end
86
+ def validate_retention_deadline!(deadline, future:)
87
+ return if deadline.nil?
88
+ valid_type = deadline.is_a?(Time) || deadline.is_a?(ActiveSupport::TimeWithZone)
89
+ raise Error.new("INVALID_RETENTION_WINDOW") unless valid_type && (!future || deadline > Time.current)
90
+ end
91
+ end
92
+ end
@@ -44,6 +44,7 @@ module RailsToolViews
44
44
  # match may stand in for this scope's Surface, or a lookup could disclose
45
45
  # another actor's or tenant's record.
46
46
  raise Error.new("NOT_FOUND", status: 404) unless surface && exact_match?(surface, id: id.to_s, actor_ref: scope.actor, tenant_ref: scope.tenant)
47
+ ensure_not_purged!(surface)
47
48
  component = compatible_component!(surface)
48
49
  component.authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
49
50
  surface
@@ -62,6 +63,7 @@ module RailsToolViews
62
63
  request_digest = Data.digest(action: definition.key, arguments: arguments, base_revision: base_revision)
63
64
  result = nil
64
65
  surface.with_lock do
66
+ ensure_not_purged!(surface)
65
67
  compatible_component!(surface).authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
66
68
  request = access(scope, :action, surface: surface, props: surface.props, action: definition.key,
67
69
  arguments: arguments, context_ref: surface.context_ref)
@@ -74,6 +76,7 @@ module RailsToolViews
74
76
  result = prior.result.symbolize_keys
75
77
  next
76
78
  end
79
+ raise Error.new("SURFACE_CLOSED", status: 410) if surface.closed?
77
80
  raise Error.new("CONFLICT", status: 409) unless surface.revision == base_revision
78
81
  if surface.action_executions.where("created_at >= ?", 1.minute.ago).count >= configuration.max_actions_per_minute
79
82
  raise Error.new("RATE_LIMITED", status: 429)
@@ -114,25 +117,35 @@ module RailsToolViews
114
117
  interaction = AgentInteraction.find_by(id: id)
115
118
  raise Error.new("NOT_FOUND", status: 404) unless interaction
116
119
  surface = restore(interaction.surface_id, scope: scope)
117
- definition = compatible_component!(surface).actions.fetch(interaction.payload.fetch("action")) { raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) }
118
- arguments = definition.schema.validate!(interaction.payload.fetch("values"), code: "INVALID_ARGUMENTS")
119
- request = access(scope, :action, surface: surface, props: surface.props, action: definition.key, arguments: arguments, context_ref: surface.context_ref)
120
- raise Error.new("NOT_ALLOWED", status: 403) unless definition.policy&.call(request) == true
121
120
  claimed = false
122
- interaction.with_lock do
123
- if %w[completed skipped].include?(interaction.status)
124
- next
125
- end
126
- if interaction.status == "delivering" && interaction.claimed_at && interaction.claimed_at > Time.current - configuration.delivery_lease
127
- next
128
- end
129
- next if interaction.status == "failed" && !retry_failed
130
- if adapter.nil?
131
- interaction.update!(status: "skipped")
132
- next
121
+ # Coordinate the claim with close/abandon/purge, using the same lock order
122
+ # as actions. Never hold a database transaction during the adapter call.
123
+ surface.with_lock do
124
+ ensure_not_purged!(surface)
125
+ component = compatible_component!(surface)
126
+ component.authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
127
+ interaction.with_lock do
128
+ definition = component.actions.fetch(interaction.payload.fetch("action")) { raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) }
129
+ arguments = definition.schema.validate!(interaction.payload.fetch("values"), code: "INVALID_ARGUMENTS")
130
+ request = access(scope, :action, surface: surface, props: surface.props, action: definition.key, arguments: arguments, context_ref: surface.context_ref)
131
+ raise Error.new("NOT_ALLOWED", status: 403) unless definition.policy&.call(request) == true
132
+ next if %w[completed skipped abandoned].include?(interaction.status)
133
+ raise Error.new("SURFACE_CLOSED", status: 410) if surface.closed?
134
+ if interaction.status == "delivering" && interaction.claimed_at && interaction.claimed_at > Time.current - configuration.delivery_lease
135
+ next
136
+ end
137
+ next if interaction.status == "failed" && !retry_failed
138
+ # Reclaiming a lease does not prove that the previous callback ended.
139
+ # This durable flag cannot be cleared by a stale worker's completion.
140
+ uncertain = interaction.delivery_uncertain? || interaction.status == "delivering"
141
+ if adapter.nil?
142
+ interaction.update!(status: "skipped", claimed_at: nil, delivery_uncertain: uncertain)
143
+ next
144
+ end
145
+ interaction.update!(status: "delivering", claimed_at: Time.current, attempts: interaction.attempts + 1,
146
+ error_code: nil, delivery_uncertain: uncertain)
147
+ claimed = true
133
148
  end
134
- interaction.update!(status: "delivering", claimed_at: Time.current, attempts: interaction.attempts + 1, error_code: nil)
135
- claimed = true
136
149
  end
137
150
  return interaction.reload unless claimed
138
151
  begin
@@ -190,6 +203,9 @@ module RailsToolViews
190
203
  end
191
204
 
192
205
  private
206
+ def ensure_not_purged!(surface)
207
+ raise Error.new("SURFACE_PURGED", status: 410) if surface.purged?
208
+ end
193
209
  def check_scope!(scope)
194
210
  raise Error.new("INVALID_SCOPE", status: 403) unless scope.is_a?(Scope)
195
211
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module RailsToolViews
3
- VERSION = "0.1.0"
3
+ VERSION = "0.3.0"
4
4
  end
@@ -3,4 +3,5 @@ require_relative "rails_tool_views/core"
3
3
  require "rails"
4
4
  require "rails/engine"
5
5
  require_relative "rails_tool_views/runtime"
6
+ require_relative "rails_tool_views/lifecycle"
6
7
  require_relative "rails_tool_views/engine"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_tool_views
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Wang
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -178,17 +178,27 @@ files:
178
178
  - app/models/rails_tool_views/presentation.rb
179
179
  - app/models/rails_tool_views/redacted_json.rb
180
180
  - app/models/rails_tool_views/surface.rb
181
+ - app/views/layouts/rails_tool_views/application.html.erb
182
+ - app/views/rails_tool_views/surfaces/_closed.html.erb
181
183
  - app/views/rails_tool_views/surfaces/_error.html.erb
182
184
  - app/views/rails_tool_views/surfaces/_surface.html.erb
185
+ - app/views/rails_tool_views/surfaces/page.html.erb
183
186
  - config/routes.rb
184
187
  - db/migrate/20261010000000_create_rails_tool_views.rb
188
+ - db/migrate/20261019000000_add_rails_tool_views_lifecycle.rb
189
+ - lib/generators/rails_tool_views/component/component_generator.rb
190
+ - lib/generators/rails_tool_views/component/templates/component_test.rb
191
+ - lib/generators/rails_tool_views/component/templates/partial.html.erb
192
+ - lib/generators/rails_tool_views/component/templates/registration.rb
185
193
  - lib/generators/rails_tool_views/install/install_generator.rb
194
+ - lib/generators/rails_tool_views/install/templates/add_rails_tool_views_lifecycle.rb
186
195
  - lib/generators/rails_tool_views/install/templates/create_rails_tool_views.rb
187
196
  - lib/generators/rails_tool_views/install/templates/rails_tool_views.rb
188
197
  - lib/rails_tool_views.rb
189
198
  - lib/rails_tool_views/adapters/ruby_llm.rb
190
199
  - lib/rails_tool_views/core.rb
191
200
  - lib/rails_tool_views/engine.rb
201
+ - lib/rails_tool_views/lifecycle.rb
192
202
  - lib/rails_tool_views/runtime.rb
193
203
  - lib/rails_tool_views/version.rb
194
204
  homepage: https://github.com/yorzi/rails_tool_views