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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +384 -0
  3. data/LICENSE +115 -0
  4. data/README.md +322 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/fosm/rails/application.css +15 -0
  7. data/app/controllers/fosm/admin/agents_controller.rb +242 -0
  8. data/app/controllers/fosm/admin/apps_controller.rb +34 -0
  9. data/app/controllers/fosm/admin/base_controller.rb +15 -0
  10. data/app/controllers/fosm/admin/dashboard_controller.rb +25 -0
  11. data/app/controllers/fosm/admin/settings_controller.rb +44 -0
  12. data/app/controllers/fosm/admin/transitions_controller.rb +22 -0
  13. data/app/controllers/fosm/admin/webhooks_controller.rb +37 -0
  14. data/app/controllers/fosm/application_controller.rb +23 -0
  15. data/app/controllers/fosm/rails/application_controller.rb +6 -0
  16. data/app/helpers/fosm/application_helper.rb +19 -0
  17. data/app/helpers/fosm/rails/application_helper.rb +11 -0
  18. data/app/jobs/fosm/application_job.rb +6 -0
  19. data/app/jobs/fosm/rails/application_job.rb +6 -0
  20. data/app/jobs/fosm/webhook_delivery_job.rb +52 -0
  21. data/app/models/fosm/application_record.rb +6 -0
  22. data/app/models/fosm/rails/application_record.rb +7 -0
  23. data/app/models/fosm/transition_log.rb +27 -0
  24. data/app/models/fosm/webhook_subscription.rb +15 -0
  25. data/app/views/fosm/admin/agents/chat.html.erb +242 -0
  26. data/app/views/fosm/admin/agents/show.html.erb +166 -0
  27. data/app/views/fosm/admin/apps/show.html.erb +114 -0
  28. data/app/views/fosm/admin/dashboard/index.html.erb +82 -0
  29. data/app/views/fosm/admin/settings/show.html.erb +63 -0
  30. data/app/views/fosm/admin/transitions/index.html.erb +65 -0
  31. data/app/views/fosm/admin/webhooks/index.html.erb +51 -0
  32. data/app/views/fosm/admin/webhooks/new.html.erb +45 -0
  33. data/app/views/layouts/fosm/application.html.erb +41 -0
  34. data/app/views/layouts/fosm/rails/application.html.erb +17 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20240101000001_create_fosm_transition_logs.rb +23 -0
  37. data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +16 -0
  38. data/lib/fosm/agent.rb +232 -0
  39. data/lib/fosm/configuration.rb +50 -0
  40. data/lib/fosm/engine.rb +133 -0
  41. data/lib/fosm/errors.rb +31 -0
  42. data/lib/fosm/lifecycle/definition.rb +103 -0
  43. data/lib/fosm/lifecycle/event_definition.rb +27 -0
  44. data/lib/fosm/lifecycle/guard_definition.rb +16 -0
  45. data/lib/fosm/lifecycle/side_effect_definition.rb +16 -0
  46. data/lib/fosm/lifecycle/state_definition.rb +18 -0
  47. data/lib/fosm/lifecycle.rb +173 -0
  48. data/lib/fosm/rails/engine.rb +9 -0
  49. data/lib/fosm/rails/version.rb +9 -0
  50. data/lib/fosm/rails.rb +9 -0
  51. data/lib/fosm/registry.rb +29 -0
  52. data/lib/fosm/version.rb +3 -0
  53. data/lib/fosm-rails.rb +40 -0
  54. data/lib/generators/fosm/app/app_generator.rb +106 -0
  55. data/lib/generators/fosm/app/templates/agent.rb.tt +26 -0
  56. data/lib/generators/fosm/app/templates/controller.rb.tt +56 -0
  57. data/lib/generators/fosm/app/templates/migration.rb.tt +14 -0
  58. data/lib/generators/fosm/app/templates/model.rb.tt +31 -0
  59. data/lib/generators/fosm/app/templates/views/_form.html.erb.tt +24 -0
  60. data/lib/generators/fosm/app/templates/views/index.html.erb.tt +37 -0
  61. data/lib/generators/fosm/app/templates/views/new.html.erb.tt +4 -0
  62. data/lib/generators/fosm/app/templates/views/show.html.erb.tt +57 -0
  63. data/lib/tasks/fosm/rails_tasks.rake +4 -0
  64. 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
@@ -0,0 +1,9 @@
1
+ # Legacy shim — the real engine is Fosm::Engine defined in lib/fosm/engine.rb
2
+ require_relative "../engine"
3
+
4
+ module Fosm
5
+ module Rails
6
+ # Kept for backward compatibility. Aliases the real engine.
7
+ Engine = Fosm::Engine
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "../version"
2
+
3
+ module Fosm
4
+ module Rails
5
+ # Legacy constant kept for backward compatibility.
6
+ # The primary version is Fosm::VERSION.
7
+ VERSION = Fosm::VERSION
8
+ end
9
+ end
data/lib/fosm/rails.rb ADDED
@@ -0,0 +1,9 @@
1
+ # Legacy entry point — kept for backward compatibility.
2
+ # The primary entry point is lib/fosm-rails.rb.
3
+ require "fosm-rails"
4
+
5
+ module Fosm
6
+ module Rails
7
+ # Shim module so any code that references Fosm::Rails still loads cleanly.
8
+ end
9
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module Fosm
2
+ VERSION = "0.1.0"
3
+ end
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,4 @@
1
+ <div class="p-6 max-w-lg space-y-6">
2
+ <h1 class="text-2xl font-bold text-gray-900">New <%= class_name.humanize %></h1>
3
+ <%%= render "form", record: @record %>
4
+ </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">&rarr;</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>
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :fosm_rails do
3
+ # # Task goes here
4
+ # end