rails_tool_views 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f0b45e8093744ab3053cb621e1b8118ef6c618b98f907e8e88b9b16f5b36fba
4
- data.tar.gz: '05959be24f98969c883260f0b06cef6815d812393bf7c250d0d1dea1cc552e7b'
3
+ metadata.gz: 870437bec297c23b8c6dcda437fba7d02d196bcdd1b4abcaf0df3a207e689cca
4
+ data.tar.gz: c4af0245f4f5d7e7e94b28b225c5da2c69e7b6205e56529e0067f481faa49c39
5
5
  SHA512:
6
- metadata.gz: 57ab0c782013453cc510f47402fcdae2b934a06fe69760b45411466b543c851d7b8b84e653bcef7824f3267f97319783e712754b7c321b9bd844826645f94afe
7
- data.tar.gz: 2d741cf1b44e389bb3cc7a00bcf1de0da70ef3377077950fed1c4a166d9a62d7ac3ae339550779616b2c414ed23b8612531b2d2fda01f10e462cbc40d7ba6b5b
6
+ metadata.gz: 50b36ef77c8eae5e3f7cf515a610bb2ded488010b031a462c8ef8e18836879b6174cea0ac3d53d7fdb07a71eebe2882f18982bb5c19906c8b90e3a815b7857b1
7
+ data.tar.gz: e6247b022577c845a32658d3a05f46caefc38e1bbae9bffcecb7a85726a7c30f0f731eca622ae60d94953b49cde35919712c7842037baeb65858a324b20c7293
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-10-20
4
+
5
+ - Emit the existing metadata-only `action.conflict` event when database rollback or lock contention maps to a retryable conflict.
6
+ - Keep `action.executed` and add `replayed` metadata to distinguish exact receipt replay from handler execution.
7
+ - Add optional bounded plain-text `Transition#notice`, rendered for the action response through JSON/Turbo or scoped HTML flash. Refresh drops it; action receipts retain exact responses. Existing persistent `state["message"]` is unchanged.
8
+
9
+ - Omit raw host exception messages and stack text from handled-error log lines; preserve the original exception in host-controlled `Rails.error`. Document the metadata-only event catalog.
10
+
11
+ - Fence delivery acknowledgments with the existing attempt counter. Late callbacks cannot overwrite newer attempts or emit false terminal notifications. Keep durable uncertainty and host deduplication.
12
+ - Refuse delivery inside an open host transaction with `DELIVERY_IN_TRANSACTION`; dispatch can still join it. Add host Active Job and process-death recovery checks. No new migration or runtime dependency.
13
+
3
14
  ## [0.3.0] - 2026-10-19
4
15
 
5
16
  - Opt-in scoped `purge_closed` preview/apply with deadlines, bounded batches, atomic receipt cleanup and durable correlation tombstones. Host business records stay intact.
@@ -85,10 +85,16 @@ module RailsToolViews
85
85
  status = response[:status] == "pending" ? 202 : 200
86
86
  respond_to do |format|
87
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 }
88
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(surface.dom_id, RailsToolViews.render(surface, view_context: view_context, scope: current_scope, notice: result[:notice])), 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_after_action(surface) }
91
+ format.html do
92
+ if result[:notice]
93
+ flash[:rails_tool_views_notice] = { "surface_id" => surface.id.to_s, "actor" => current_scope.actor,
94
+ "tenant" => current_scope.tenant, "text" => result[:notice] }
95
+ end
96
+ redirect_after_action(surface)
97
+ end
92
98
  end
93
99
  end
94
100
  def redirect_after_action(surface)
@@ -106,7 +112,7 @@ module RailsToolViews
106
112
  end
107
113
  end
108
114
  def render_surface_page(surface, error: nil)
109
- html = surface && RailsToolViews.render(surface, view_context: view_context, scope: current_scope, error: error)
115
+ html = surface && view_context.rails_tool_views_surface(surface, scope: current_scope, error: error)
110
116
  render template: "rails_tool_views/surfaces/page", layout: "rails_tool_views/application",
