fosm-rails 0.2.0 → 0.2.1
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/app/controllers/fosm/admin/settings_controller.rb +1 -1
- data/app/controllers/fosm/admin/transitions_controller.rb +1 -1
- data/app/models/fosm/transition_log.rb +11 -0
- data/app/views/fosm/admin/apps/show.html.erb +17 -9
- data/config/routes.rb +5 -5
- data/db/migrate/20240101000001_create_fosm_transition_logs.rb +1 -1
- data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +1 -1
- data/db/migrate/20240101000005_add_state_snapshot_to_fosm_transition_logs.rb +7 -0
- data/lib/fosm/engine.rb +1 -1
- data/lib/fosm/errors.rb +10 -2
- data/lib/fosm/lifecycle/definition.rb +51 -2
- data/lib/fosm/lifecycle/guard_definition.rb +22 -0
- data/lib/fosm/lifecycle/side_effect_definition.rb +7 -2
- data/lib/fosm/lifecycle/snapshot_configuration.rb +148 -0
- data/lib/fosm/lifecycle.rb +365 -15
- data/lib/fosm/registry.rb +9 -0
- data/lib/fosm/version.rb +1 -1
- data/lib/generators/fosm/app/app_generator.rb +32 -0
- data/lib/tasks/fosm_graph.rake +135 -0
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7d819f282973824d9dcb7c7d502193f59f3394602ff7f334eddb160eb401a10
|
|
4
|
+
data.tar.gz: 1b4ced5b691e16ffde902dd1686e8d5c05723e96eaab610a7f0f01950703e925
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f98e77d77b443810901e802538cb1a169d9dbf0ecc63c64b19d64b14a782b181748199e5d51520c812f27f1b0f9779786a739a21f5ecfba093c2024b1bbe46c2
|
|
7
|
+
data.tar.gz: 39179d9dd4fa91b077745547ba29c667c19d74e966f2eb0a2d88ed2bd370ea03b3862080179d6be15c079710eed1a759f0f8dee4fecd3440fc1250d3fecdfe6e
|
|
@@ -13,7 +13,7 @@ module Fosm
|
|
|
13
13
|
{ name: "OpenAI", env_key: "OPENAI_API_KEY", model_prefix: "openai/" },
|
|
14
14
|
{ name: "Google (Gemini)", env_key: "GEMINI_API_KEY", model_prefix: "gemini/" },
|
|
15
15
|
{ name: "Cohere", env_key: "COHERE_API_KEY", model_prefix: "cohere/" },
|
|
16
|
-
{ name: "Mistral", env_key: "MISTRAL_API_KEY", model_prefix: "mistral/" }
|
|
16
|
+
{ name: "Mistral", env_key: "MISTRAL_API_KEY", model_prefix: "mistral/" }
|
|
17
17
|
].freeze
|
|
18
18
|
|
|
19
19
|
def detect_llm_providers
|
|
@@ -10,7 +10,7 @@ module Fosm
|
|
|
10
10
|
@transitions = @transitions.where.not(actor_type: "symbol") if params[:actor] == "human"
|
|
11
11
|
|
|
12
12
|
@per_page = 50
|
|
13
|
-
@current_page = [params[:page].to_i, 1].max
|
|
13
|
+
@current_page = [ params[:page].to_i, 1 ].max
|
|
14
14
|
@total_count = @transitions.count
|
|
15
15
|
@total_pages = (@total_count / @per_page.to_f).ceil
|
|
16
16
|
@transitions = @transitions.limit(@per_page).offset((@current_page - 1) * @per_page)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Fosm
|
|
2
2
|
# Immutable audit trail of every FOSM state transition.
|
|
3
3
|
# Records are never updated or deleted — this is an append-only log.
|
|
4
|
+
# Supports optional state snapshots for efficient replay and audit.
|
|
4
5
|
class TransitionLog < ApplicationRecord
|
|
5
6
|
self.table_name = "fosm_transition_logs"
|
|
6
7
|
|
|
@@ -16,6 +17,11 @@ module Fosm
|
|
|
16
17
|
scope :by_event, ->(event) { where(event_name: event.to_s) }
|
|
17
18
|
scope :by_actor_type, ->(type) { where(actor_type: type) }
|
|
18
19
|
|
|
20
|
+
# Snapshot-related scopes
|
|
21
|
+
scope :with_snapshot, -> { where.not(state_snapshot: nil) }
|
|
22
|
+
scope :without_snapshot, -> { where(state_snapshot: nil) }
|
|
23
|
+
scope :by_snapshot_reason, ->(reason) { where(snapshot_reason: reason) }
|
|
24
|
+
|
|
19
25
|
def by_agent?
|
|
20
26
|
actor_type == "symbol" && actor_label == "agent"
|
|
21
27
|
end
|
|
@@ -23,5 +29,10 @@ module Fosm
|
|
|
23
29
|
def by_human?
|
|
24
30
|
!by_agent? && actor_id.present?
|
|
25
31
|
end
|
|
32
|
+
|
|
33
|
+
# Returns true if this log entry includes a state snapshot
|
|
34
|
+
def snapshot?
|
|
35
|
+
state_snapshot.present?
|
|
36
|
+
end
|
|
26
37
|
end
|
|
27
38
|
end
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
<% end %>
|
|
13
13
|
</div>
|
|
14
14
|
<div class="flex gap-2 shrink-0">
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
<% if main_app.respond_to?("fosm_#{@slug.pluralize}_path") %>
|
|
16
|
+
<%= link_to "View all #{@model_class.name.demodulize.humanize.pluralize}",
|
|
17
|
+
main_app.send("fosm_#{@slug.pluralize}_path"),
|
|
18
|
+
class: "text-sm border border-gray-200 bg-white text-gray-700 px-3 py-1.5 rounded hover:bg-gray-50" %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if main_app.respond_to?("new_fosm_#{@slug}_path") %>
|
|
21
|
+
<%= link_to "+ New #{@model_class.name.demodulize.humanize}",
|
|
22
|
+
main_app.send("new_fosm_#{@slug}_path"),
|
|
23
|
+
class: "text-sm bg-gray-900 text-white px-3 py-1.5 rounded hover:bg-gray-700" %>
|
|
24
|
+
<% end %>
|
|
21
25
|
<%= link_to "Agent Tools",
|
|
22
26
|
fosm.agent_admin_app_path(@slug),
|
|
23
27
|
class: "text-sm border border-purple-200 bg-purple-50 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-100" %>
|
|
@@ -186,9 +190,13 @@
|
|
|
186
190
|
<% @recent_transitions.each do |t| %>
|
|
187
191
|
<tr>
|
|
188
192
|
<td class="py-2 text-gray-600">
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
193
|
+
<% if main_app.respond_to?("fosm_#{@slug}_path") %>
|
|
194
|
+
<%= link_to "##{t.record_id}",
|
|
195
|
+
main_app.send("fosm_#{@slug}_path", t.record_id),
|
|
196
|
+
class: "text-blue-600 hover:underline" %>
|
|
197
|
+
<% else %>
|
|
198
|
+
<span class="text-gray-500">#<%= t.record_id %></span>
|
|
199
|
+
<% end %>
|
|
192
200
|
</td>
|
|
193
201
|
<td class="py-2 font-medium"><%= t.event_name %></td>
|
|
194
202
|
<td class="py-2">
|
data/config/routes.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Fosm::Engine.routes.draw do
|
|
2
2
|
namespace :admin do
|
|
3
3
|
root to: "dashboard#index"
|
|
4
|
-
resources :apps, only: [:index, :show], param: :slug do
|
|
4
|
+
resources :apps, only: [ :index, :show ], param: :slug do
|
|
5
5
|
member do
|
|
6
6
|
get :agent, to: "agents#show"
|
|
7
7
|
post :agent_invoke, to: "agents#agent_invoke"
|
|
@@ -10,13 +10,13 @@ Fosm::Engine.routes.draw do
|
|
|
10
10
|
delete "agent/chat", to: "agents#chat_reset", as: :agent_chat_reset
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
|
-
resources :transitions, only: [:index]
|
|
14
|
-
resources :webhooks, only: [:index, :new, :create, :destroy]
|
|
15
|
-
resources :roles, only: [:index, :new, :create, :destroy] do
|
|
13
|
+
resources :transitions, only: [ :index ]
|
|
14
|
+
resources :webhooks, only: [ :index, :new, :create, :destroy ]
|
|
15
|
+
resources :roles, only: [ :index, :new, :create, :destroy ] do
|
|
16
16
|
collection do
|
|
17
17
|
get :users_search
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
|
-
resource
|
|
20
|
+
resource :settings, only: [ :show ]
|
|
21
21
|
end
|
|
22
22
|
end
|
|
@@ -15,7 +15,7 @@ class CreateFosmTransitionLogs < ActiveRecord::Migration[8.1]
|
|
|
15
15
|
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
add_index :fosm_transition_logs, [:record_type, :record_id], name: "idx_fosm_tl_record"
|
|
18
|
+
add_index :fosm_transition_logs, [ :record_type, :record_id ], name: "idx_fosm_tl_record"
|
|
19
19
|
add_index :fosm_transition_logs, :event_name, name: "idx_fosm_tl_event"
|
|
20
20
|
add_index :fosm_transition_logs, :created_at, name: "idx_fosm_tl_created_at"
|
|
21
21
|
add_index :fosm_transition_logs, :actor_label, name: "idx_fosm_tl_actor"
|
|
@@ -10,7 +10,7 @@ class CreateFosmWebhookSubscriptions < ActiveRecord::Migration[8.1]
|
|
|
10
10
|
t.timestamps
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
add_index :fosm_webhook_subscriptions, [:model_class_name, :event_name], name: "idx_fosm_webhooks_model_event"
|
|
13
|
+
add_index :fosm_webhook_subscriptions, [ :model_class_name, :event_name ], name: "idx_fosm_webhooks_model_event"
|
|
14
14
|
add_index :fosm_webhook_subscriptions, :active, name: "idx_fosm_webhooks_active"
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddStateSnapshotToFosmTransitionLogs < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :fosm_transition_logs, :state_snapshot, :json, default: nil
|
|
4
|
+
add_column :fosm_transition_logs, :snapshot_reason, :string, default: nil
|
|
5
|
+
add_index :fosm_transition_logs, :snapshot_reason, name: "idx_fosm_tl_snapshot_reason"
|
|
6
|
+
end
|
|
7
|
+
end
|
data/lib/fosm/engine.rb
CHANGED
|
@@ -125,7 +125,7 @@ module Fosm
|
|
|
125
125
|
klass.respond_to?(:fosm_lifecycle) &&
|
|
126
126
|
klass.fosm_lifecycle.present?
|
|
127
127
|
}.each do |klass|
|
|
128
|
-
slug = klass.name.demodulize.underscore
|
|
128
|
+
slug = klass.name.demodulize.underscore
|
|
129
129
|
Fosm::Registry.register(klass, slug: slug)
|
|
130
130
|
end
|
|
131
131
|
|
data/lib/fosm/errors.rb
CHANGED
|
@@ -16,9 +16,17 @@ module Fosm
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Raised when a guard blocks a transition
|
|
19
|
+
# 🆕 Supports optional reason for better error messages
|
|
19
20
|
class GuardFailed < Error
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
attr_reader :guard_name, :event_name, :reason
|
|
22
|
+
|
|
23
|
+
def initialize(guard_name, event_name, reason = nil)
|
|
24
|
+
@guard_name = guard_name
|
|
25
|
+
@event_name = event_name
|
|
26
|
+
@reason = reason
|
|
27
|
+
msg = "Guard '#{guard_name}' prevented transition for event '#{event_name}'"
|
|
28
|
+
msg += ": #{reason}" if reason
|
|
29
|
+
super(msg)
|
|
22
30
|
end
|
|
23
31
|
end
|
|
24
32
|
|
|
@@ -3,6 +3,7 @@ require_relative "event_definition"
|
|
|
3
3
|
require_relative "guard_definition"
|
|
4
4
|
require_relative "side_effect_definition"
|
|
5
5
|
require_relative "access_definition"
|
|
6
|
+
require_relative "snapshot_configuration"
|
|
6
7
|
|
|
7
8
|
module Fosm
|
|
8
9
|
module Lifecycle
|
|
@@ -89,8 +90,14 @@ module Fosm
|
|
|
89
90
|
end
|
|
90
91
|
|
|
91
92
|
# DSL: declare a side effect on an event
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
# Options:
|
|
94
|
+
# defer: false (default), true — run after transaction commits
|
|
95
|
+
def side_effect(name, on:, defer: false, &block)
|
|
96
|
+
side_effect_def = SideEffectDefinition.new(
|
|
97
|
+
name: name,
|
|
98
|
+
defer: defer,
|
|
99
|
+
&block
|
|
100
|
+
)
|
|
94
101
|
event_def = find_event(on)
|
|
95
102
|
if event_def
|
|
96
103
|
event_def.add_side_effect(side_effect_def)
|
|
@@ -125,6 +132,48 @@ module Fosm
|
|
|
125
132
|
@events.select { |e| e.valid_from?(state) }
|
|
126
133
|
end
|
|
127
134
|
|
|
135
|
+
# DSL: configure automatic state snapshots on transitions
|
|
136
|
+
# Supports multiple strategies for how often to snapshot:
|
|
137
|
+
#
|
|
138
|
+
# snapshot :every # snapshot on every transition
|
|
139
|
+
# snapshot every: 10 # snapshot every 10 transitions
|
|
140
|
+
# snapshot time: 300 # snapshot if >5 min since last snapshot
|
|
141
|
+
# snapshot :terminal # snapshot only when reaching terminal states
|
|
142
|
+
# snapshot :manual # only snapshot when explicitly requested (default)
|
|
143
|
+
#
|
|
144
|
+
# snapshot_attributes :amount, :due_date, :line_items_count
|
|
145
|
+
#
|
|
146
|
+
def snapshot(strategy = nil, **options)
|
|
147
|
+
@snapshot_configuration ||= SnapshotConfiguration.new
|
|
148
|
+
|
|
149
|
+
if strategy.is_a?(Symbol) || strategy.is_a?(String)
|
|
150
|
+
@snapshot_configuration.send(strategy)
|
|
151
|
+
elsif options[:every]
|
|
152
|
+
@snapshot_configuration.count(options[:every])
|
|
153
|
+
elsif options[:time]
|
|
154
|
+
@snapshot_configuration.time(options[:time])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@snapshot_configuration
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# DSL: specify which attributes to include in snapshots
|
|
161
|
+
# Usage: snapshot_attributes :amount, :status, :line_items_count
|
|
162
|
+
def snapshot_attributes(*attrs)
|
|
163
|
+
@snapshot_configuration ||= SnapshotConfiguration.new
|
|
164
|
+
@snapshot_configuration.set_attributes(*attrs)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Returns true if snapshot configuration has been set
|
|
168
|
+
def snapshot_configured?
|
|
169
|
+
@snapshot_configuration.present?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Returns the snapshot configuration (nil if not configured)
|
|
173
|
+
def snapshot_configuration
|
|
174
|
+
@snapshot_configuration
|
|
175
|
+
end
|
|
176
|
+
|
|
128
177
|
# Returns a hash suitable for rendering a state diagram
|
|
129
178
|
def to_diagram_data
|
|
130
179
|
{
|
|
@@ -11,6 +11,28 @@ module Fosm
|
|
|
11
11
|
def call(record)
|
|
12
12
|
@block.call(record)
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
# 🆕 Evaluate guard and return [allowed, reason] tuple
|
|
16
|
+
# Supports: true/false (legacy), String (failure reason), [:fail, reason]
|
|
17
|
+
def evaluate(record)
|
|
18
|
+
result = call(record)
|
|
19
|
+
|
|
20
|
+
case result
|
|
21
|
+
when true
|
|
22
|
+
[ true, nil ]
|
|
23
|
+
when false
|
|
24
|
+
[ false, nil ]
|
|
25
|
+
when String
|
|
26
|
+
# String is treated as failure reason
|
|
27
|
+
[ false, result ]
|
|
28
|
+
when Array
|
|
29
|
+
result[0] == :fail ? [ false, result[1] ] : [ true, nil ]
|
|
30
|
+
else
|
|
31
|
+
# 🆕 Any other truthy value is treated as passing
|
|
32
|
+
# Only false/nil fails; everything else passes
|
|
33
|
+
result ? [ true, nil ] : [ false, nil ]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
14
36
|
end
|
|
15
37
|
end
|
|
16
38
|
end
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
module Fosm
|
|
2
2
|
module Lifecycle
|
|
3
3
|
class SideEffectDefinition
|
|
4
|
-
attr_reader :name
|
|
4
|
+
attr_reader :name, :deferred
|
|
5
5
|
|
|
6
|
-
def initialize(name:, &block)
|
|
6
|
+
def initialize(name:, defer: false, &block)
|
|
7
7
|
@name = name
|
|
8
|
+
@deferred = defer
|
|
8
9
|
@block = block
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def call(record, transition)
|
|
12
13
|
@block.call(record, transition)
|
|
13
14
|
end
|
|
15
|
+
|
|
16
|
+
def deferred?
|
|
17
|
+
@deferred
|
|
18
|
+
end
|
|
14
19
|
end
|
|
15
20
|
end
|
|
16
21
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Lifecycle
|
|
3
|
+
# Configuration for when and what to snapshot during transitions.
|
|
4
|
+
# Supports multiple strategies: every, count, time, terminal, manual
|
|
5
|
+
class SnapshotConfiguration
|
|
6
|
+
# Strategy types
|
|
7
|
+
STRATEGIES = %i[every count time terminal manual].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :strategy, :interval, :conditions
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@strategy = :manual # default: no automatic snapshots
|
|
13
|
+
@attributes = [] # empty = snapshot all readable attributes
|
|
14
|
+
@interval = nil # for :count (transitions) or :time (seconds)
|
|
15
|
+
@conditions = [] # additional conditions that must be met
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the configured attributes
|
|
19
|
+
def attributes
|
|
20
|
+
@attributes
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# DSL: snapshot on every transition
|
|
24
|
+
# Usage: snapshot :every
|
|
25
|
+
def every
|
|
26
|
+
@strategy = :every
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# DSL: snapshot every N transitions
|
|
30
|
+
# Usage: snapshot every: 10
|
|
31
|
+
def count(n)
|
|
32
|
+
@strategy = :count
|
|
33
|
+
@interval = n
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# DSL: snapshot if last snapshot was more than N seconds ago
|
|
37
|
+
# Usage: snapshot time: 300 (5 minutes)
|
|
38
|
+
def time(seconds)
|
|
39
|
+
@strategy = :time
|
|
40
|
+
@interval = seconds
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# DSL: snapshot only on terminal states
|
|
44
|
+
# Usage: snapshot :terminal
|
|
45
|
+
def terminal
|
|
46
|
+
@strategy = :terminal
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# DSL: manual snapshots only (default)
|
|
50
|
+
# Usage: snapshot :manual
|
|
51
|
+
def manual
|
|
52
|
+
@strategy = :manual
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# DSL: specify which attributes to snapshot
|
|
56
|
+
# Usage: snapshot_attributes :amount, :status, :line_items_count
|
|
57
|
+
# snapshot_attributes %i[amount status] # array also works
|
|
58
|
+
def set_attributes(*attrs)
|
|
59
|
+
@attributes = attrs.flatten.map(&:to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if a snapshot should be taken for this transition
|
|
63
|
+
# @param transition_count [Integer] transitions since last snapshot
|
|
64
|
+
# @param seconds_since_last [Float] seconds since last snapshot
|
|
65
|
+
# @param to_state [String] the state we're transitioning to
|
|
66
|
+
# @param to_state_terminal [Boolean] whether the destination state is terminal
|
|
67
|
+
# @param force [Boolean] manual override to force snapshot
|
|
68
|
+
def should_snapshot?(transition_count:, seconds_since_last:, to_state:, to_state_terminal:, force: false)
|
|
69
|
+
return true if force
|
|
70
|
+
return false if @strategy == :manual
|
|
71
|
+
return true if @strategy == :every
|
|
72
|
+
return true if @strategy == :terminal && to_state_terminal
|
|
73
|
+
return true if @strategy == :count && transition_count >= @interval
|
|
74
|
+
return true if @strategy == :time && seconds_since_last >= @interval
|
|
75
|
+
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build the snapshot data from a record
|
|
80
|
+
# @param record [ActiveRecord::Base] the record to snapshot
|
|
81
|
+
# @return [Hash] the snapshot data
|
|
82
|
+
def build_snapshot(record)
|
|
83
|
+
attrs = @attributes.any? ? @attributes : default_attributes(record)
|
|
84
|
+
|
|
85
|
+
snapshot = {}
|
|
86
|
+
attrs.each do |attr|
|
|
87
|
+
value = read_attribute(record, attr)
|
|
88
|
+
snapshot[attr] = serialize_value(value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Always include core FOSM metadata
|
|
92
|
+
snapshot["_fosm_snapshot_meta"] = {
|
|
93
|
+
"snapshot_at" => Time.current.iso8601,
|
|
94
|
+
"record_class" => record.class.name,
|
|
95
|
+
"record_id" => record.id.to_s
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
snapshot
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Default attributes to snapshot when none specified
|
|
104
|
+
# Excludes internal AR columns and associations
|
|
105
|
+
def default_attributes(record)
|
|
106
|
+
record.attributes.keys.reject do |attr|
|
|
107
|
+
attr.start_with?("_") ||
|
|
108
|
+
%w[id created_at updated_at].include?(attr) ||
|
|
109
|
+
attr.end_with?("_id") && record.class.reflect_on_association(attr.sub(/_id$/, ""))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def read_attribute(record, attr)
|
|
114
|
+
# Handle associations (e.g., :line_items -> count)
|
|
115
|
+
if attr.to_s.end_with?("_count") || attr.to_s.start_with?("count_")
|
|
116
|
+
association_name = attr.to_s.gsub(/^_count|count_/, "").pluralize
|
|
117
|
+
if record.respond_to?(association_name)
|
|
118
|
+
record.send(association_name).count
|
|
119
|
+
else
|
|
120
|
+
record.send(attr)
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
record.send(attr)
|
|
124
|
+
end
|
|
125
|
+
rescue => e
|
|
126
|
+
# Graceful degradation: log error, return nil for this attribute
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def serialize_value(value)
|
|
131
|
+
case value
|
|
132
|
+
when ActiveRecord::Base
|
|
133
|
+
{ "_type" => "record", "class" => value.class.name, "id" => value.id }
|
|
134
|
+
when ActiveRecord::Relation, Array
|
|
135
|
+
value.map { |v| serialize_value(v) }
|
|
136
|
+
when Time, DateTime, Date
|
|
137
|
+
{ "_type" => "datetime", "value" => value.iso8601 }
|
|
138
|
+
when BigDecimal
|
|
139
|
+
{ "_type" => "decimal", "value" => value.to_s }
|
|
140
|
+
when Symbol
|
|
141
|
+
value.to_s
|
|
142
|
+
else
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/fosm/lifecycle.rb
CHANGED
|
@@ -34,8 +34,8 @@ module Fosm
|
|
|
34
34
|
|
|
35
35
|
# Generate dynamic bang methods per event: invoice.send_invoice!(actor: user)
|
|
36
36
|
fosm_lifecycle.events.each do |event_def|
|
|
37
|
-
define_method(:"#{event_def.name}!") do |actor: nil, metadata: {}|
|
|
38
|
-
fire!(event_def.name, actor: actor, metadata: metadata)
|
|
37
|
+
define_method(:"#{event_def.name}!") do |actor: nil, metadata: {}, snapshot_data: nil|
|
|
38
|
+
fire!(event_def.name, actor: actor, metadata: metadata, snapshot_data: snapshot_data)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
define_method(:"can_#{event_def.name}?") do
|
|
@@ -53,21 +53,31 @@ module Fosm
|
|
|
53
53
|
# 3. Check event is valid from current state
|
|
54
54
|
# 4. Run guards (pure in-memory functions)
|
|
55
55
|
# 5. RBAC check (O(1) cache lookup after first request hit)
|
|
56
|
-
# 6.
|
|
56
|
+
# 6. Acquire row lock (SELECT FOR UPDATE) and re-validate
|
|
57
|
+
# 7. BEGIN TRANSACTION: UPDATE state, run side effects
|
|
57
58
|
# [optionally INSERT log if strategy == :sync]
|
|
58
59
|
# COMMIT
|
|
59
|
-
#
|
|
60
|
-
#
|
|
60
|
+
# 8. Emit transition log (:async or :buffered, non-blocking)
|
|
61
|
+
# 9. Enqueue webhook delivery (always async)
|
|
62
|
+
#
|
|
63
|
+
# RACE CONDITION PROTECTION:
|
|
64
|
+
# - Uses SELECT FOR UPDATE to lock the row, preventing concurrent transitions
|
|
65
|
+
# - Re-validates state, guards, and RBAC after acquiring lock
|
|
66
|
+
# - Guarantees only one transition succeeds when concurrent requests fire
|
|
67
|
+
# the same event on the same record
|
|
61
68
|
#
|
|
62
69
|
# @param event_name [Symbol, String] the event to fire
|
|
63
70
|
# @param actor [Object] who/what is firing the event (User, or :system/:agent symbol)
|
|
64
71
|
# @param metadata [Hash] optional metadata stored in the transition log
|
|
72
|
+
# @param snapshot_data [Hash] arbitrary observations/data to include in state snapshot
|
|
73
|
+
# This allows capturing adhoc observations alongside schema attributes.
|
|
74
|
+
# Merged with schema data under the `_observations` key in the snapshot.
|
|
65
75
|
# @raise [Fosm::UnknownEvent] if event doesn't exist
|
|
66
76
|
# @raise [Fosm::TerminalState] if current state is terminal
|
|
67
77
|
# @raise [Fosm::InvalidTransition] if current state doesn't allow this event
|
|
68
78
|
# @raise [Fosm::GuardFailed] if a guard blocks the transition
|
|
69
79
|
# @raise [Fosm::AccessDenied] if actor lacks a role that permits this event
|
|
70
|
-
def fire!(event_name, actor: nil, metadata: {})
|
|
80
|
+
def fire!(event_name, actor: nil, metadata: {}, snapshot_data: nil)
|
|
71
81
|
lifecycle = self.class.fosm_lifecycle
|
|
72
82
|
raise Fosm::Error, "No lifecycle defined on #{self.class.name}" unless lifecycle
|
|
73
83
|
|
|
@@ -88,9 +98,11 @@ module Fosm
|
|
|
88
98
|
end
|
|
89
99
|
|
|
90
100
|
# Run guards (pure functions — no side effects, evaluated before any writes)
|
|
101
|
+
# 🆕 Use evaluate for rich error messages
|
|
91
102
|
event_def.guards.each do |guard_def|
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
allowed, reason = guard_def.evaluate(self)
|
|
104
|
+
unless allowed
|
|
105
|
+
raise Fosm::GuardFailed.new(guard_def.name, event_name, reason)
|
|
94
106
|
end
|
|
95
107
|
end
|
|
96
108
|
|
|
@@ -103,6 +115,9 @@ module Fosm
|
|
|
103
115
|
to_state = event_def.to_state.to_s
|
|
104
116
|
transition_data = { from: from_state, to: to_state, event: event_name.to_s, actor: actor }
|
|
105
117
|
|
|
118
|
+
# Auto-capture triggered_by when called from within a side effect
|
|
119
|
+
triggered_by = Thread.current[:fosm_trigger_context]
|
|
120
|
+
|
|
106
121
|
log_data = {
|
|
107
122
|
"record_type" => self.class.name,
|
|
108
123
|
"record_id" => self.id.to_s,
|
|
@@ -112,20 +127,152 @@ module Fosm
|
|
|
112
127
|
"actor_type" => actor_type_for(actor),
|
|
113
128
|
"actor_id" => actor_id_for(actor),
|
|
114
129
|
"actor_label" => actor_label_for(actor),
|
|
115
|
-
"metadata" => metadata
|
|
130
|
+
"metadata" => metadata.merge(
|
|
131
|
+
triggered_by ? { triggered_by: triggered_by } : {}
|
|
132
|
+
).compact
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
# ==========================================================================
|
|
136
|
+
# SNAPSHOT CONFIGURATION
|
|
137
|
+
# ==========================================================================
|
|
138
|
+
# If snapshots are configured, determine if we should capture one for this
|
|
139
|
+
# transition based on the strategy (every, count, time, terminal, manual).
|
|
140
|
+
# ==========================================================================
|
|
141
|
+
snapshot_config = lifecycle.snapshot_configuration
|
|
142
|
+
if snapshot_config && metadata[:snapshot] != false # allow manual opt-out
|
|
143
|
+
# Calculate metrics for snapshot decision
|
|
144
|
+
last_snapshot = Fosm::TransitionLog
|
|
145
|
+
.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
146
|
+
.where.not(state_snapshot: nil)
|
|
147
|
+
.order(created_at: :desc)
|
|
148
|
+
.first
|
|
149
|
+
|
|
150
|
+
transitions_since = last_snapshot ?
|
|
151
|
+
Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
152
|
+
.where("created_at > ?", last_snapshot.created_at).count :
|
|
153
|
+
Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s).count
|
|
154
|
+
|
|
155
|
+
seconds_since = last_snapshot ?
|
|
156
|
+
(Time.current - last_snapshot.created_at).to_f :
|
|
157
|
+
Float::INFINITY
|
|
158
|
+
|
|
159
|
+
to_state_def = lifecycle.find_state(to_state)
|
|
160
|
+
|
|
161
|
+
# Check if we should snapshot (respecting manual: false unless forced)
|
|
162
|
+
force_snapshot = metadata[:snapshot] == true
|
|
163
|
+
should_snapshot = snapshot_config.should_snapshot?(
|
|
164
|
+
transition_count: transitions_since,
|
|
165
|
+
seconds_since_last: seconds_since,
|
|
166
|
+
to_state: to_state,
|
|
167
|
+
to_state_terminal: to_state_def&.terminal? || false,
|
|
168
|
+
force: force_snapshot
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if should_snapshot
|
|
172
|
+
# Build snapshot: schema attributes + arbitrary observations
|
|
173
|
+
schema_snapshot = snapshot_config.build_snapshot(self)
|
|
174
|
+
|
|
175
|
+
# Merge arbitrary observations if provided (stored under _observations key)
|
|
176
|
+
if snapshot_data.present?
|
|
177
|
+
schema_snapshot["_observations"] = snapshot_data.as_json
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
log_data["state_snapshot"] = schema_snapshot
|
|
181
|
+
log_data["snapshot_reason"] = determine_snapshot_reason(
|
|
182
|
+
snapshot_config.strategy, force_snapshot, to_state_def
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ==========================================================================
|
|
188
|
+
# RACE CONDITION FIX: SELECT FOR UPDATE
|
|
189
|
+
# ==========================================================================
|
|
190
|
+
# Acquire a row-level lock before proceeding. This prevents concurrent
|
|
191
|
+
# transactions from reading stale state and attempting concurrent transitions.
|
|
192
|
+
#
|
|
193
|
+
# Behavior:
|
|
194
|
+
# - PostgreSQL/MySQL: SELECT ... FOR UPDATE blocks until lock acquired
|
|
195
|
+
# - SQLite: No-op (database-level locking makes it already serializable)
|
|
196
|
+
#
|
|
197
|
+
# We re-read state after locking to ensure we have the latest value.
|
|
198
|
+
# If another transaction committed while we waited for the lock, we get
|
|
199
|
+
# the fresh state and must re-validate our checks.
|
|
200
|
+
# ==========================================================================
|
|
201
|
+
|
|
202
|
+
# Acquire lock - this blocks if another transaction holds the lock
|
|
203
|
+
locked_record = self.class.lock.find(self.id)
|
|
204
|
+
|
|
205
|
+
# Re-validate with locked state - the world may have changed while waiting
|
|
206
|
+
locked_current = locked_record.state.to_s
|
|
207
|
+
locked_current_state_def = lifecycle.find_state(locked_current)
|
|
208
|
+
|
|
209
|
+
# If state changed while waiting for lock, transition may no longer be valid
|
|
210
|
+
if locked_current_state_def&.terminal?
|
|
211
|
+
raise Fosm::TerminalState.new(locked_current, self.class)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
unless event_def.valid_from?(locked_current)
|
|
215
|
+
raise Fosm::InvalidTransition.new(event_name, locked_current, self.class)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Re-check guards with locked record (guards may depend on fresh state)
|
|
219
|
+
event_def.guards.each do |guard_def|
|
|
220
|
+
allowed, reason = guard_def.evaluate(locked_record)
|
|
221
|
+
unless allowed
|
|
222
|
+
raise Fosm::GuardFailed.new(guard_def.name, event_name, reason)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Re-check RBAC with locked record (for consistency, though RBAC uses cache)
|
|
227
|
+
if lifecycle.access_defined?
|
|
228
|
+
unless fosm_rbac_bypass?(actor)
|
|
229
|
+
unless fosm_actor_has_event_permission_for_record?(event_name, actor, locked_record)
|
|
230
|
+
raise Fosm::AccessDenied.new(event_name, actor)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Update from_state to reflect the locked state's current value
|
|
236
|
+
from_state = locked_current
|
|
237
|
+
log_data["from_state"] = from_state
|
|
238
|
+
|
|
118
239
|
ActiveRecord::Base.transaction do
|
|
119
|
-
update
|
|
240
|
+
# Use the locked record for the update to ensure we hold the lock
|
|
241
|
+
locked_record.update!(state: to_state)
|
|
242
|
+
|
|
243
|
+
# Sync our instance state with what was written
|
|
244
|
+
self.state = to_state
|
|
120
245
|
|
|
121
246
|
# :sync strategy — INSERT inside transaction for strict consistency
|
|
122
247
|
if Fosm.config.transition_log_strategy == :sync
|
|
123
248
|
Fosm::TransitionLog.create!(log_data)
|
|
124
249
|
end
|
|
125
250
|
|
|
126
|
-
# Run side effects inside transaction so they roll back on error
|
|
127
|
-
|
|
128
|
-
|
|
251
|
+
# Run immediate side effects inside transaction so they roll back on error
|
|
252
|
+
# Set context for auto-capturing triggered_by in nested transitions
|
|
253
|
+
begin
|
|
254
|
+
Thread.current[:fosm_trigger_context] = {
|
|
255
|
+
record_type: self.class.name,
|
|
256
|
+
record_id: self.id.to_s,
|
|
257
|
+
event_name: event_name.to_s
|
|
258
|
+
}
|
|
259
|
+
# Call side effects on self (not locked_record) so instance state is preserved
|
|
260
|
+
event_def.side_effects.reject(&:deferred?).each do |side_effect_def|
|
|
261
|
+
side_effect_def.call(self, transition_data)
|
|
262
|
+
end
|
|
263
|
+
ensure
|
|
264
|
+
Thread.current[:fosm_trigger_context] = nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# 🆕 Queue deferred side effects to run after commit
|
|
268
|
+
deferred_effects = event_def.side_effects.select(&:deferred?)
|
|
269
|
+
if deferred_effects.any?
|
|
270
|
+
# Set instance variables on locked_record (the instance that gets saved)
|
|
271
|
+
# so after_commit callback can access them
|
|
272
|
+
locked_record.instance_variable_set(:@_fosm_deferred_side_effects, deferred_effects)
|
|
273
|
+
locked_record.instance_variable_set(:@_fosm_transition_data, transition_data)
|
|
274
|
+
# Use after_commit to run after transaction completes
|
|
275
|
+
locked_record.class.after_commit :_fosm_run_deferred_side_effects, on: :update
|
|
129
276
|
end
|
|
130
277
|
end
|
|
131
278
|
|
|
@@ -160,10 +307,12 @@ module Fosm
|
|
|
160
307
|
|
|
161
308
|
event_def = lifecycle.find_event(event_name)
|
|
162
309
|
return false unless event_def
|
|
310
|
+
# Terminal states block transitions
|
|
163
311
|
return false if lifecycle.find_state(self.state)&.terminal?
|
|
164
312
|
return false unless event_def.valid_from?(self.state)
|
|
165
313
|
|
|
166
|
-
|
|
314
|
+
# Use evaluate to properly check guards (handles rich return values)
|
|
315
|
+
event_def.guards.all? { |guard_def| guard_def.evaluate(self).first }
|
|
167
316
|
end
|
|
168
317
|
|
|
169
318
|
# Returns true if the actor has a role permitting this event AND the transition is valid.
|
|
@@ -180,15 +329,163 @@ module Fosm
|
|
|
180
329
|
return [] unless lifecycle
|
|
181
330
|
|
|
182
331
|
lifecycle.available_events_from(self.state).select { |event_def|
|
|
183
|
-
|
|
332
|
+
# 🆕 Use evaluate to properly check guards
|
|
333
|
+
event_def.guards.all? { |g| g.evaluate(self).first }
|
|
184
334
|
}.map(&:name)
|
|
185
335
|
end
|
|
186
336
|
|
|
337
|
+
# 🆕 Detailed introspection: why can this event (not) be fired?
|
|
338
|
+
# Returns a hash with diagnostic information for debugging and UI messages.
|
|
339
|
+
def why_cannot_fire?(event_name)
|
|
340
|
+
lifecycle = self.class.fosm_lifecycle
|
|
341
|
+
return { can_fire: false, reason: "No lifecycle defined" } unless lifecycle
|
|
342
|
+
|
|
343
|
+
event_def = lifecycle.find_event(event_name)
|
|
344
|
+
return { can_fire: false, reason: "Unknown event '#{event_name}'" } unless event_def
|
|
345
|
+
|
|
346
|
+
current = self.state.to_s
|
|
347
|
+
current_state_def = lifecycle.find_state(current)
|
|
348
|
+
result = {
|
|
349
|
+
can_fire: true,
|
|
350
|
+
event: event_name.to_s,
|
|
351
|
+
current_state: current
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
# Check terminal state
|
|
355
|
+
if current_state_def&.terminal?
|
|
356
|
+
result[:can_fire] = false
|
|
357
|
+
result[:reason] = "State '#{current}' is terminal and cannot transition further"
|
|
358
|
+
result[:is_terminal] = true
|
|
359
|
+
return result
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Check valid from state
|
|
363
|
+
unless event_def.valid_from?(current)
|
|
364
|
+
result[:can_fire] = false
|
|
365
|
+
result[:reason] = "Cannot fire '#{event_name}' from '#{current}' (valid from: #{event_def.from_states.join(', ')})"
|
|
366
|
+
result[:valid_from_states] = event_def.from_states
|
|
367
|
+
return result
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Evaluate guards
|
|
371
|
+
failed_guards = []
|
|
372
|
+
passed_guards = []
|
|
373
|
+
|
|
374
|
+
event_def.guards.each do |guard_def|
|
|
375
|
+
allowed, reason = guard_def.evaluate(self)
|
|
376
|
+
if allowed
|
|
377
|
+
passed_guards << guard_def.name
|
|
378
|
+
else
|
|
379
|
+
failed_guards << { name: guard_def.name, reason: reason }
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
if failed_guards.any?
|
|
384
|
+
result[:can_fire] = false
|
|
385
|
+
result[:failed_guards] = failed_guards
|
|
386
|
+
result[:passed_guards] = passed_guards
|
|
387
|
+
first_failure = failed_guards.first
|
|
388
|
+
result[:reason] = "Guard '#{first_failure[:name]}' failed"
|
|
389
|
+
result[:reason] += ": #{first_failure[:reason]}" if first_failure[:reason]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
result
|
|
393
|
+
end
|
|
394
|
+
|
|
187
395
|
# Returns the current state as a symbol
|
|
188
396
|
def current_state
|
|
189
397
|
self.state.to_sym
|
|
190
398
|
end
|
|
191
399
|
|
|
400
|
+
# ==========================================================================
|
|
401
|
+
# SNAPSHOT REPLAY AND TIME-TRAVEL METHODS
|
|
402
|
+
# ==========================================================================
|
|
403
|
+
|
|
404
|
+
# Returns the most recent snapshot for this record, or nil if none exists.
|
|
405
|
+
# @return [Fosm::TransitionLog, nil] the transition log entry with snapshot
|
|
406
|
+
def last_snapshot
|
|
407
|
+
Fosm::TransitionLog
|
|
408
|
+
.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
409
|
+
.where.not(state_snapshot: nil)
|
|
410
|
+
.order(created_at: :desc)
|
|
411
|
+
.first
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Returns the snapshot data from the most recent snapshot, or nil.
|
|
415
|
+
# @return [Hash, nil] the snapshot data
|
|
416
|
+
def last_snapshot_data
|
|
417
|
+
last_snapshot&.state_snapshot
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Returns the state of the record at a specific transition log ID.
|
|
421
|
+
# This is a "time-travel" query that reconstructs state from snapshot + replay.
|
|
422
|
+
#
|
|
423
|
+
# @param transition_log_id [Integer] the ID of the transition log entry
|
|
424
|
+
# @return [Hash] the reconstructed state at that point in time
|
|
425
|
+
def state_at_transition(transition_log_id)
|
|
426
|
+
log = Fosm::TransitionLog.find_by(id: transition_log_id)
|
|
427
|
+
return nil unless log
|
|
428
|
+
return nil unless log.record_type == self.class.name && log.record_id == self.id.to_s
|
|
429
|
+
|
|
430
|
+
# If this log entry has a snapshot, use it directly
|
|
431
|
+
return log.state_snapshot if log.state_snapshot.present?
|
|
432
|
+
|
|
433
|
+
# Otherwise, find the most recent snapshot before this log entry
|
|
434
|
+
prior_snapshot = Fosm::TransitionLog
|
|
435
|
+
.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
436
|
+
.where.not(state_snapshot: nil)
|
|
437
|
+
.where("created_at <= ?", log.created_at)
|
|
438
|
+
.order(created_at: :desc)
|
|
439
|
+
.first
|
|
440
|
+
|
|
441
|
+
# Return the prior snapshot data, or nil if no snapshot exists
|
|
442
|
+
prior_snapshot&.state_snapshot
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Replays events from a specific snapshot forward to the current state.
|
|
446
|
+
# Yields each transition to a block for custom processing.
|
|
447
|
+
#
|
|
448
|
+
# @param from_snapshot [Fosm::TransitionLog, Integer] snapshot log entry or ID
|
|
449
|
+
# @yield [transition_log] optional block to process each transition
|
|
450
|
+
# @return [Array<Fosm::TransitionLog>] the transitions replayed
|
|
451
|
+
def replay_from(from_snapshot)
|
|
452
|
+
snapshot_id = from_snapshot.is_a?(Fosm::TransitionLog) ? from_snapshot.id : from_snapshot
|
|
453
|
+
|
|
454
|
+
transitions = Fosm::TransitionLog
|
|
455
|
+
.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
456
|
+
.where("id > ?", snapshot_id)
|
|
457
|
+
.order(:created_at)
|
|
458
|
+
|
|
459
|
+
if block_given?
|
|
460
|
+
transitions.each { |log| yield log }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
transitions.to_a
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Returns all snapshots for this record in chronological order.
|
|
467
|
+
# Useful for audit trails and debugging.
|
|
468
|
+
# @return [ActiveRecord::Relation] snapshot transition logs
|
|
469
|
+
def snapshots
|
|
470
|
+
Fosm::TransitionLog
|
|
471
|
+
.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
472
|
+
.where.not(state_snapshot: nil)
|
|
473
|
+
.order(:created_at)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Returns the number of transitions since the last snapshot.
|
|
477
|
+
# Useful for monitoring snapshot coverage.
|
|
478
|
+
# @return [Integer] transitions since last snapshot
|
|
479
|
+
def transitions_since_snapshot
|
|
480
|
+
last_snap = last_snapshot
|
|
481
|
+
return Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s).count unless last_snap
|
|
482
|
+
|
|
483
|
+
Fosm::TransitionLog
|
|
484
|
+
.where(record_type: self.class.name, record_id: self.id.to_s)
|
|
485
|
+
.where("created_at > ?", last_snap.created_at)
|
|
486
|
+
.count
|
|
487
|
+
end
|
|
488
|
+
|
|
192
489
|
private
|
|
193
490
|
|
|
194
491
|
def fosm_set_initial_state
|
|
@@ -259,6 +556,15 @@ module Fosm
|
|
|
259
556
|
(actor_roles & permitted_roles).any?
|
|
260
557
|
end
|
|
261
558
|
|
|
559
|
+
# Variant for checking permissions with a locked record (after SELECT FOR UPDATE)
|
|
560
|
+
def fosm_actor_has_event_permission_for_record?(event_name, actor, record)
|
|
561
|
+
return true if fosm_rbac_bypass?(actor)
|
|
562
|
+
|
|
563
|
+
permitted_roles = record.class.fosm_lifecycle.access_definition.roles_for_event(event_name)
|
|
564
|
+
actor_roles = fosm_roles_for_actor(actor)
|
|
565
|
+
(actor_roles & permitted_roles).any?
|
|
566
|
+
end
|
|
567
|
+
|
|
262
568
|
# Returns the actor's roles for this specific record (type-level + record-level combined)
|
|
263
569
|
def fosm_roles_for_actor(actor)
|
|
264
570
|
return [] unless actor.respond_to?(:id) && actor.respond_to?(:class)
|
|
@@ -292,5 +598,49 @@ module Fosm
|
|
|
292
598
|
return nil unless actor
|
|
293
599
|
actor.respond_to?(:email) ? actor.email : actor.to_s
|
|
294
600
|
end
|
|
601
|
+
|
|
602
|
+
# Run deferred side effects after transaction commits
|
|
603
|
+
# This prevents SQLite locking when cross-machine triggers occur
|
|
604
|
+
def _fosm_run_deferred_side_effects
|
|
605
|
+
return unless defined?(@_fosm_deferred_side_effects) && @_fosm_deferred_side_effects
|
|
606
|
+
|
|
607
|
+
transition_data = @_fosm_transition_data
|
|
608
|
+
|
|
609
|
+
begin
|
|
610
|
+
# Set context for auto-capturing triggered_by in nested transitions
|
|
611
|
+
Thread.current[:fosm_trigger_context] = {
|
|
612
|
+
record_type: self.class.name,
|
|
613
|
+
record_id: self.id.to_s,
|
|
614
|
+
event_name: transition_data[:event]
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
@_fosm_deferred_side_effects.each do |side_effect_def|
|
|
618
|
+
side_effect_def.call(self, transition_data)
|
|
619
|
+
end
|
|
620
|
+
rescue => e
|
|
621
|
+
# Log error but don't fail - transaction is already committed
|
|
622
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
623
|
+
logger ||= Logger.new(STDOUT)
|
|
624
|
+
logger.error("[Fosm] Deferred side effect failed: #{e.message}")
|
|
625
|
+
ensure
|
|
626
|
+
Thread.current[:fosm_trigger_context] = nil
|
|
627
|
+
# Clean up instance variables
|
|
628
|
+
@_fosm_deferred_side_effects = nil
|
|
629
|
+
@_fosm_transition_data = nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Remove the after_commit callback to avoid running on subsequent updates
|
|
633
|
+
self.class.skip_callback(:commit, :after, :_fosm_run_deferred_side_effects, on: :update, raise: false)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# Determine the reason string for a snapshot based on strategy and context
|
|
637
|
+
def determine_snapshot_reason(strategy, forced, to_state_def)
|
|
638
|
+
return "manual" if forced
|
|
639
|
+
return "every" if strategy == :every
|
|
640
|
+
return "terminal" if strategy == :terminal && to_state_def&.terminal?
|
|
641
|
+
return "count_interval" if strategy == :count
|
|
642
|
+
return "time_interval" if strategy == :time
|
|
643
|
+
"auto"
|
|
644
|
+
end
|
|
295
645
|
end
|
|
296
646
|
end
|
data/lib/fosm/registry.rb
CHANGED
|
@@ -6,6 +6,11 @@ module Fosm
|
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
8
|
def register(model_class, slug:)
|
|
9
|
+
unless slug.match?(/\A[a-z0-9_]+\z/)
|
|
10
|
+
raise ArgumentError, "FOSM slug must contain only lowercase letters, digits, and underscores " \
|
|
11
|
+
"(got: #{slug.inspect} for #{model_class.name}). " \
|
|
12
|
+
"Hyphens are not allowed because slugs are used to construct Ruby route helper names."
|
|
13
|
+
end
|
|
9
14
|
@registered[slug] = model_class
|
|
10
15
|
end
|
|
11
16
|
|
|
@@ -24,6 +29,10 @@ module Fosm
|
|
|
24
29
|
def slugs
|
|
25
30
|
@registered.keys
|
|
26
31
|
end
|
|
32
|
+
|
|
33
|
+
def each(&block)
|
|
34
|
+
@registered.each(&block)
|
|
35
|
+
end
|
|
27
36
|
end
|
|
28
37
|
end
|
|
29
38
|
end
|
data/lib/fosm/version.rb
CHANGED
|
@@ -41,6 +41,38 @@ module Fosm
|
|
|
41
41
|
migration_template "migration.rb.tt", "db/migrate/create_fosm_#{table_name}.rb"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def inject_agent_instructions
|
|
45
|
+
claude_md = ::Rails.root.join("CLAUDE.md")
|
|
46
|
+
sentinel = "<!-- fosm:agent-instructions -->"
|
|
47
|
+
|
|
48
|
+
if File.exist?(claude_md) && File.read(claude_md).include?(sentinel)
|
|
49
|
+
say_status :skip, "CLAUDE.md already has FOSM agent instructions", :yellow
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
gem_root = Fosm::Engine.root.to_s
|
|
54
|
+
agents_md_path = File.join(gem_root, "AGENTS.md")
|
|
55
|
+
|
|
56
|
+
section = <<~MARKDOWN
|
|
57
|
+
|
|
58
|
+
#{sentinel}
|
|
59
|
+
## FOSM (fosm-rails)
|
|
60
|
+
|
|
61
|
+
This project uses `fosm-rails` — a Finite Object State Machine engine.
|
|
62
|
+
When the user asks to build, extend, or modify features involving state machines,
|
|
63
|
+
lifecycles, FOSM, or any code under `app/models/fosm/`, `app/controllers/fosm/`,
|
|
64
|
+
or `app/agents/fosm/`, you **must** read and follow the instructions in:
|
|
65
|
+
|
|
66
|
+
`#{agents_md_path}`
|
|
67
|
+
MARKDOWN
|
|
68
|
+
|
|
69
|
+
if File.exist?(claude_md)
|
|
70
|
+
append_to_file claude_md, section
|
|
71
|
+
else
|
|
72
|
+
create_file claude_md, section.lstrip
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
44
76
|
def update_routes
|
|
45
77
|
routes_file = ::Rails.root.join("config/routes/fosm.rb")
|
|
46
78
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
|
|
5
|
+
namespace :fosm do
|
|
6
|
+
namespace :graph do
|
|
7
|
+
desc "Generate state machine graph for a FOSM model (e.g., rake fosm:graph:generate MODEL=Invoice)"
|
|
8
|
+
task generate: :environment do
|
|
9
|
+
model_name = ENV["MODEL"]
|
|
10
|
+
raise "Usage: rake fosm:graph:generate MODEL=Invoice" unless model_name
|
|
11
|
+
|
|
12
|
+
model_class = "Fosm::#{model_name}".constantize rescue model_name.constantize
|
|
13
|
+
raise "#{model_name} does not include Fosm::Lifecycle" unless model_class.respond_to?(:fosm_lifecycle)
|
|
14
|
+
|
|
15
|
+
lifecycle = model_class.fosm_lifecycle
|
|
16
|
+
output_dir = ENV["OUTPUT"] || Rails.root.join("app", "assets", "graphs")
|
|
17
|
+
FileUtils.mkdir_p(output_dir)
|
|
18
|
+
|
|
19
|
+
# Generate machine-level graph
|
|
20
|
+
machine_data = {
|
|
21
|
+
machine: model_class.name,
|
|
22
|
+
states: lifecycle.states.map { |s|
|
|
23
|
+
{
|
|
24
|
+
name: s.name,
|
|
25
|
+
initial: s.initial?,
|
|
26
|
+
terminal: s.terminal?
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
events: lifecycle.events.map { |e|
|
|
30
|
+
{
|
|
31
|
+
name: e.name,
|
|
32
|
+
from: e.from_states,
|
|
33
|
+
to: e.to_state,
|
|
34
|
+
guards: e.guards.map(&:name),
|
|
35
|
+
side_effects: e.side_effects.map(&:name)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Detect cross-machine connections by analyzing side effect names
|
|
41
|
+
cross_connections = []
|
|
42
|
+
lifecycle.events.each do |event|
|
|
43
|
+
event.side_effects.each do |side_effect|
|
|
44
|
+
name = side_effect.name.to_s
|
|
45
|
+
# Convention: trigger_other_machine or activate_contract patterns
|
|
46
|
+
if name.include?("_")
|
|
47
|
+
parts = name.split("_")
|
|
48
|
+
potential_targets = parts.select { |p|
|
|
49
|
+
# Look for capitalized words that match model names
|
|
50
|
+
p.capitalize == p && Object.const_defined?("Fosm::#{p.capitalize}") rescue false
|
|
51
|
+
}
|
|
52
|
+
potential_targets.each do |target|
|
|
53
|
+
cross_connections << {
|
|
54
|
+
source: { machine: model_class.name, event: event.name },
|
|
55
|
+
via: side_effect.name,
|
|
56
|
+
target_machine: "Fosm::#{target.capitalize}"
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
machine_data[:cross_machine_connections] = cross_connections
|
|
64
|
+
|
|
65
|
+
# Write machine graph
|
|
66
|
+
machine_file = File.join(output_dir, "#{model_name.underscore}_graph.json")
|
|
67
|
+
File.write(machine_file, JSON.pretty_generate(machine_data))
|
|
68
|
+
puts "Generated: #{machine_file}"
|
|
69
|
+
|
|
70
|
+
# Generate system-wide graph if requested
|
|
71
|
+
if ENV["SYSTEM"]
|
|
72
|
+
system_data = Fosm::Graph.system_graph
|
|
73
|
+
system_file = File.join(output_dir, "fosm_system_graph.json")
|
|
74
|
+
File.write(system_file, JSON.pretty_generate(system_data))
|
|
75
|
+
puts "Generated: #{system_file}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
desc "Generate graphs for all FOSM models"
|
|
80
|
+
task all: :environment do
|
|
81
|
+
Fosm::Registry.each do |slug, model_class|
|
|
82
|
+
ENV["MODEL"] = model_class.name.demodulize
|
|
83
|
+
Rake::Task["fosm:graph:generate"].invoke
|
|
84
|
+
Rake::Task["fosm:graph:generate"].reenable
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module Fosm
|
|
91
|
+
class Graph
|
|
92
|
+
# Generate system-wide dependency graph
|
|
93
|
+
def self.system_graph
|
|
94
|
+
machines = {}
|
|
95
|
+
connections = []
|
|
96
|
+
|
|
97
|
+
Fosm::Registry.each do |slug, model_class|
|
|
98
|
+
next unless model_class.respond_to?(:fosm_lifecycle)
|
|
99
|
+
lifecycle = model_class.fosm_lifecycle
|
|
100
|
+
|
|
101
|
+
machines[model_class.name] = {
|
|
102
|
+
states: lifecycle.states.count,
|
|
103
|
+
events: lifecycle.events.count,
|
|
104
|
+
terminal_states: lifecycle.states.select(&:terminal?).map(&:name)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lifecycle.events.each do |event|
|
|
108
|
+
event.side_effects.each do |side_effect|
|
|
109
|
+
connections << {
|
|
110
|
+
from: model_class.name,
|
|
111
|
+
to: infer_target_from_side_effect(side_effect),
|
|
112
|
+
via: side_effect.name,
|
|
113
|
+
event: event.name
|
|
114
|
+
} if infer_target_from_side_effect(side_effect)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
machines: machines,
|
|
121
|
+
connections: connections.compact,
|
|
122
|
+
generated_at: Time.current.iso8601
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.infer_target_from_side_effect(side_effect)
|
|
127
|
+
name = side_effect.name.to_s
|
|
128
|
+
# Common patterns: activate_contract, notify_user, create_payment
|
|
129
|
+
targets = %w[Contract Invoice User Payment Order Shipment].select do |model|
|
|
130
|
+
name.downcase.include?(model.downcase)
|
|
131
|
+
end
|
|
132
|
+
targets.first ? "Fosm::#{targets.first}" : nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fosm-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abhishek Parolkar
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-03-24 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: rails
|
|
@@ -91,6 +92,7 @@ files:
|
|
|
91
92
|
- db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb
|
|
92
93
|
- db/migrate/20240101000003_create_fosm_role_assignments.rb
|
|
93
94
|
- db/migrate/20240101000004_create_fosm_access_events.rb
|
|
95
|
+
- db/migrate/20240101000005_add_state_snapshot_to_fosm_transition_logs.rb
|
|
94
96
|
- lib/fosm-rails.rb
|
|
95
97
|
- lib/fosm/agent.rb
|
|
96
98
|
- lib/fosm/configuration.rb
|
|
@@ -104,6 +106,7 @@ files:
|
|
|
104
106
|
- lib/fosm/lifecycle/guard_definition.rb
|
|
105
107
|
- lib/fosm/lifecycle/role_definition.rb
|
|
106
108
|
- lib/fosm/lifecycle/side_effect_definition.rb
|
|
109
|
+
- lib/fosm/lifecycle/snapshot_configuration.rb
|
|
107
110
|
- lib/fosm/lifecycle/state_definition.rb
|
|
108
111
|
- lib/fosm/rails.rb
|
|
109
112
|
- lib/fosm/rails/engine.rb
|
|
@@ -121,6 +124,7 @@ files:
|
|
|
121
124
|
- lib/generators/fosm/app/templates/views/new.html.erb.tt
|
|
122
125
|
- lib/generators/fosm/app/templates/views/show.html.erb.tt
|
|
123
126
|
- lib/tasks/fosm/rails_tasks.rake
|
|
127
|
+
- lib/tasks/fosm_graph.rake
|
|
124
128
|
homepage: https://github.com/inloopstudio/fosm-rails
|
|
125
129
|
licenses:
|
|
126
130
|
- FSL-1.1-Apache-2.0
|
|
@@ -128,6 +132,7 @@ metadata:
|
|
|
128
132
|
homepage_uri: https://github.com/inloopstudio/fosm-rails
|
|
129
133
|
source_code_uri: https://github.com/inloopstudio/fosm-rails
|
|
130
134
|
changelog_uri: https://github.com/inloopstudio/fosm-rails/blob/main/CHANGELOG.md
|
|
135
|
+
post_install_message:
|
|
131
136
|
rdoc_options: []
|
|
132
137
|
require_paths:
|
|
133
138
|
- lib
|
|
@@ -142,7 +147,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
142
147
|
- !ruby/object:Gem::Version
|
|
143
148
|
version: '0'
|
|
144
149
|
requirements: []
|
|
145
|
-
rubygems_version: 3.
|
|
150
|
+
rubygems_version: 3.5.22
|
|
151
|
+
signing_key:
|
|
146
152
|
specification_version: 4
|
|
147
153
|
summary: Finite Object State Machine for Rails — declarative lifecycles for business
|
|
148
154
|
objects
|