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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 632476c001f595b1c39b2eb3a98af17ff6aeca2c54505a373c85bc442d3f07fd
4
- data.tar.gz: 1d1401c44b00161288485afa4bb7228cfd88e216b1254d12233345334d437eeb
3
+ metadata.gz: f7d819f282973824d9dcb7c7d502193f59f3394602ff7f334eddb160eb401a10
4
+ data.tar.gz: 1b4ced5b691e16ffde902dd1686e8d5c05723e96eaab610a7f0f01950703e925
5
5
  SHA512:
6
- metadata.gz: 343cff86bca38d2098bee6b13a577c4d9b2ec1f14dd186d12f1ea239817768a737e86542cd74eb8f6266bcc4388faf615351ed6d8677420863d7f6ca1aa3560d
7
- data.tar.gz: 5f811cda70c5a7321af1599ce639da25b746f70a81e7dbbecb108acd4773259cb2b00450979ab6268a4f41436c3b6a4e8c767d393972cc025701d3a00d940e7e
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
- <%= link_to "View all #{@model_class.name.demodulize.humanize.pluralize}",
16
- main_app.send("fosm_#{@slug.pluralize}_path"),
17
- class: "text-sm border border-gray-200 bg-white text-gray-700 px-3 py-1.5 rounded hover:bg-gray-50" %>
18
- <%= link_to "+ New #{@model_class.name.demodulize.humanize}",
19
- main_app.send("new_fosm_#{@slug}_path"),
20
- class: "text-sm bg-gray-900 text-white px-3 py-1.5 rounded hover:bg-gray-700" %>
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
- <%= link_to "##{t.record_id}",
190
- main_app.send("fosm_#{@slug}_path", t.record_id),
191
- class: "text-blue-600 hover:underline" %>
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 :settings, only: [:show]
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.dasherize
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
- def initialize(guard_name, event_name)
21
- super("Guard '#{guard_name}' prevented transition for event '#{event_name}'")
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
- def side_effect(name, on:, &block)
93
- side_effect_def = SideEffectDefinition.new(name: name, &block)
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
@@ -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. BEGIN TRANSACTION: UPDATE state, run side effects
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
- # 7. Emit transition log (:async or :buffered, non-blocking)
60
- # 8. Enqueue webhook delivery (always async)
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
- unless guard_def.call(self)
93
- raise Fosm::GuardFailed.new(guard_def.name, event_name)
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!(state: to_state)
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
- event_def.side_effects.each do |side_effect_def|
128
- side_effect_def.call(self, transition_data)
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
- event_def.guards.all? { |guard_def| guard_def.call(self) }
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
- event_def.guards.all? { |g| g.call(self) }
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
@@ -1,3 +1,3 @@
1
1
  module Fosm
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
@@ -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.0
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: 1980-01-02 00:00:00.000000000 Z
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.6.7
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