111
117
  locals: { surface_html: html, reload_path: surface && surface_path(surface), error: error },
112
118
  status: error ? error.status : :ok
@@ -14,9 +14,11 @@ module RailsToolViews
14
14
  agent_unavailable: "AI follow-up unavailable.", agent_retry: "Retry AI follow-up"
15
15
  }.freeze
16
16
 
17
- # Renders the Surface with a fresh scope and optional field feedback.
18
- def rails_tool_views_surface(surface, scope:, error: nil)
19
- RailsToolViews.render(surface, view_context: self, scope: scope, error: error)
17
+ # Renders with fresh authorization, optional field feedback and a transient
18
+ # notice. A matching HTML redirect's scoped flash is consumed once.
19
+ def rails_tool_views_surface(surface, scope:, error: nil, notice: nil)
20
+ notice = rails_tool_views_flash_notice(surface, scope) if notice.nil?
21
+ RailsToolViews.render(surface, view_context: self, scope: scope, error: error, notice: notice)
20
22
  end
21
23
 
22
24
  def rails_tool_views_text(key, **values)
@@ -78,5 +80,14 @@ module RailsToolViews
78
80
  view = respond_to?(:rails_tool_views) || respond_to?(:main_app) ? self : helpers
79
81
  view.respond_to?(:rails_tool_views) ? view.rails_tool_views : view.main_app.rails_tool_views
80
82
  end
83
+
84
+ private
85
+ def rails_tool_views_flash_notice(surface, scope) # :nodoc:
86
+ return unless scope.is_a?(RailsToolViews::Scope) && respond_to?(:flash)
87
+ stored = flash[:rails_tool_views_notice]
88
+ return unless stored.is_a?(Hash) && stored.values_at("surface_id", "actor", "tenant") == [surface.id.to_s, scope.actor, scope.tenant]
89
+ flash.delete(:rails_tool_views_notice)
90
+ stored["text"]
91
+ end
81
92
  end
82
93
  end
@@ -4,7 +4,7 @@
4
4
  <p id="<%= surface.dom_id %>_error" role="alert" tabindex="-1" data-rails-tool-views-target="error"><%= error.message %></p>
5
5
  <% end %>
6
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) %>
7
+ <%= notice || (surface.state["message"].is_a?(String) ? surface.state["message"] : rails_tool_views_text(surface.revision > 1 ? :saved : :ready)) %>
8
8
  </p>
9
9
  <% interaction = surface.agent_interactions.order(:id).last %>
10
10
  <% if interaction && interaction.status != "skipped" %>
@@ -16,6 +16,7 @@ module RailsToolViews
16
16
  "SURFACE_CLOSED" => "This view is closed. Its saved actions cannot be changed.",
17
17
  "SURFACE_PURGED" => "This view has been cleaned up and cannot be restored.",
18
18
  "LIFECYCLE_BUSY" => "This view has unfinished or uncertain delivery. Resolve it before closing.",
19
+ "DELIVERY_IN_TRANSACTION" => "Deliver the follow-up after the host transaction commits.",
19
20
  "INVALID_RETENTION_REQUEST" => "Choose an explicit past cutoff and a batch size from 1 to 1000.",
20
21
  "INVALID_RETENTION_WINDOW" => "Choose a future retention deadline or keep this view indefinitely.",
21
22
  "VALIDATION_FAILED" => "Check the highlighted fields and try again.",
@@ -183,8 +184,9 @@ module RailsToolViews
183
184
 
184
185
  # Policy and handler context populated by the runtime.
185
186
  Access = Struct.new(:actor, :tenant, :operation, :props, :surface, :action, :arguments, :context_ref, keyword_init: true)
186
- # Handler result containing the next UI state and an optional agent flag.
187
- Transition = Struct.new(:state, :agent, keyword_init: true)
187
+ # Handler result containing persistent UI state, an optional agent flag and
188
+ # plain-text notice (at most 500 bytes) displayed for this action response.
189
+ Transition = Struct.new(:state, :agent, :notice, keyword_init: true)
188
190
  Action = Struct.new(:key, :schema, :policy, :handler, keyword_init: true)
