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 +4 -4
- data/CHANGELOG.md +11 -0
- data/app/controllers/rails_tool_views/surfaces_controller.rb +9 -3
- data/app/helpers/rails_tool_views/surfaces_helper.rb +14 -3
- data/app/views/rails_tool_views/surfaces/_surface.html.erb +1 -1
- data/lib/rails_tool_views/core.rb +4 -2
- data/lib/rails_tool_views/runtime.rb +36 -11
- data/lib/rails_tool_views/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 870437bec297c23b8c6dcda437fba7d02d196bcdd1b4abcaf0df3a207e689cca
|
|
4
|
+
data.tar.gz: c4af0245f4f5d7e7e94b28b225c5da2c69e7b6205e56529e0067f481faa49c39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 &&
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
108
|
+
raise_action_conflict!
|
|
105
109
|
rescue ActiveRecord::StatementInvalid => error
|
|
106
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
163
|
-
|
|
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}
|
|
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
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Wang
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|