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 +7 -0
- data/CHANGELOG.md +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +90 -0
- data/SECURITY.md +11 -0
- data/app/assets/javascripts/rails_tool_views_controller.js +41 -0
- data/app/controllers/rails_tool_views/surfaces_controller.rb +118 -0
- data/app/helpers/rails_tool_views/surfaces_helper.rb +78 -0
- data/app/models/rails_tool_views/action_execution.rb +11 -0
- data/app/models/rails_tool_views/agent_interaction.rb +15 -0
- data/app/models/rails_tool_views/presentation.rb +10 -0
- data/app/models/rails_tool_views/redacted_json.rb +20 -0
- data/app/models/rails_tool_views/surface.rb +23 -0
- data/app/views/rails_tool_views/surfaces/_error.html.erb +1 -0
- data/app/views/rails_tool_views/surfaces/_surface.html.erb +19 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261010000000_create_rails_tool_views.rb +56 -0
- data/lib/generators/rails_tool_views/install/install_generator.rb +20 -0
- data/lib/generators/rails_tool_views/install/templates/create_rails_tool_views.rb +56 -0
- data/lib/generators/rails_tool_views/install/templates/rails_tool_views.rb +12 -0
- data/lib/rails_tool_views/adapters/ruby_llm.rb +75 -0
- data/lib/rails_tool_views/core.rb +288 -0
- data/lib/rails_tool_views/engine.rb +11 -0
- data/lib/rails_tool_views/runtime.rb +221 -0
- data/lib/rails_tool_views/version.rb +4 -0
- data/lib/rails_tool_views.rb +6 -0
- metadata +223 -0
|
@@ -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,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
RailsToolViews.configure do |config|
|
|
3
|
+
# Fail closed until host authentication resolves stable actor AND tenant refs.
|
|
4
|
+
# config.scope_resolver = ->(controller) {
|
|
5
|
+
# RailsToolViews::Scope.new(actor: controller.current_user.id.to_s,
|
|
6
|
+
# tenant: controller.current_account.id.to_s)
|
|
7
|
+
# }
|
|
8
|
+
# Mounted controllers inherit this host controller's filters and helpers.
|
|
9
|
+
# config.parent_controller = "::ApplicationController"
|
|
10
|
+
end
|
|
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.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Only this explicitly required file loads RubyLLM.
|
|
3
|
+
require "rails_tool_views"
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
|
|
6
|
+
module RailsToolViews
|
|
7
|
+
module Adapters
|
|
8
|
+
module RubyLLM
|
|
9
|
+
# Optional RubyLLM tool exposing a scoped, approved component catalog.
|
|
10
|
+
class PresentUI < ::RubyLLM::Tool
|
|
11
|
+
description "Present one trusted interactive Rails component to the user."
|
|
12
|
+
# Returns the stable provider-facing tool name.
|
|
13
|
+
def self.tool_name
|
|
14
|
+
"present_ui"
|
|
15
|
+
end
|
|
16
|
+
attr_reader :references
|
|
17
|
+
# Captures an authorized catalog and server-owned correlation context.
|
|
18
|
+
def initialize(keys:, scope:, context:, adapter_key: "ruby_llm")
|
|
19
|
+
@scope = scope
|
|
20
|
+
@context = Data.identifier(context, code: "INVALID_CONTEXT")
|
|
21
|
+
@adapter_key = Data.identifier(adapter_key, code: "INVALID_CORRELATION")
|
|
22
|
+
@components = RailsToolViews.registry.catalog(keys: keys, scope: scope)
|
|
23
|
+
raise Error.new("EMPTY_CATALOG") if @components.empty?
|
|
24
|
+
@references = []
|
|
25
|
+
end
|
|
26
|
+
# Returns the bounded schema presented to the provider.
|
|
27
|
+
def parameters_schema
|
|
28
|
+
{ "type" => "object", "additionalProperties" => false, "required" => %w[component props],
|
|
29
|
+
"properties" => { "component" => { "type" => "string", "enum" => @components.map(&:key) },
|
|
30
|
+
"props" => { "anyOf" => @components.map { |component| component.schema.json }.uniq } } }
|
|
31
|
+
end
|
|
32
|
+
# Presents a component using a real RubyLLM ToolCall; returns an acknowledgment or rejection.
|
|
33
|
+
def execute(component:, props:, tool_call: nil)
|
|
34
|
+
definition = @components.find { |item| item.key == component.to_s }
|
|
35
|
+
raise Error.new("NOT_ALLOWED", status: 403) unless definition
|
|
36
|
+
raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) unless RailsToolViews.registry.fetch(definition.key).digest == definition.digest
|
|
37
|
+
# A server-owned ToolCall is required for provider replay safety.
|
|
38
|
+
raise Error.new("MISSING_TOOL_CALL") unless tool_call.is_a?(::RubyLLM::ToolCall)
|
|
39
|
+
raise Error.new("INVALID_TOOL_CALL") unless tool_call.name == name
|
|
40
|
+
surface = RailsToolViews.present(definition.key, props: props, scope: @scope, context: @context,
|
|
41
|
+
correlation: { adapter: @adapter_key, call_id: tool_call.id, context: @context })
|
|
42
|
+
reference = { surface_id: surface.id, context_ref: @context }
|
|
43
|
+
@references << reference.freeze unless @references.include?(reference)
|
|
44
|
+
RailsToolViews.notify("adapter.tool_called", surface_id: surface.id, revision: surface.revision)
|
|
45
|
+
# The persisted presentation receipt preserves the original tool result.
|
|
46
|
+
# Match the receipt byte for byte; a non-binary collation must not return
|
|
47
|
+
# another call's acknowledgment.
|
|
48
|
+
presentation = RailsToolViews::Presentation.find_by(surface_id: surface.id, adapter: @adapter_key, call_id: tool_call.id, context_ref: @context)
|
|
49
|
+
unless presentation && presentation.adapter == @adapter_key && presentation.call_id == tool_call.id && presentation.context_ref == @context
|
|
50
|
+
raise Error.new("INVALID_CORRELATION")
|
|
51
|
+
end
|
|
52
|
+
presentation.result.symbolize_keys
|
|
53
|
+
rescue Error => error
|
|
54
|
+
error.to_h
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Host owns durable chat lookup and delivery deduplication. This adapter
|
|
59
|
+
# only starts a supported user turn; RubyLLM prevents pending-tool mixing.
|
|
60
|
+
class Continuation
|
|
61
|
+
# Accepts authorized chat resolution and durable host delivery deduplication.
|
|
62
|
+
def initialize(chat_resolver:, delivery:)
|
|
63
|
+
@chat_resolver, @delivery = chat_resolver, delivery
|
|
64
|
+
end
|
|
65
|
+
# Starts a user turn within the host delivery deduplication block.
|
|
66
|
+
def call(payload, delivery_key:)
|
|
67
|
+
@delivery.call(delivery_key) do
|
|
68
|
+
chat = @chat_resolver.call(payload.fetch("context_ref"))
|
|
69
|
+
chat.ask(JSON.generate(payload))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "json_schemer"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
# Registers trusted Rails views and orchestrates persistent, authorized interactions.
|
|
9
|
+
module RailsToolViews
|
|
10
|
+
# A recoverable rejection with a stable code and HTTP status.
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
attr_reader :code, :status
|
|
13
|
+
MESSAGES = {
|
|
14
|
+
"NOT_FOUND" => "This view is unavailable.", "NOT_ALLOWED" => "You do not have permission to use this view.",
|
|
15
|
+
"CONFLICT" => "This view changed. Refresh to continue.", "INVALID_ARGUMENTS" => "Check the submitted values and try again.",
|
|
16
|
+
"VALIDATION_FAILED" => "Check the highlighted fields and try again.",
|
|
17
|
+
"INCOMPATIBLE_COMPONENT" => "This view's contract changed. Ask the application owner to migrate or recreate it.",
|
|
18
|
+
"RENDER_FAILED" => "This view cannot be rendered. Your saved state is preserved.",
|
|
19
|
+
"AGENT_UNAVAILABLE" => "Your changes were saved, but the AI follow-up could not complete.",
|
|
20
|
+
"IDEMPOTENCY_CONFLICT" => "This request identifier was already used for a different request."
|
|
21
|
+
}.freeze
|
|
22
|
+
# Hosts own the wording of their codes: pass message:, or translate
|
|
23
|
+
# rails_tool_views.errors.<downcased code>. Built-in codes translate the same way.
|
|
24
|
+
def self.message_for(code)
|
|
25
|
+
fallback = MESSAGES.fetch(code) { "The request was rejected (#{code})." }
|
|
26
|
+
defined?(::I18n) ? ::I18n.t(code.downcase, scope: %i[rails_tool_views errors], default: fallback) : fallback
|
|
27
|
+
end
|
|
28
|
+
# Creates a rejection; message overrides the default or translated wording.
|
|
29
|
+
def initialize(code, status: 400, message: nil)
|
|
30
|
+
@code, @status = code, status
|
|
31
|
+
super(message || self.class.message_for(code))
|
|
32
|
+
end
|
|
33
|
+
# Returns a bounded rejection payload for the caller.
|
|
34
|
+
def to_h
|
|
35
|
+
{ status: "rejected", code: code, message: message }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module Data # :nodoc:
|
|
40
|
+
module_function
|
|
41
|
+
def normalize(value, depth = 0)
|
|
42
|
+
raise Error.new("PAYLOAD_TOO_DEEP") if depth > RailsToolViews.configuration.max_depth
|
|
43
|
+
case value
|
|
44
|
+
when Hash
|
|
45
|
+
result = {}
|
|
46
|
+
value.each do |key, item|
|
|
47
|
+
raise Error.new("INVALID_JSON") unless key.is_a?(String) || key.is_a?(Symbol)
|
|
48
|
+
raise Error.new("INVALID_JSON") if result.key?(key.to_s)
|
|
49
|
+
result[key.to_s] = normalize(item, depth + 1)
|
|
50
|
+
end
|
|
51
|
+
result
|
|
52
|
+
when Array then value.map { |item| normalize(item, depth + 1) }
|
|
53
|
+
when String then value.dup
|
|
54
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
55
|
+
when Float
|
|
56
|
+
raise Error.new("INVALID_JSON") unless value.finite?
|
|
57
|
+
value
|
|
58
|
+
else raise Error.new("INVALID_JSON")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
def bounded(value)
|
|
62
|
+
normalized = normalize(value)
|
|
63
|
+
raise Error.new("PAYLOAD_TOO_LARGE", status: 413) if JSON.generate(normalized).bytesize > RailsToolViews.configuration.max_payload_bytes
|
|
64
|
+
normalized
|
|
65
|
+
rescue JSON::GeneratorError, EncodingError
|
|
66
|
+
raise Error.new("INVALID_JSON")
|
|
67
|
+
end
|
|
68
|
+
def freeze_deep(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash then value.each { |key, item| key.freeze; freeze_deep(item) }
|
|
71
|
+
when Array then value.each { |item| freeze_deep(item) }
|
|
72
|
+
end
|
|
73
|
+
value.freeze
|
|
74
|
+
end
|
|
75
|
+
def canonical(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
|
|
78
|
+
when Array then value.map { |item| canonical(item) }
|
|
79
|
+
else value
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
def digest(value)
|
|
83
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(canonical(bounded(value))))}"
|
|
84
|
+
end
|
|
85
|
+
# Actor, tenant, context, adapter, call and replay references are persisted
|
|
86
|
+
# in string columns that carry multi-column indexes. Cap them in bytes so a
|
|
87
|
+
# value can never overflow a byte-limited column and every unique key fits
|
|
88
|
+
# utf8mb4's 3072-byte index limit on MySQL/InnoDB. A byte cap also bounds the
|
|
89
|
+
# character count, which is what a varchar length actually limits.
|
|
90
|
+
IDENTIFIER_BYTES = 150
|
|
91
|
+
def reference(value, code: "INVALID_SCOPE", limit: 255)
|
|
92
|
+
raise Error.new(code) unless value.is_a?(String) && !value.strip.empty? && value.bytesize <= limit
|
|
93
|
+
value.dup.freeze
|
|
94
|
+
end
|
|
95
|
+
# A bounded reference for a stored, indexed identifier.
|
|
96
|
+
def identifier(value, code: "INVALID_SCOPE")
|
|
97
|
+
reference(value, code: code, limit: IDENTIFIER_BYTES)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Host-owned validation feedback. Only return approved, schema-validated form
|
|
102
|
+
# values; rejected requests do not change Surface or domain persistence.
|
|
103
|
+
# A rejected form with field messages and explicitly safe retained values.
|
|
104
|
+
class ValidationError < Error
|
|
105
|
+
attr_reader :fields, :values
|
|
106
|
+
# Creates field feedback with only the values the host approves for redisplay.
|
|
107
|
+
def initialize(fields:, values: {}, message: nil)
|
|
108
|
+
@fields, @values = Data.bounded(fields), Data.bounded(values)
|
|
109
|
+
unless @fields.is_a?(Hash) && @values.is_a?(Hash) && @fields.all? { |key, text| key.match?(/\A[a-z][a-z0-9_]*\z/) && text.is_a?(String) && text.length <= 500 }
|
|
110
|
+
raise Error.new("INVALID_VALIDATION_FEEDBACK")
|
|
111
|
+
end
|
|
112
|
+
Data.freeze_deep(@fields)
|
|
113
|
+
Data.freeze_deep(@values)
|
|
114
|
+
super("VALIDATION_FAILED", status: 422, message: message)
|
|
115
|
+
end
|
|
116
|
+
# Returns a bounded rejection payload for the caller.
|
|
117
|
+
def to_h
|
|
118
|
+
super.merge(fields: fields, values: values)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Validates a bounded, closed JSON contract.
|
|
123
|
+
class Schema
|
|
124
|
+
KEYWORDS = %w[type properties required additionalProperties items enum const minimum maximum minLength maxLength pattern minItems maxItems uniqueItems description title anyOf oneOf allOf].freeze
|
|
125
|
+
TYPES = %w[object array string number integer boolean null].freeze
|
|
126
|
+
COMBINATORS = %w[anyOf oneOf allOf].freeze
|
|
127
|
+
attr_reader :json
|
|
128
|
+
# Builds a closed schema; rejects unsupported or unbounded nested contracts.
|
|
129
|
+
def initialize(schema)
|
|
130
|
+
@json = Data.bounded(schema)
|
|
131
|
+
inspect_schema!(@json)
|
|
132
|
+
raise Error.new("INVALID_SCHEMA") unless @json["type"] == "object" && @json["additionalProperties"] == false
|
|
133
|
+
@validator = JSONSchemer.schema(@json, ref_resolver: ->(_uri) { raise Error.new("INVALID_SCHEMA") })
|
|
134
|
+
raise Error.new("INVALID_SCHEMA") unless @validator.valid_schema?
|
|
135
|
+
Data.freeze_deep(@json)
|
|
136
|
+
rescue JSONSchemer::UnknownRef, ArgumentError
|
|
137
|
+
raise Error.new("INVALID_SCHEMA")
|
|
138
|
+
end
|
|
139
|
+
# Returns normalized validated data, or raises Error with the supplied code.
|
|
140
|
+
def validate!(value, code: "INVALID_PROPS")
|
|
141
|
+
data = Data.bounded(value)
|
|
142
|
+
raise Error.new(code) unless @validator.valid?(data)
|
|
143
|
+
data
|
|
144
|
+
end
|
|
145
|
+
private
|
|
146
|
+
# Every subschema is walked, and every value position must say what it
|
|
147
|
+
# accepts: an untyped or empty schema admits any JSON, which would reopen a
|
|
148
|
+
# closed object from the inside. `bounded` is true only for a combinator
|
|
149
|
+
# branch whose enclosing schema already constrains the same value.
|
|
150
|
+
def inspect_schema!(schema, bounded: false) # :nodoc:
|
|
151
|
+
raise Error.new("INVALID_SCHEMA") unless schema.is_a?(Hash) && (schema.keys - KEYWORDS).empty?
|
|
152
|
+
types = Array(schema["type"])
|
|
153
|
+
combinators = COMBINATORS.select { |key| schema.key?(key) }
|
|
154
|
+
constrained = types.any? || schema.key?("enum") || schema.key?("const")
|
|
155
|
+
valid = (types - TYPES).empty? && (constrained || bounded || combinators.any?) &&
|
|
156
|
+
(!schema.key?("enum") || schema["enum"].is_a?(Array)) &&
|
|
157
|
+
(!schema.key?("additionalProperties") || schema["additionalProperties"] == false) &&
|
|
158
|
+
schema.fetch("properties", {}).is_a?(Hash) && schema.fetch("items", {}).is_a?(Hash) &&
|
|
159
|
+
(!types.include?("object") || schema["additionalProperties"] == false) &&
|
|
160
|
+
(!types.include?("array") || schema.key?("items")) &&
|
|
161
|
+
combinators.all? { |key| schema[key].is_a?(Array) && schema[key].any? }
|
|
162
|
+
raise Error.new("INVALID_SCHEMA") unless valid
|
|
163
|
+
schema.fetch("properties", {}).each_value { |child| inspect_schema!(child) }
|
|
164
|
+
inspect_schema!(schema["items"]) if schema.key?("items")
|
|
165
|
+
combinators.each { |key| schema[key].each { |child| inspect_schema!(child, bounded: bounded || constrained) } }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Immutable actor and tenant references resolved by the host.
|
|
170
|
+
class Scope
|
|
171
|
+
attr_reader :actor, :tenant
|
|
172
|
+
# Creates frozen, nonempty string references for an authenticated identity.
|
|
173
|
+
def initialize(actor:, tenant:)
|
|
174
|
+
@actor, @tenant = Data.identifier(actor), Data.identifier(tenant)
|
|
175
|
+
freeze
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Policy and handler context populated by the runtime.
|
|
180
|
+
Access = Struct.new(:actor, :tenant, :operation, :props, :surface, :action, :arguments, :context_ref, keyword_init: true)
|
|
181
|
+
# Handler result containing the next UI state and an optional agent flag.
|
|
182
|
+
Transition = Struct.new(:state, :agent, keyword_init: true)
|
|
183
|
+
Action = Struct.new(:key, :schema, :policy, :handler, keyword_init: true)
|
|
184
|
+
|
|
185
|
+
# An immutable renderer, schema, version, and authorization contract.
|
|
186
|
+
class Component
|
|
187
|
+
# A ViewComponent opts into these by naming them as initializer keywords.
|
|
188
|
+
CONTEXT = %i[surface state error].freeze
|
|
189
|
+
attr_reader :key, :description, :schema, :partial, :component, :policy, :actions, :expose_to, :digest, :version, :context
|
|
190
|
+
# Builds an immutable component with exactly one trusted renderer.
|
|
191
|
+
def initialize(key, description:, schema:, partial: nil, component: nil, policy: nil, actions: {}, expose_to: [], version: "1")
|
|
192
|
+
@key = key.to_s.freeze
|
|
193
|
+
raise Error.new("INVALID_COMPONENT") unless @key.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
|
|
194
|
+
@description = Data.reference(description, code: "INVALID_COMPONENT")
|
|
195
|
+
@version = Data.reference(version.to_s, code: "INVALID_COMPONENT")
|
|
196
|
+
@schema = Schema.new(schema)
|
|
197
|
+
if (!!partial == !!component) || (partial && (!partial.is_a?(String) || !partial.match?(/\A[a-zA-Z0-9_\/]+\z/))) || (component && (!component.is_a?(Class) || component.name.nil?))
|
|
198
|
+
raise Error.new("INVALID_RENDERER")
|
|
199
|
+
end
|
|
200
|
+
@partial, @component, @policy = partial&.dup&.freeze, component, policy
|
|
201
|
+
keywords = component ? component.instance_method(:initialize).parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) } : []
|
|
202
|
+
@context = (CONTEXT & keywords).freeze
|
|
203
|
+
raise Error.new("INVALID_COMPONENT") if (@context.map(&:to_s) & @schema.json.fetch("properties", {}).keys).any?
|
|
204
|
+
@expose_to = expose_to.map(&:to_sym).freeze
|
|
205
|
+
@actions = actions.to_h do |name, definition|
|
|
206
|
+
name = name.to_s.freeze
|
|
207
|
+
raise Error.new("INVALID_ACTION") unless name.match?(/\A[a-z][a-z0-9_]{0,63}\z/) && (definition.keys - %i[schema policy handler]).empty? && definition[:handler].respond_to?(:call)
|
|
208
|
+
[name, Action.new(key: name, schema: Schema.new(definition.fetch(:schema)), policy: definition[:policy], handler: definition[:handler]).freeze]
|
|
209
|
+
end.freeze
|
|
210
|
+
@digest = Data.digest(key: @key, version: @version, schema: @schema.json, renderer: @partial || @component.name,
|
|
211
|
+
actions: @actions.transform_values { |action| action.schema.json }, contract_version: 1).freeze
|
|
212
|
+
freeze
|
|
213
|
+
end
|
|
214
|
+
def authorize!(access) # :nodoc:
|
|
215
|
+
raise Error.new("NOT_ALLOWED", status: 403) unless policy&.call(access) == true
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Stores explicit component definitions and filters discoverable catalogs.
|
|
220
|
+
class Registry
|
|
221
|
+
# Creates an empty, process-local registry.
|
|
222
|
+
def initialize
|
|
223
|
+
@components = {}
|
|
224
|
+
@mutex = Mutex.new
|
|
225
|
+
end
|
|
226
|
+
# Registers a component; duplicate keys raise DUPLICATE_COMPONENT.
|
|
227
|
+
def register(key, **options)
|
|
228
|
+
component = Component.new(key, **options)
|
|
229
|
+
@mutex.synchronize do
|
|
230
|
+
raise Error.new("DUPLICATE_COMPONENT") if @components.key?(component.key)
|
|
231
|
+
@components[component.key] = component
|
|
232
|
+
end
|
|
233
|
+
component
|
|
234
|
+
end
|
|
235
|
+
# Returns the named component, or raises UNKNOWN_COMPONENT.
|
|
236
|
+
def fetch(key)
|
|
237
|
+
@components.fetch(key.to_s) { raise Error.new("UNKNOWN_COMPONENT", status: 404) }
|
|
238
|
+
end
|
|
239
|
+
# Returns explicitly exposed components allowed by discovery policy for scope.
|
|
240
|
+
def catalog(keys:, scope:, exposure: :agent)
|
|
241
|
+
raise Error.new("INVALID_SCOPE", status: 403) unless scope.is_a?(Scope)
|
|
242
|
+
raise Error.new("CATALOG_TOO_LARGE") unless keys.is_a?(Array) && keys.length.between?(1, RailsToolViews.configuration.max_catalog_components) && keys.map(&:to_s).uniq.length == keys.length
|
|
243
|
+
keys.map { |key| fetch(key) }.select do |component|
|
|
244
|
+
component.expose_to.include?(exposure) && component.policy&.call(Access.new(actor: scope.actor, tenant: scope.tenant, operation: :discover, props: {}, context_ref: nil)) == true
|
|
245
|
+
end.freeze
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Process-local host hooks, request limits, and delivery settings.
|
|
250
|
+
class Configuration
|
|
251
|
+
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
|
|
253
|
+
# Initializes conservative request limits with no host identity or agent adapter.
|
|
254
|
+
def initialize
|
|
255
|
+
@max_payload_bytes, @max_depth, @max_catalog_components, @max_actions_per_minute = 32_768, 16, 20, 60
|
|
256
|
+
@auto_deliver = true
|
|
257
|
+
# Name the host controller that carries authentication; seconds before an unfinished delivery may be reclaimed.
|
|
258
|
+
@parent_controller, @delivery_lease = "::ApplicationController", 300
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
class << self
|
|
263
|
+
# Returns the process-local component registry.
|
|
264
|
+
def registry
|
|
265
|
+
@registry ||= Registry.new
|
|
266
|
+
end
|
|
267
|
+
# Returns the process-local configuration.
|
|
268
|
+
def configuration
|
|
269
|
+
@configuration ||= Configuration.new
|
|
270
|
+
end
|
|
271
|
+
# Yields configuration to the host initializer.
|
|
272
|
+
def configure
|
|
273
|
+
yield configuration
|
|
274
|
+
end
|
|
275
|
+
# Registers a component; duplicate keys raise DUPLICATE_COMPONENT.
|
|
276
|
+
def register(key, **options)
|
|
277
|
+
registry.register(key, **options)
|
|
278
|
+
end
|
|
279
|
+
# Clears definitions while retaining configuration; use in Rails reload hooks.
|
|
280
|
+
def reset_registry!
|
|
281
|
+
@registry = Registry.new
|
|
282
|
+
end
|
|
283
|
+
# Clears definitions and configuration; intended for isolated tests.
|
|
284
|
+
def reset!
|
|
285
|
+
@registry, @configuration = Registry.new, Configuration.new
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module RailsToolViews
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
isolate_namespace RailsToolViews
|
|
5
|
+
# Log redaction lives on this engine's own controller and models, not in the
|
|
6
|
+
# host-wide filter list, where names like `state` would redact host data too.
|
|
7
|
+
initializer "rails_tool_views.helpers" do
|
|
8
|
+
ActiveSupport.on_load(:action_controller_base) { helper RailsToolViews::SurfacesHelper }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module RailsToolViews
|
|
3
|
+
class << self
|
|
4
|
+
# Validates and authorizes props, then returns a persisted Surface. Correlation replays the original presentation.
|
|
5
|
+
def present(key, props:, scope:, context: nil, correlation: nil)
|
|
6
|
+
check_scope!(scope)
|
|
7
|
+
component = registry.fetch(key)
|
|
8
|
+
props = component.schema.validate!(props)
|
|
9
|
+
context = Data.identifier(context, code: "INVALID_CONTEXT") if context
|
|
10
|
+
component.authorize!(access(scope, :present, props: props, context_ref: context))
|
|
11
|
+
correlation = normalize_correlation(correlation) if correlation
|
|
12
|
+
request_digest = Data.digest(component: component.key, digest: component.digest, props: props, context: context)
|
|
13
|
+
lookup = correlation&.merge(actor_ref: scope.actor, tenant_ref: scope.tenant)
|
|
14
|
+
if lookup && (prior = correlated_presentation(lookup))
|
|
15
|
+
return correlated_surface(prior, request_digest, scope)
|
|
16
|
+
end
|
|
17
|
+
surface = nil
|
|
18
|
+
Surface.transaction(requires_new: true) do
|
|
19
|
+
surface = Surface.create!(actor_ref: scope.actor, tenant_ref: scope.tenant, context_ref: context,
|
|
20
|
+
component_key: component.key, component_digest: component.digest, props: props, state: {})
|
|
21
|
+
if lookup
|
|
22
|
+
Presentation.create!(**lookup, surface: surface, request_digest: request_digest,
|
|
23
|
+
result: { status: "presented", surface_id: surface.id, revision: surface.revision })
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
notify("presentation.accepted", surface_id: surface.id, component: component.key, revision: surface.revision)
|
|
27
|
+
surface
|
|
28
|
+
rescue ActiveRecord::RecordNotUnique
|
|
29
|
+
prior = lookup && correlated_presentation(lookup)
|
|
30
|
+
raise unless prior
|
|
31
|
+
correlated_surface(prior, request_digest, scope)
|
|
32
|
+
rescue Error => error
|
|
33
|
+
notify("presentation.rejected", code: error.code)
|
|
34
|
+
raise
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns a compatible Surface after checking scope and current read policy.
|
|
38
|
+
def restore(id, scope:)
|
|
39
|
+
check_scope!(scope)
|
|
40
|
+
surface = Surface.find_by(id: id, actor_ref: scope.actor, tenant_ref: scope.tenant)
|
|
41
|
+
# A database collation that is not binary (for example a case-insensitive
|
|
42
|
+
# MySQL or PostgreSQL collation) can match a stored actor, tenant or id
|
|
43
|
+
# that merely compares equal to the requested one. Only a byte-exact
|
|
44
|
+
# match may stand in for this scope's Surface, or a lookup could disclose
|
|
45
|
+
# another actor's or tenant's record.
|
|
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
|
+
component = compatible_component!(surface)
|
|
48
|
+
component.authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
|
|
49
|
+
surface
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Commits an authorized action and returns its receipt. Matching retries replay without executing the handler.
|
|
53
|
+
def dispatch(id, scope:, action:, arguments:, base_revision:, idempotency_key:, source_component: nil, target: nil)
|
|
54
|
+
surface = restore(id, scope: scope)
|
|
55
|
+
raise Error.new("INVALID_SOURCE") if source_component && source_component.to_s != surface.component_key
|
|
56
|
+
raise Error.new("INVALID_TARGET") if target && target != surface.dom_id
|
|
57
|
+
component = compatible_component!(surface)
|
|
58
|
+
definition = component.actions.fetch(action.to_s) { raise Error.new("UNKNOWN_ACTION") }
|
|
59
|
+
arguments = definition.schema.validate!(arguments, code: "INVALID_ARGUMENTS")
|
|
60
|
+
raise Error.new("INVALID_REVISION") unless base_revision.is_a?(Integer) && base_revision.positive?
|
|
61
|
+
idempotency_key = Data.identifier(idempotency_key, code: "INVALID_IDEMPOTENCY_KEY")
|
|
62
|
+
request_digest = Data.digest(action: definition.key, arguments: arguments, base_revision: base_revision)
|
|
63
|
+
result = nil
|
|
64
|
+
surface.with_lock do
|
|
65
|
+
compatible_component!(surface).authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
|
|
66
|
+
request = access(scope, :action, surface: surface, props: surface.props, action: definition.key,
|
|
67
|
+
arguments: arguments, context_ref: surface.context_ref)
|
|
68
|
+
raise Error.new("NOT_ALLOWED", status: 403) unless definition.policy&.call(request) == true
|
|
69
|
+
prior = ActionExecution.find_by(surface_id: surface.id, idempotency_key: idempotency_key)
|
|
70
|
+
if prior
|
|
71
|
+
# A non-binary collation could match a different key; only replay the
|
|
72
|
+
# exact key, and keep the digest check as the second gate.
|
|
73
|
+
raise Error.new("IDEMPOTENCY_CONFLICT", status: 409) unless prior.idempotency_key == idempotency_key && prior.request_digest == request_digest
|
|
74
|
+
result = prior.result.symbolize_keys
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
raise Error.new("CONFLICT", status: 409) unless surface.revision == base_revision
|
|
78
|
+
if surface.action_executions.where("created_at >= ?", 1.minute.ago).count >= configuration.max_actions_per_minute
|
|
79
|
+
raise Error.new("RATE_LIMITED", status: 429)
|
|
80
|
+
end
|
|
81
|
+
transition = definition.handler.call(request)
|
|
82
|
+
raise Error.new("INVALID_TRANSITION", status: 422) unless transition.is_a?(Transition) && transition.state.is_a?(Hash)
|
|
83
|
+
state = Data.bounded(transition.state)
|
|
84
|
+
surface.update!(state: state, revision: base_revision + 1)
|
|
85
|
+
result = { status: "completed", surface_id: surface.id, revision: surface.revision }
|
|
86
|
+
execution = ActionExecution.create!(surface: surface, actor_ref: scope.actor, action_key: definition.key,
|
|
87
|
+
base_revision: base_revision, idempotency_key: idempotency_key, request_digest: request_digest, result: result)
|
|
88
|
+
if transition.agent
|
|
89
|
+
interaction = AgentInteraction.create!(surface: surface, action_execution: execution,
|
|
90
|
+
payload: { type: "agent_interaction", surface_id: surface.id, action: definition.key, values: arguments,
|
|
91
|
+
context_ref: surface.context_ref, revision: surface.revision }, status: configuration.agent_adapter ? "pending" : "skipped")
|
|
92
|
+
result[:interaction_id] = interaction.id
|
|
93
|
+
execution.update!(result: result)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
notify("action.executed", surface_id: surface.id, revision: result[:revision], action: definition.key)
|
|
97
|
+
result
|
|
98
|
+
rescue ActiveRecord::StaleObjectError, ActiveRecord::TransactionRollbackError, ActiveRecord::LockWaitTimeout
|
|
99
|
+
# The database rolled the whole action back (deadlock, serialization failure,
|
|
100
|
+
# lock timeout); retrying with the same key is safe.
|
|
101
|
+
raise Error.new("CONFLICT", status: 409)
|
|
102
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
103
|
+
raise Error.new("CONFLICT", status: 409) if error.cause.class.name.include?("Busy") || error.cause.class.name.include?("Locked")
|
|
104
|
+
raise
|
|
105
|
+
rescue Error => error
|
|
106
|
+
notify(error.code == "CONFLICT" ? "action.conflict" : "action.rejected", code: error.code)
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The adapter must deduplicate by delivery_key. A crash after remote acceptance
|
|
111
|
+
# can leave a lease in delivering state; retry is explicit and keeps the key.
|
|
112
|
+
# Delivers a saved interaction after authorization; retry_failed explicitly retries failed delivery.
|
|
113
|
+
def deliver(id, scope:, adapter: configuration.agent_adapter, retry_failed: false)
|
|
114
|
+
interaction = AgentInteraction.find_by(id: id)
|
|
115
|
+
raise Error.new("NOT_FOUND", status: 404) unless interaction
|
|
116
|
+
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
|
+
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
|
|
133
|
+
end
|
|
134
|
+
interaction.update!(status: "delivering", claimed_at: Time.current, attempts: interaction.attempts + 1, error_code: nil)
|
|
135
|
+
claimed = true
|
|
136
|
+
end
|
|
137
|
+
return interaction.reload unless claimed
|
|
138
|
+
begin
|
|
139
|
+
adapter.call(Data.freeze_deep(Data.normalize(interaction.payload)), delivery_key: interaction.delivery_key)
|
|
140
|
+
interaction.update!(status: "completed", claimed_at: nil)
|
|
141
|
+
notify("agent_returned", surface_id: interaction.surface_id, interaction_id: interaction.id)
|
|
142
|
+
rescue StandardError => error
|
|
143
|
+
interaction.update!(status: "failed", error_code: "AGENT_UNAVAILABLE", claimed_at: nil)
|
|
144
|
+
report(error, "agent_failed", surface_id: interaction.surface_id, interaction_id: interaction.id)
|
|
145
|
+
end
|
|
146
|
+
interaction.reload
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Renders the trusted component after a fresh restore and authorization check.
|
|
150
|
+
def render(surface, view_context:, scope:, error: nil)
|
|
151
|
+
# Never trust an already-loaded object as an authorization decision.
|
|
152
|
+
surface = restore(surface.id, scope: scope)
|
|
153
|
+
component = compatible_component!(surface)
|
|
154
|
+
view_context.render(partial: "rails_tool_views/surfaces/surface", formats: [:html], locals: { surface: surface, definition: component, error: error })
|
|
155
|
+
rescue Error => failure
|
|
156
|
+
view_context.render(partial: "rails_tool_views/surfaces/error", formats: [:html], locals: { error: failure })
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def compatible_component!(surface) # :nodoc:
|
|
160
|
+
component = registry.fetch(surface.component_key)
|
|
161
|
+
raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) unless surface.contract_version == 1 && component.digest == surface.component_digest
|
|
162
|
+
component.schema.validate!(surface.props)
|
|
163
|
+
component
|
|
164
|
+
rescue Error => error
|
|
165
|
+
raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) if %w[UNKNOWN_COMPONENT INVALID_PROPS].include?(error.code)
|
|
166
|
+
raise
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The mounted controller needs CSRF protection, helpers and HTML rendering.
|
|
170
|
+
def parent_controller # :nodoc:
|
|
171
|
+
parent = configuration.parent_controller.to_s.constantize
|
|
172
|
+
return parent if parent <= ::ActionController::Base
|
|
173
|
+
raise ArgumentError, "RailsToolViews parent_controller must inherit from ActionController::Base; #{parent} does not"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def access(scope, operation, **attributes) # :nodoc:
|
|
177
|
+
Access.new(actor: scope.actor, tenant: scope.tenant, operation: operation, **attributes).freeze
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def notify(event, **payload) # :nodoc:
|
|
181
|
+
ActiveSupport::Notifications.instrument("#{event}.rails_tool_views", payload)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Host renderer or adapter code failed behind a rescue. Surface it the way
|
|
185
|
+
# Rails surfaces handled errors; the event itself stays metadata-only.
|
|
186
|
+
def report(error, event, **payload) # :nodoc:
|
|
187
|
+
notify(event, **payload, error_class: error.class.name)
|
|
188
|
+
Rails.error.report(error, handled: true, severity: :error, context: payload, source: "rails_tool_views")
|
|
189
|
+
Rails.logger&.error("RailsToolViews #{event} handled #{error.class} (#{error.message}) - #{error.backtrace&.first}")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
def check_scope!(scope)
|
|
194
|
+
raise Error.new("INVALID_SCOPE", status: 403) unless scope.is_a?(Scope)
|
|
195
|
+
end
|
|
196
|
+
# True only when every attribute equals the requested value byte for byte,
|
|
197
|
+
# regardless of the database collation used to find the row.
|
|
198
|
+
def exact_match?(record, **expected)
|
|
199
|
+
expected.all? { |attribute, value| record.public_send(attribute) == value }
|
|
200
|
+
end
|
|
201
|
+
# Returns the durable presentation for this exact correlation, or nil. A row
|
|
202
|
+
# that a non-binary collation matched but that is not byte-identical is a
|
|
203
|
+
# conflict, never a replay of another correlation.
|
|
204
|
+
def correlated_presentation(lookup)
|
|
205
|
+
prior = Presentation.find_by(lookup)
|
|
206
|
+
return nil unless prior
|
|
207
|
+
return prior if exact_match?(prior, **lookup)
|
|
208
|
+
raise Error.new("IDEMPOTENCY_CONFLICT", status: 409)
|
|
209
|
+
end
|
|
210
|
+
def normalize_correlation(value)
|
|
211
|
+
raise Error.new("INVALID_CORRELATION") unless value.is_a?(Hash) && (value.keys - %i[adapter call_id context]).empty? && value.keys.sort == %i[adapter call_id context].sort
|
|
212
|
+
{ adapter: Data.identifier(value[:adapter], code: "INVALID_CORRELATION"),
|
|
213
|
+
call_id: Data.identifier(value[:call_id], code: "INVALID_CORRELATION"),
|
|
214
|
+
context_ref: Data.identifier(value[:context], code: "INVALID_CORRELATION") }
|
|
215
|
+
end
|
|
216
|
+
def correlated_surface(prior, digest, scope)
|
|
217
|
+
raise Error.new("IDEMPOTENCY_CONFLICT", status: 409) unless prior.request_digest == digest
|
|
218
|
+
restore(prior.surface_id, scope: scope)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|