fosm-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +384 -0
- data/LICENSE +115 -0
- data/README.md +322 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/fosm/rails/application.css +15 -0
- data/app/controllers/fosm/admin/agents_controller.rb +242 -0
- data/app/controllers/fosm/admin/apps_controller.rb +34 -0
- data/app/controllers/fosm/admin/base_controller.rb +15 -0
- data/app/controllers/fosm/admin/dashboard_controller.rb +25 -0
- data/app/controllers/fosm/admin/settings_controller.rb +44 -0
- data/app/controllers/fosm/admin/transitions_controller.rb +22 -0
- data/app/controllers/fosm/admin/webhooks_controller.rb +37 -0
- data/app/controllers/fosm/application_controller.rb +23 -0
- data/app/controllers/fosm/rails/application_controller.rb +6 -0
- data/app/helpers/fosm/application_helper.rb +19 -0
- data/app/helpers/fosm/rails/application_helper.rb +11 -0
- data/app/jobs/fosm/application_job.rb +6 -0
- data/app/jobs/fosm/rails/application_job.rb +6 -0
- data/app/jobs/fosm/webhook_delivery_job.rb +52 -0
- data/app/models/fosm/application_record.rb +6 -0
- data/app/models/fosm/rails/application_record.rb +7 -0
- data/app/models/fosm/transition_log.rb +27 -0
- data/app/models/fosm/webhook_subscription.rb +15 -0
- data/app/views/fosm/admin/agents/chat.html.erb +242 -0
- data/app/views/fosm/admin/agents/show.html.erb +166 -0
- data/app/views/fosm/admin/apps/show.html.erb +114 -0
- data/app/views/fosm/admin/dashboard/index.html.erb +82 -0
- data/app/views/fosm/admin/settings/show.html.erb +63 -0
- data/app/views/fosm/admin/transitions/index.html.erb +65 -0
- data/app/views/fosm/admin/webhooks/index.html.erb +51 -0
- data/app/views/fosm/admin/webhooks/new.html.erb +45 -0
- data/app/views/layouts/fosm/application.html.erb +41 -0
- data/app/views/layouts/fosm/rails/application.html.erb +17 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20240101000001_create_fosm_transition_logs.rb +23 -0
- data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +16 -0
- data/lib/fosm/agent.rb +232 -0
- data/lib/fosm/configuration.rb +50 -0
- data/lib/fosm/engine.rb +133 -0
- data/lib/fosm/errors.rb +31 -0
- data/lib/fosm/lifecycle/definition.rb +103 -0
- data/lib/fosm/lifecycle/event_definition.rb +27 -0
- data/lib/fosm/lifecycle/guard_definition.rb +16 -0
- data/lib/fosm/lifecycle/side_effect_definition.rb +16 -0
- data/lib/fosm/lifecycle/state_definition.rb +18 -0
- data/lib/fosm/lifecycle.rb +173 -0
- data/lib/fosm/rails/engine.rb +9 -0
- data/lib/fosm/rails/version.rb +9 -0
- data/lib/fosm/rails.rb +9 -0
- data/lib/fosm/registry.rb +29 -0
- data/lib/fosm/version.rb +3 -0
- data/lib/fosm-rails.rb +40 -0
- data/lib/generators/fosm/app/app_generator.rb +106 -0
- data/lib/generators/fosm/app/templates/agent.rb.tt +26 -0
- data/lib/generators/fosm/app/templates/controller.rb.tt +56 -0
- data/lib/generators/fosm/app/templates/migration.rb.tt +14 -0
- data/lib/generators/fosm/app/templates/model.rb.tt +31 -0
- data/lib/generators/fosm/app/templates/views/_form.html.erb.tt +24 -0
- data/lib/generators/fosm/app/templates/views/index.html.erb.tt +37 -0
- data/lib/generators/fosm/app/templates/views/new.html.erb.tt +4 -0
- data/lib/generators/fosm/app/templates/views/show.html.erb.tt +57 -0
- data/lib/tasks/fosm/rails_tasks.rake +4 -0
- metadata +139 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Lifecycle
|
|
3
|
+
class StateDefinition
|
|
4
|
+
attr_reader :name
|
|
5
|
+
|
|
6
|
+
def initialize(name:, initial: false, terminal: false)
|
|
7
|
+
@name = name.to_sym
|
|
8
|
+
@initial = initial
|
|
9
|
+
@terminal = terminal
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initial? = @initial
|
|
13
|
+
def terminal? = @terminal
|
|
14
|
+
|
|
15
|
+
def to_s = @name.to_s
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
require_relative "lifecycle/definition"
|
|
2
|
+
|
|
3
|
+
module Fosm
|
|
4
|
+
module Lifecycle
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# The lifecycle definition is stored as a class attribute
|
|
9
|
+
class_attribute :fosm_lifecycle, instance_accessor: false
|
|
10
|
+
|
|
11
|
+
# Validations
|
|
12
|
+
before_validation :fosm_set_initial_state, on: :create
|
|
13
|
+
validates :state, inclusion: { in: ->(record) { record.class.fosm_lifecycle&.state_names || [] } },
|
|
14
|
+
allow_blank: false,
|
|
15
|
+
if: -> { self.class.fosm_lifecycle.present? }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
# The lifecycle DSL entry point
|
|
20
|
+
def lifecycle(&block)
|
|
21
|
+
self.fosm_lifecycle = Fosm::Lifecycle::Definition.new
|
|
22
|
+
self.fosm_lifecycle.instance_eval(&block)
|
|
23
|
+
|
|
24
|
+
# Generate state predicate methods: invoice.draft? => true
|
|
25
|
+
fosm_lifecycle.states.each do |state_def|
|
|
26
|
+
define_method(:"#{state_def.name}?") do
|
|
27
|
+
self.state.to_s == state_def.name.to_s
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Generate dynamic bang methods per event: invoice.send_invoice!(actor: user)
|
|
32
|
+
fosm_lifecycle.events.each do |event_def|
|
|
33
|
+
define_method(:"#{event_def.name}!") do |actor: nil, metadata: {}|
|
|
34
|
+
fire!(event_def.name, actor: actor, metadata: metadata)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
define_method(:"can_#{event_def.name}?") do
|
|
38
|
+
can_fire?(event_def.name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fire a lifecycle event. This is the ONLY way to change state.
|
|
45
|
+
#
|
|
46
|
+
# @param event_name [Symbol, String] the event to fire
|
|
47
|
+
# @param actor [Object] who/what is firing the event (User, or symbol like :system, :agent)
|
|
48
|
+
# @param metadata [Hash] optional metadata stored in the transition log
|
|
49
|
+
# @raise [Fosm::UnknownEvent] if event doesn't exist
|
|
50
|
+
# @raise [Fosm::TerminalState] if current state is terminal
|
|
51
|
+
# @raise [Fosm::InvalidTransition] if current state doesn't allow this event
|
|
52
|
+
# @raise [Fosm::GuardFailed] if a guard blocks the transition
|
|
53
|
+
def fire!(event_name, actor: nil, metadata: {})
|
|
54
|
+
lifecycle = self.class.fosm_lifecycle
|
|
55
|
+
raise Fosm::Error, "No lifecycle defined on #{self.class.name}" unless lifecycle
|
|
56
|
+
|
|
57
|
+
event_def = lifecycle.find_event(event_name)
|
|
58
|
+
raise Fosm::UnknownEvent.new(event_name, self.class) unless event_def
|
|
59
|
+
|
|
60
|
+
current = self.state.to_s
|
|
61
|
+
current_state_def = lifecycle.find_state(current)
|
|
62
|
+
|
|
63
|
+
# Block terminal states from further transitions
|
|
64
|
+
if current_state_def&.terminal?
|
|
65
|
+
raise Fosm::TerminalState.new(current, self.class)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check the transition is valid from current state
|
|
69
|
+
unless event_def.valid_from?(current)
|
|
70
|
+
raise Fosm::InvalidTransition.new(event_name, current, self.class)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run guards
|
|
74
|
+
event_def.guards.each do |guard_def|
|
|
75
|
+
unless guard_def.call(self)
|
|
76
|
+
raise Fosm::GuardFailed.new(guard_def.name, event_name)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
from_state = current
|
|
81
|
+
to_state = event_def.to_state.to_s
|
|
82
|
+
|
|
83
|
+
transition_data = { from: from_state, to: to_state, event: event_name.to_s, actor: actor }
|
|
84
|
+
|
|
85
|
+
ActiveRecord::Base.transaction do
|
|
86
|
+
update!(state: to_state)
|
|
87
|
+
|
|
88
|
+
# Write immutable transition log
|
|
89
|
+
Fosm::TransitionLog.create!(
|
|
90
|
+
record_type: self.class.name,
|
|
91
|
+
record_id: self.id.to_s,
|
|
92
|
+
event_name: event_name.to_s,
|
|
93
|
+
from_state: from_state,
|
|
94
|
+
to_state: to_state,
|
|
95
|
+
actor_type: actor_type_for(actor),
|
|
96
|
+
actor_id: actor_id_for(actor),
|
|
97
|
+
actor_label: actor_label_for(actor),
|
|
98
|
+
metadata: metadata
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Run side effects inside transaction so they can roll back on error
|
|
102
|
+
event_def.side_effects.each do |side_effect_def|
|
|
103
|
+
side_effect_def.call(self, transition_data)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Deliver webhooks asynchronously (outside transaction)
|
|
108
|
+
Fosm::WebhookDeliveryJob.perform_later(
|
|
109
|
+
record_type: self.class.name,
|
|
110
|
+
record_id: self.id.to_s,
|
|
111
|
+
event_name: event_name.to_s,
|
|
112
|
+
from_state: from_state,
|
|
113
|
+
to_state: to_state,
|
|
114
|
+
metadata: metadata
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns true if the given event can be fired from the current state
|
|
121
|
+
def can_fire?(event_name)
|
|
122
|
+
lifecycle = self.class.fosm_lifecycle
|
|
123
|
+
return false unless lifecycle
|
|
124
|
+
|
|
125
|
+
event_def = lifecycle.find_event(event_name)
|
|
126
|
+
return false unless event_def
|
|
127
|
+
return false if lifecycle.find_state(self.state)&.terminal?
|
|
128
|
+
return false unless event_def.valid_from?(self.state)
|
|
129
|
+
|
|
130
|
+
event_def.guards.all? { |guard_def| guard_def.call(self) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns list of event names that can be fired from the current state
|
|
134
|
+
def available_events
|
|
135
|
+
lifecycle = self.class.fosm_lifecycle
|
|
136
|
+
return [] unless lifecycle
|
|
137
|
+
|
|
138
|
+
lifecycle.available_events_from(self.state).select { |event_def|
|
|
139
|
+
event_def.guards.all? { |g| g.call(self) }
|
|
140
|
+
}.map(&:name)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns the current state as a symbol
|
|
144
|
+
def current_state
|
|
145
|
+
self.state.to_sym
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def fosm_set_initial_state
|
|
151
|
+
return if self.state.present?
|
|
152
|
+
initial = self.class.fosm_lifecycle&.initial_state
|
|
153
|
+
self.state = initial.name.to_s if initial
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def actor_type_for(actor)
|
|
157
|
+
return nil if actor.nil?
|
|
158
|
+
return "symbol" if actor.is_a?(Symbol)
|
|
159
|
+
actor.class.name
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def actor_id_for(actor)
|
|
163
|
+
return nil if actor.nil? || actor.is_a?(Symbol)
|
|
164
|
+
actor.respond_to?(:id) ? actor.id.to_s : nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def actor_label_for(actor)
|
|
168
|
+
return actor.to_s if actor.is_a?(Symbol)
|
|
169
|
+
return nil unless actor
|
|
170
|
+
actor.respond_to?(:email) ? actor.email : actor.to_s
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
data/lib/fosm/rails.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Global registry of all FOSM app model classes.
|
|
3
|
+
# Models auto-register when they include Fosm::Lifecycle and call lifecycle { }.
|
|
4
|
+
module Registry
|
|
5
|
+
@registered = {}
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def register(model_class, slug:)
|
|
9
|
+
@registered[slug] = model_class
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def all
|
|
13
|
+
@registered
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(slug)
|
|
17
|
+
@registered[slug]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def model_classes
|
|
21
|
+
@registered.values
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def slugs
|
|
25
|
+
@registered.keys
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/fosm/version.rb
ADDED
data/lib/fosm-rails.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "gemlings"
|
|
2
|
+
require "fosm/version"
|
|
3
|
+
require "fosm/errors"
|
|
4
|
+
require "fosm/configuration"
|
|
5
|
+
require "fosm/registry"
|
|
6
|
+
require "fosm/lifecycle"
|
|
7
|
+
require "fosm/agent"
|
|
8
|
+
require "fosm/engine"
|
|
9
|
+
|
|
10
|
+
module Fosm
|
|
11
|
+
# FOSM — Finite Object State Machine for Rails
|
|
12
|
+
#
|
|
13
|
+
# Include Fosm::Lifecycle in any ActiveRecord model to give it a
|
|
14
|
+
# formal, enforced lifecycle with states, events, guards, and side-effects.
|
|
15
|
+
#
|
|
16
|
+
# Quick start:
|
|
17
|
+
#
|
|
18
|
+
# class Invoice < ApplicationRecord
|
|
19
|
+
# include Fosm::Lifecycle
|
|
20
|
+
#
|
|
21
|
+
# lifecycle do
|
|
22
|
+
# state :draft, initial: true
|
|
23
|
+
# state :sent
|
|
24
|
+
# state :paid, terminal: true
|
|
25
|
+
# state :cancelled, terminal: true
|
|
26
|
+
#
|
|
27
|
+
# event :send_invoice, from: :draft, to: :sent
|
|
28
|
+
# event :pay, from: :sent, to: :paid
|
|
29
|
+
# event :cancel, from: [:draft, :sent], to: :cancelled
|
|
30
|
+
#
|
|
31
|
+
# guard :has_line_items, on: :send_invoice do |invoice|
|
|
32
|
+
# invoice.amount > 0
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# side_effect :notify_client, on: :send_invoice do |invoice, transition|
|
|
36
|
+
# InvoiceMailer.send_to_client(invoice).deliver_later
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Fosm
|
|
5
|
+
module Generators
|
|
6
|
+
class AppGenerator < ::Rails::Generators::NamedBase
|
|
7
|
+
include ::Rails::Generators::Migration
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :fields, type: :array, default: [], banner: "field:type field:type",
|
|
11
|
+
desc: "List of fields for the FOSM record table"
|
|
12
|
+
class_option :states, type: :string, default: "draft,active,completed",
|
|
13
|
+
desc: "Comma-separated list of states (first is initial)"
|
|
14
|
+
class_option :access, type: :string, default: "",
|
|
15
|
+
desc: "Authorization method (e.g. authenticate_user!, require_facilitator!)"
|
|
16
|
+
|
|
17
|
+
def self.next_migration_number(dirname)
|
|
18
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_model_file
|
|
22
|
+
template "model.rb.tt", "app/models/fosm/#{file_name}.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_controller_file
|
|
26
|
+
template "controller.rb.tt", "app/controllers/fosm/#{file_name}_controller.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_agent_file
|
|
30
|
+
template "agent.rb.tt", "app/agents/fosm/#{file_name}_agent.rb"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_view_files
|
|
34
|
+
template "views/index.html.erb.tt", "app/views/fosm/#{file_name}/index.html.erb"
|
|
35
|
+
template "views/show.html.erb.tt", "app/views/fosm/#{file_name}/show.html.erb"
|
|
36
|
+
template "views/new.html.erb.tt", "app/views/fosm/#{file_name}/new.html.erb"
|
|
37
|
+
template "views/_form.html.erb.tt", "app/views/fosm/#{file_name}/_form.html.erb"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create_migration_file
|
|
41
|
+
migration_template "migration.rb.tt", "db/migrate/create_fosm_#{table_name}.rb"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def update_routes
|
|
45
|
+
routes_file = ::Rails.root.join("config/routes/fosm.rb")
|
|
46
|
+
|
|
47
|
+
route_entry = <<~RUBY
|
|
48
|
+
scope module: "fosm", path: "/fosm/apps", as: :fosm do
|
|
49
|
+
resources :#{plural_name}, controller: "#{file_name}" do
|
|
50
|
+
member { post :fire_event }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
RUBY
|
|
54
|
+
|
|
55
|
+
if File.exist?(routes_file)
|
|
56
|
+
append_to_file routes_file, "\n#{route_entry}"
|
|
57
|
+
else
|
|
58
|
+
create_file routes_file, route_entry
|
|
59
|
+
# Also ensure main routes.rb draws from fosm.rb
|
|
60
|
+
main_routes = ::Rails.root.join("config/routes.rb")
|
|
61
|
+
unless File.read(main_routes).include?("draw :fosm")
|
|
62
|
+
inject_into_file main_routes, "\n draw :fosm", after: "Rails.application.routes.draw do"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def states_list
|
|
70
|
+
options[:states].split(",").map(&:strip)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initial_state
|
|
74
|
+
states_list.first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def table_name
|
|
78
|
+
file_name.pluralize
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def plural_name
|
|
82
|
+
name.underscore.pluralize
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def access_method
|
|
86
|
+
# Strip shell-escaped ! (zsh escapes ! in double-quoted strings as \!)
|
|
87
|
+
options[:access].gsub("\\!", "!")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fields_for_migration
|
|
91
|
+
# Handle both styles:
|
|
92
|
+
# --fields name:string amount:decimal (array, multiple args)
|
|
93
|
+
# --fields "name:string amount:decimal" (single quoted string)
|
|
94
|
+
raw_fields = options[:fields].join(" ").split(/[\s,]+/).reject(&:blank?)
|
|
95
|
+
raw_fields.map do |field|
|
|
96
|
+
parts = field.split(":")
|
|
97
|
+
{ name: parts[0].strip, type: (parts[1] || "string").strip }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fields_for_permit
|
|
102
|
+
fields_for_migration.map { |f| f[:name].to_sym }.inspect
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# AI Agent for <%= class_name %> powered by Gemlings.
|
|
3
|
+
#
|
|
4
|
+
# Standard tools are auto-generated from the lifecycle definition:
|
|
5
|
+
# - list_<%= plural_name %> List records, optionally filtered by state
|
|
6
|
+
# - get_<%= file_name %> Get a specific record by ID
|
|
7
|
+
# - get_available_events_for_<%= file_name %> What events can fire from current state
|
|
8
|
+
# - get_transition_history_for_<%= file_name %> Full audit trail for a record
|
|
9
|
+
# - [event_name]_<%= file_name %> One tool per lifecycle event (bounded!)
|
|
10
|
+
#
|
|
11
|
+
# The AI agent CANNOT bypass the state machine. If a transition isn't valid
|
|
12
|
+
# from the current state, the tool returns { success: false, error: ... }.
|
|
13
|
+
#
|
|
14
|
+
# Add custom tools below using fosm_tool:
|
|
15
|
+
class <%= class_name %>Agent < Fosm::Agent
|
|
16
|
+
model_class Fosm::<%= class_name %>
|
|
17
|
+
|
|
18
|
+
# Example custom tool:
|
|
19
|
+
# fosm_tool :find_<%= plural_name %>_needing_attention,
|
|
20
|
+
# description: "Find <%= plural_name %> that need human review" do
|
|
21
|
+
# Fosm::<%= class_name %>.where(state: "<%= states_list[0] %>")
|
|
22
|
+
# .where("created_at < ?", 48.hours.ago)
|
|
23
|
+
# .map { |r| { id: r.id, state: r.state } }
|
|
24
|
+
# end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
class <%= class_name %>Controller < Fosm::ApplicationController
|
|
3
|
+
use_host_routes!
|
|
4
|
+
layout -> { Fosm.config.app_layout }
|
|
5
|
+
<% if access_method.present? %>
|
|
6
|
+
before_action :<%= access_method %>
|
|
7
|
+
<% end %>
|
|
8
|
+
before_action :set_record, only: [:show, :destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@records = Fosm::<%= class_name %>.order(created_at: :desc)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def new
|
|
18
|
+
@record = Fosm::<%= class_name %>.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@record = Fosm::<%= class_name %>.new(record_params)
|
|
23
|
+
@record.created_by_id = fosm_current_user&.id
|
|
24
|
+
if @record.save
|
|
25
|
+
redirect_to fosm_<%= file_name %>_path(@record), notice: "<%= class_name.humanize %> created."
|
|
26
|
+
else
|
|
27
|
+
render :new, status: :unprocessable_entity
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fire_event
|
|
32
|
+
@record = Fosm::<%= class_name %>.find(params[:id])
|
|
33
|
+
event = params[:event]&.to_sym
|
|
34
|
+
@record.fire!(event, actor: fosm_current_user)
|
|
35
|
+
redirect_to fosm_<%= file_name %>_path(@record), notice: "Event '#{event}' fired successfully."
|
|
36
|
+
rescue Fosm::Error => e
|
|
37
|
+
redirect_to fosm_<%= file_name %>_path(@record), alert: e.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def destroy
|
|
41
|
+
@record = Fosm::<%= class_name %>.find(params[:id])
|
|
42
|
+
@record.destroy
|
|
43
|
+
redirect_to fosm_<%= plural_name %>_path, notice: "<%= class_name.humanize %> deleted."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def set_record
|
|
49
|
+
@record = Fosm::<%= class_name %>.find(params[:id])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_params
|
|
53
|
+
params.require(Fosm::<%= class_name %>.model_name.param_key).permit(<%= fields_for_permit %>)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreateFosm<%= class_name.pluralize %> < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :fosm_<%= table_name %> do |t|
|
|
4
|
+
t.string :state, null: false, default: "<%= initial_state %>"
|
|
5
|
+
t.bigint :created_by_id, null: true # FK to users — add if you have a users table
|
|
6
|
+
<% fields_for_migration.each do |field| %>
|
|
7
|
+
t.<%= field[:type] %> :<%= field[:name] %>
|
|
8
|
+
<% end %>
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :fosm_<%= table_name %>, :state
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
class <%= class_name %> < ApplicationRecord
|
|
3
|
+
include Fosm::Lifecycle
|
|
4
|
+
|
|
5
|
+
self.table_name = "fosm_<%= table_name %>"
|
|
6
|
+
|
|
7
|
+
# Uncomment if your app has a User model:
|
|
8
|
+
# belongs_to :created_by, class_name: "User", optional: true
|
|
9
|
+
|
|
10
|
+
lifecycle do
|
|
11
|
+
# States — first state is the initial state
|
|
12
|
+
<% states_list.each_with_index do |state, index| %>
|
|
13
|
+
state :<%= state %><%= index == 0 ? ", initial: true" : "" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
# Events — define transitions between states
|
|
17
|
+
# event :activate, from: :<%= states_list[0] %>, to: :<%= states_list[1] || states_list[0] %>
|
|
18
|
+
# event :complete, from: :<%= states_list[1] || states_list[0] %>, to: :<%= states_list.last %>
|
|
19
|
+
|
|
20
|
+
# Guards — conditions that must be true for an event to fire
|
|
21
|
+
# guard :has_required_data, on: :activate do |record|
|
|
22
|
+
# record.some_field.present?
|
|
23
|
+
# end
|
|
24
|
+
|
|
25
|
+
# Side effects — actions that run after a successful transition
|
|
26
|
+
# side_effect :notify_stakeholders, on: :activate do |record, transition|
|
|
27
|
+
# # Your notification code here
|
|
28
|
+
# end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%%= form_with model: record, url: record.new_record? ? fosm_<%= plural_name %>_path : fosm_<%= file_name %>_path(record), class: "space-y-4" do |f| %>
|
|
2
|
+
<%% if record.errors.any? %>
|
|
3
|
+
<div class="bg-red-50 border border-red-200 rounded p-3">
|
|
4
|
+
<p class="text-sm text-red-700 font-medium">Please fix the following errors:</p>
|
|
5
|
+
<ul class="text-sm text-red-600 list-disc list-inside mt-1">
|
|
6
|
+
<%% record.errors.full_messages.each do |msg| %>
|
|
7
|
+
<li><%%= msg %></li>
|
|
8
|
+
<%% end %>
|
|
9
|
+
</ul>
|
|
10
|
+
</div>
|
|
11
|
+
<%% end %>
|
|
12
|
+
|
|
13
|
+
<% fields_for_migration.each do |field| %>
|
|
14
|
+
<div>
|
|
15
|
+
<%%= f.label :<%= field[:name] %>, class: "block text-sm font-medium text-gray-700 mb-1" %>
|
|
16
|
+
<%%= f.<%= field[:type] == "text" ? "text_area" : "text_field" %> :<%= field[:name] %>, class: "w-full border border-gray-200 rounded px-3 py-2 text-sm" %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<div class="flex gap-3 pt-2">
|
|
21
|
+
<%%= f.submit class: "bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700" %>
|
|
22
|
+
<%%= link_to "Cancel", fosm_<%= plural_name %>_path, class: "text-sm text-gray-500 py-2" %>
|
|
23
|
+
</div>
|
|
24
|
+
<%% end %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<div class="p-6 space-y-4">
|
|
2
|
+
<div class="flex items-center justify-between">
|
|
3
|
+
<h1 class="text-2xl font-bold text-gray-900"><%= class_name.pluralize.humanize %></h1>
|
|
4
|
+
<%%= link_to "New <%= class_name.humanize %>", new_fosm_<%= file_name %>_path, class: "bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700" %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<table class="w-full bg-white border border-gray-200 rounded-lg text-sm">
|
|
8
|
+
<thead class="bg-gray-50 text-gray-500 text-xs uppercase">
|
|
9
|
+
<tr>
|
|
10
|
+
<th class="px-4 py-2 text-left">ID</th>
|
|
11
|
+
<th class="px-4 py-2 text-left">State</th>
|
|
12
|
+
<% fields_for_migration.first(3).each do |field| %>
|
|
13
|
+
<th class="px-4 py-2 text-left"><%= field[:name].humanize %></th>
|
|
14
|
+
<% end %>
|
|
15
|
+
<th class="px-4 py-2 text-left">Created</th>
|
|
16
|
+
<th class="px-4 py-2 text-left">Actions</th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody class="divide-y divide-gray-100">
|
|
20
|
+
<%% @records.each do |record| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td class="px-4 py-2 text-gray-600"><%%= record.id %></td>
|
|
23
|
+
<td class="px-4 py-2">
|
|
24
|
+
<span class="text-xs font-medium bg-gray-100 text-gray-700 px-2 py-0.5 rounded"><%%= record.state %></span>
|
|
25
|
+
</td>
|
|
26
|
+
<% fields_for_migration.first(3).each do |field| %>
|
|
27
|
+
<td class="px-4 py-2 text-gray-700"><%%= record.<%= field[:name] %> %></td>
|
|
28
|
+
<% end %>
|
|
29
|
+
<td class="px-4 py-2 text-gray-400"><%%= record.created_at.strftime("%b %d, %Y") %></td>
|
|
30
|
+
<td class="px-4 py-2">
|
|
31
|
+
<%%= link_to "View", fosm_<%= file_name %>_path(record), class: "text-blue-600 hover:underline text-xs" %>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<%% end %>
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<div class="p-6 max-w-2xl space-y-6">
|
|
2
|
+
<div class="flex items-center gap-3">
|
|
3
|
+
<%%= link_to "← Back", fosm_<%= plural_name %>_path, class: "text-sm text-blue-600 hover:underline" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="bg-white border border-gray-200 rounded-lg p-5 space-y-4">
|
|
7
|
+
<div class="flex items-center justify-between">
|
|
8
|
+
<h1 class="text-xl font-bold text-gray-900"><%= class_name.humanize %> #<%%= @record.id %></h1>
|
|
9
|
+
<span class="text-sm font-medium bg-blue-50 text-blue-700 px-3 py-1 rounded-full"><%%= @record.state %></span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% fields_for_migration.each do |field| %>
|
|
13
|
+
<div>
|
|
14
|
+
<span class="text-xs text-gray-500 uppercase tracking-wide"><%= field[:name].humanize %></span>
|
|
15
|
+
<p class="text-gray-900 mt-0.5"><%%= @record.<%= field[:name] %> || "—" %></p>
|
|
16
|
+
</div>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<%% if @record.available_events.any? %>
|
|
21
|
+
<div class="bg-white border border-gray-200 rounded-lg p-5">
|
|
22
|
+
<h2 class="font-semibold text-gray-900 mb-3">Available Actions</h2>
|
|
23
|
+
<div class="flex gap-2 flex-wrap">
|
|
24
|
+
<%% @record.available_events.each do |event| %>
|
|
25
|
+
<%%= button_to event.to_s.humanize,
|
|
26
|
+
fire_event_fosm_<%= file_name %>_path(@record),
|
|
27
|
+
params: { event: event },
|
|
28
|
+
method: :post,
|
|
29
|
+
class: "text-sm bg-gray-800 text-white px-4 py-2 rounded hover:bg-gray-700" %>
|
|
30
|
+
<%% end %>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<%% end %>
|
|
34
|
+
|
|
35
|
+
<div class="bg-white border border-gray-200 rounded-lg p-5">
|
|
36
|
+
<h2 class="font-semibold text-gray-900 mb-3">Transition History</h2>
|
|
37
|
+
<%% transitions = Fosm::TransitionLog.for_record("Fosm::<%= class_name %>", @record.id).recent %>
|
|
38
|
+
<%% if transitions.any? %>
|
|
39
|
+
<div class="space-y-2">
|
|
40
|
+
<%% transitions.each do |t| %>
|
|
41
|
+
<div class="text-sm flex items-center gap-2">
|
|
42
|
+
<span class="text-orange-600"><%%= t.from_state %></span>
|
|
43
|
+
<span class="text-gray-400">→</span>
|
|
44
|
+
<span class="text-green-600"><%%= t.to_state %></span>
|
|
45
|
+
<span class="text-gray-400">via <%%= t.event_name %></span>
|
|
46
|
+
<%% if t.actor_label.present? %>
|
|
47
|
+
<span class="text-gray-500">by <%%= t.actor_label %></span>
|
|
48
|
+
<%% end %>
|
|
49
|
+
<span class="text-gray-400 text-xs"><%%= t.created_at.strftime("%b %d %H:%M") %></span>
|
|
50
|
+
</div>
|
|
51
|
+
<%% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<%% else %>
|
|
54
|
+
<p class="text-sm text-gray-400">No transitions yet.</p>
|
|
55
|
+
<%% end %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|