189
191
 
190
192
  # An immutable renderer, schema, version, and authorization contract.
@@ -62,6 +62,7 @@ module RailsToolViews
62
62
  idempotency_key = Data.identifier(idempotency_key, code: "INVALID_IDEMPOTENCY_KEY")
63
63
  request_digest = Data.digest(action: definition.key, arguments: arguments, base_revision: base_revision)
64
64
  result = nil
65
+ replayed = false
65
66
  surface.with_lock do
66
67
  ensure_not_purged!(surface)
67
68
  compatible_component!(surface).authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
@@ -74,6 +75,7 @@ module RailsToolViews
74
75
  # exact key, and keep the digest check as the second gate.
75
76
  raise Error.new("IDEMPOTENCY_CONFLICT", status: 409) unless prior.idempotency_key == idempotency_key && prior.request_digest == request_digest
76
77
  result = prior.result.symbolize_keys
78
+ replayed = true
77
79
  next
78
80
  end
79
81
  raise Error.new("SURFACE_CLOSED", status: 410) if surface.closed?
@@ -84,8 +86,10 @@ module RailsToolViews
84
86
  transition = definition.handler.call(request)
85
87
  raise Error.new("INVALID_TRANSITION", status: 422) unless transition.is_a?(Transition) && transition.state.is_a?(Hash)
86
88
  state = Data.bounded(transition.state)
89
+ notice = normalize_notice(transition.notice)
87
90
  surface.update!(state: state, revision: base_revision + 1)
88
91
  result = { status: "completed", surface_id: surface.id, revision: surface.revision }
92
+ result[:notice] = notice unless notice.nil?
89
93
  execution = ActionExecution.create!(surface: surface, actor_ref: scope.actor, action_key: definition.key,
90
94
  base_revision: base_revision, idempotency_key: idempotency_key, request_digest: request_digest, result: result)
91
95
  if transition.agent
@@ -96,14 +100,14 @@ module RailsToolViews
96
100
  execution.update!(result: result)
97
101
  end
98
102
  end
99
- notify("action.executed", surface_id: surface.id, revision: result[:revision], action: definition.key)
103
+ notify("action.executed", surface_id: surface.id, revision: result[:revision], action: definition.key, replayed: replayed)
100
104
  result
101
105
  rescue ActiveRecord::StaleObjectError, ActiveRecord::TransactionRollbackError, ActiveRecord::LockWaitTimeout
102
106
  # The database rolled the whole action back (deadlock, serialization failure,
103
107
  # lock timeout); retrying with the same key is safe.
104
- raise Error.new("CONFLICT", status: 409)
108
+ raise_action_conflict!
105
109
  rescue ActiveRecord::StatementInvalid => error
106
- raise Error.new("CONFLICT", status: 409) if error.cause.class.name.include?("Busy") || error.cause.class.name.include?("Locked")
110
+ raise_action_conflict! if error.cause.class.name.include?("Busy") || error.cause.class.name.include?("Locked")
107
111
  raise
108
112
  rescue Error => error
109
113
  notify(error.code == "CONFLICT" ? "action.conflict" : "action.rejected", code: error.code)
@@ -113,7 +117,9 @@ module RailsToolViews
113
117
  # The adapter must deduplicate by delivery_key. A crash after remote acceptance
114
118
  # can leave a lease in delivering state; retry is explicit and keeps the key.
115
119
  # Delivers a saved interaction after authorization; retry_failed explicitly retries failed delivery.
120
+ # Call only after the host transaction commits. Stale attempts cannot acknowledge a newer claim.
116
121
  def deliver(id, scope:, adapter: configuration.agent_adapter, retry_failed: false)
122
+ raise Error.new("DELIVERY_IN_TRANSACTION", status: 409) if AgentInteraction.connection.transaction_open?
117
123
  interaction = AgentInteraction.find_by(id: id)
118
124
  raise Error.new("NOT_FOUND", status: 404) unless interaction
119
125
  surface = restore(interaction.surface_id, scope: scope)
@@ -148,23 +154,32 @@ module RailsToolViews
148
154
  end
149
155
  end
150
156
  return interaction.reload unless claimed
157
+ attempt = interaction.attempts
158
+ failure = nil
151
159
  begin
152
160
  adapter.call(Data.freeze_deep(Data.normalize(interaction.payload)), delivery_key: interaction.delivery_key)
153
- interaction.update!(status: "completed", claimed_at: nil)
154
- notify("agent_returned", surface_id: interaction.surface_id, interaction_id: interaction.id)
155
161
  rescue StandardError => error
156
- interaction.update!(status: "failed", error_code: "AGENT_UNAVAILABLE", claimed_at: nil)
157
- report(error, "agent_failed", surface_id: interaction.surface_id, interaction_id: interaction.id)
162
+ failure = error
163
+ end
164
+ # Compare-and-set fences both success and failure. The lease may expire
165
+ # while the callback runs; its acknowledgment belongs only to its attempt.
166
+ acknowledged = AgentInteraction.where(id: interaction.id, status: "delivering", attempts: attempt).update_all(
167
+ status: failure ? "failed" : "completed", error_code: failure ? "AGENT_UNAVAILABLE" : nil,
168
+ claimed_at: nil, updated_at: Time.current)
169
+ if acknowledged == 1
170
+ metadata = { surface_id: interaction.surface_id, interaction_id: interaction.id }
171
+ failure ? report(failure, "agent_failed", **metadata) : notify("agent_returned", **metadata)
158
172
  end
159
173
  interaction.reload
160
174
  end
161
175
 
162
- # Renders the trusted component after a fresh restore and authorization check.
163
- def render(surface, view_context:, scope:, error: nil)
176
+ # Renders the trusted component after fresh authorization. Optional notice
177
+ # is plain-text response feedback; it never changes the Surface snapshot.
178
+ def render(surface, view_context:, scope:, error: nil, notice: nil)
164
179
  # Never trust an already-loaded object as an authorization decision.
165
180
  surface = restore(surface.id, scope: scope)
166
181
  component = compatible_component!(surface)
167
- view_context.render(partial: "rails_tool_views/surfaces/surface", formats: [:html], locals: { surface: surface, definition: component, error: error })
182
+ view_context.render(partial: "rails_tool_views/surfaces/surface", formats: [:html], locals: { surface: surface, definition: component, error: error, notice: normalize_notice(notice) })
168
183
  rescue Error => failure
169
184
  view_context.render(partial: "rails_tool_views/surfaces/error", formats: [:html], locals: { error: failure })
170
185
  end
@@ -199,10 +214,20 @@ module RailsToolViews
199
214
  def report(error, event, **payload) # :nodoc:
200
215
  notify(event, **payload, error_class: error.class.name)
201
216
  Rails.error.report(error, handled: true, severity: :error, context: payload, source: "rails_tool_views")
202
- Rails.logger&.error("RailsToolViews #{event} handled #{error.class} (#{error.message}) - #{error.backtrace&.first}")
217
+ Rails.logger&.error("RailsToolViews #{event} handled #{error.class}; inspect the host Rails.error report")
203
218
  end
204
219
 
205
220
  private
221
+ def raise_action_conflict! # :nodoc:
222
+ notify("action.conflict", code: "CONFLICT")
223
+ raise Error.new("CONFLICT", status: 409)
224
+ end
225
+ def normalize_notice(notice) # :nodoc:
226
+ return if notice.nil?
227
+ raise Error.new("INVALID_TRANSITION", status: 422) unless notice.is_a?(String) && notice.bytesize <= 500
228
+ # A host SafeBuffer must still be rendered as escaped text.
229
+ Data.bounded(String.new(notice))
230
+ end
206
231
  def ensure_not_purged!(surface)
207
232
  raise Error.new("SURFACE_PURGED", status: 410) if surface.purged?
208
233
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module RailsToolViews
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.0"
4
4
  end
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.3.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-19 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails