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
data/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # fosm-rails
2
+
3
+ **Finite Object State Machine for Rails** — declarative lifecycles for business objects, with an AI agent interface that enforces bounded autonomy.
4
+
5
+ ```ruby
6
+ class Invoice < ApplicationRecord
7
+ include Fosm::Lifecycle
8
+
9
+ lifecycle do
10
+ state :draft, initial: true
11
+ state :sent
12
+ state :paid, terminal: true
13
+ state :cancelled, terminal: true
14
+
15
+ event :send_invoice, from: :draft, to: :sent
16
+ event :pay, from: :sent, to: :paid
17
+ event :cancel, from: [:draft, :sent], to: :cancelled
18
+
19
+ guard :has_line_items, on: :send_invoice do |invoice|
20
+ invoice.amount > 0
21
+ end
22
+
23
+ side_effect :notify_client, on: :send_invoice do |invoice, transition|
24
+ InvoiceMailer.send_to_client(invoice).deliver_later
25
+ end
26
+ end
27
+ end
28
+ ```
29
+
30
+ That block is the complete lifecycle specification. There is no path from `draft` to `paid`. There is no path out of `paid` — it's terminal. A guard blocks sending an empty invoice. A side effect fires the notification email. The machine enforces all of it.
31
+
32
+ ---
33
+
34
+ ## Installation
35
+
36
+ Add to your `Gemfile`:
37
+
38
+ ```ruby
39
+ gem "fosm-rails"
40
+ ```
41
+
42
+ `gemlings` (the AI agent framework) is a **required dependency** — it is declared in `fosm-rails.gemspec` and installed automatically. You do not need to add it separately. Set the API key for your LLM provider (e.g. `ANTHROPIC_API_KEY`) and the agent is ready to use with no extra configuration.
43
+
44
+ Run:
45
+
46
+ ```bash
47
+ bundle install
48
+ rails fosm:install:migrations
49
+ rails db:migrate
50
+ ```
51
+
52
+ Mount the engine in `config/routes.rb`:
53
+
54
+ ```ruby
55
+ Rails.application.routes.draw do
56
+ mount Fosm::Engine => "/fosm"
57
+ draw :fosm # draws config/routes/fosm.rb (auto-created by generators)
58
+ end
59
+ ```
60
+
61
+ Configure auth in `config/initializers/fosm.rb`:
62
+
63
+ ```ruby
64
+ Fosm.configure do |config|
65
+ # The base controller the FOSM engine inherits from
66
+ config.base_controller = "ApplicationController"
67
+
68
+ # Who can access /fosm/admin — should be superadmin only
69
+ config.admin_authorize = -> { redirect_to root_path unless current_user&.superadmin? }
70
+
71
+ # How to authorize individual FOSM apps
72
+ config.app_authorize = ->(_level) { authenticate_user! }
73
+
74
+ # How to get the current user (for transition log actor tracking)
75
+ config.current_user_method = -> { current_user }
76
+
77
+ # Layouts
78
+ config.admin_layout = "admin" # your admin layout
79
+ config.app_layout = "application"
80
+ end
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Quickstart: create a new FOSM app
86
+
87
+ ```bash
88
+ rails generate fosm:app invoice \
89
+ --fields name:string amount:decimal client_name:string due_date:date \
90
+ --states draft,sent,paid,overdue,cancelled \
91
+ --access authenticate_user!
92
+ ```
93
+
94
+ This generates:
95
+
96
+ ```
97
+ app/models/fosm/invoice.rb # Model with lifecycle DSL stub
98
+ app/controllers/fosm/invoice_controller.rb
99
+ app/views/fosm/invoice/ # index, show, new, _form
100
+ app/agents/fosm/invoice_agent.rb # Gemlings AI agent
101
+ db/migrate/..._create_fosm_invoices.rb
102
+ config/routes/fosm.rb # Route registration
103
+ ```
104
+
105
+ Then run `rails db:migrate` and visit `/fosm/apps/invoices`.
106
+
107
+ > **One lifecycle definition → three things for free**
108
+ >
109
+ > When you run `rails generate fosm:app invoice`, FOSM generates a model with a lifecycle stub, a CRUD controller, HTML views, database migration, and a Gemlings AI agent — all wired together. Define the states, events, guards, and side effects once. The CRUD UI enforces them. The AI agent is bounded by them. The admin dashboard visualises them.
110
+
111
+ ---
112
+
113
+ ## Defining lifecycles
114
+
115
+ ### States
116
+
117
+ ```ruby
118
+ lifecycle do
119
+ state :draft, initial: true # starting state (exactly one allowed)
120
+ state :active
121
+ state :closed, terminal: true # no transitions out of terminal states
122
+ end
123
+ ```
124
+
125
+ ### Events
126
+
127
+ ```ruby
128
+ event :activate, from: :draft, to: :active
129
+ event :close, from: [:draft, :active], to: :closed
130
+ ```
131
+
132
+ ### Guards
133
+
134
+ Guards are **pure functions** — they block a transition if they return false. No side effects inside guards.
135
+
136
+ ```ruby
137
+ guard :has_required_fields, on: :activate do |record|
138
+ record.name.present? && record.amount.positive?
139
+ end
140
+ ```
141
+
142
+ ### Side effects
143
+
144
+ Side effects run **after** the state persists, within the same database transaction.
145
+
146
+ ```ruby
147
+ side_effect :send_notification, on: :activate do |record, transition|
148
+ # transition contains: { from:, to:, event:, actor: }
149
+ NotificationMailer.activated(record).deliver_later
150
+ end
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Firing events
156
+
157
+ ```ruby
158
+ # Dynamic bang method (generated per event)
159
+ invoice.send_invoice!(actor: current_user)
160
+ invoice.pay!(actor: current_user)
161
+ invoice.cancel!(actor: current_user, metadata: { reason: "client request" })
162
+
163
+ # Or via the generic fire! method
164
+ invoice.fire!(:send_invoice, actor: current_user)
165
+
166
+ # Check before firing
167
+ invoice.can_send_invoice? # => true/false
168
+ invoice.available_events # => [:pay, :cancel]
169
+ invoice.draft? # => false (state predicate)
170
+ invoice.sent? # => true
171
+ ```
172
+
173
+ When a transition is invalid, `fire!` raises a `Fosm::InvalidTransition` error. When a guard fails, it raises `Fosm::GuardFailed`. There is no silent state corruption.
174
+
175
+ ---
176
+
177
+ ## AI Agents (powered by Gemlings)
178
+
179
+ **Every FOSM app automatically has a fully-configured AI agent.** You don't write any agent code to get started — the tools are derived directly from the lifecycle definition at runtime. The agent is bounded by the same rules as the human UI: it can only fire events that exist, it cannot bypass guards, and every action is written to the immutable transition log.
180
+
181
+ Each FOSM app auto-generates standard Gemlings tools from the lifecycle definition. The agent can only fire events that exist in the machine.
182
+
183
+ ```ruby
184
+ # app/agents/fosm/invoice_agent.rb
185
+ class Fosm::InvoiceAgent < Fosm::Agent
186
+ model_class Fosm::Invoice
187
+ default_model "anthropic/claude-sonnet-4-20250514"
188
+
189
+ # Optional: add custom tools
190
+ fosm_tool :find_overdue,
191
+ description: "Find sent invoices past their due date",
192
+ inputs: {} do
193
+ Fosm::Invoice.where(state: "sent")
194
+ .where("due_date < ?", Date.today)
195
+ .map { |inv| { id: inv.id, due_date: inv.due_date.to_s } }
196
+ end
197
+ end
198
+
199
+ # Use it
200
+ agent = Fosm::InvoiceAgent.build_agent
201
+ agent.run("Mark all sent invoices older than 30 days as overdue")
202
+
203
+ # Or use a different model
204
+ agent = Fosm::InvoiceAgent.build_agent(model: "openai/gpt-4o")
205
+ agent.run("Pay invoice #42 if it's in the correct state")
206
+ ```
207
+
208
+ **Standard tools auto-generated for every FOSM app:**
209
+
210
+ | Tool | Description |
211
+ |---|---|
212
+ | `list_invoices` | List records, optionally filtered by state |
213
+ | `get_invoice` | Get a record by ID with state + available events |
214
+ | `available_events_for_invoice` | What events can fire from current state |
215
+ | `transition_history_for_invoice` | Full audit trail for a record |
216
+ | `send_invoice_invoice` | Fire the `send_invoice` event (one per lifecycle event) |
217
+ | `pay_invoice` | Fire the `pay` event |
218
+ | `cancel_invoice` | Fire the `cancel` event |
219
+
220
+ The agent cannot fire an event that doesn't exist in the lifecycle. Invalid transitions return `{ success: false }` — the machine refuses, not the LLM.
221
+
222
+ ---
223
+
224
+ ## Admin UI
225
+
226
+ The engine mounts an admin interface at `/fosm/admin` (access controlled by `config.admin_authorize`):
227
+
228
+ - **Dashboard** — all FOSM apps with state distribution
229
+ - **App detail** — lifecycle definition table, state distribution chart, stuck record detection
230
+ - **Agent explorer** (`/fosm/admin/apps/:slug/agent`) — the auto-generated tool catalog for the app's AI agent, a direct tool tester (no LLM required), and the system prompt injected into agents
231
+ - **Agent chat** (`/fosm/admin/apps/:slug/agent/chat`) — live multi-turn chat with the agent; see tool calls, thoughts, and state changes in real time
232
+ - **Transition log** — complete audit trail, filterable by app / event / actor (human vs AI agent)
233
+ - **Webhooks** — configure HTTP callbacks for any FOSM event (with HMAC-SHA256 signing)
234
+ - **Settings** — LLM provider key status, engine configuration overview
235
+
236
+ ---
237
+
238
+ ## Webhooks
239
+
240
+ Configure via the admin UI at `/fosm/admin/webhooks` or programmatically:
241
+
242
+ ```ruby
243
+ Fosm::WebhookSubscription.create!(
244
+ model_class_name: "Fosm::Invoice",
245
+ event_name: "send_invoice",
246
+ url: "https://your-app.com/webhooks/fosm",
247
+ secret_token: "your_signing_secret",
248
+ active: true
249
+ )
250
+ ```
251
+
252
+ FOSM POSTs a JSON payload to your URL with headers:
253
+ - `X-FOSM-Event`: the event name
254
+ - `X-FOSM-Record-Type`: the model class name
255
+ - `X-FOSM-Signature`: `sha256=HMAC-SHA256(secret_token, payload)` (if secret token set)
256
+
257
+ ---
258
+
259
+ ## Transition log
260
+
261
+ Every state change is written to `fosm_transition_logs` — an immutable, append-only table. Records cannot be updated or deleted.
262
+
263
+ ```ruby
264
+ Fosm::TransitionLog.for_record("Fosm::Invoice", 42).recent
265
+ # => [{ event: "send_invoice", from: "draft", to: "sent", actor: "user@example.com", at: "..." }]
266
+
267
+ Fosm::TransitionLog.for_app(Fosm::Invoice).by_event("pay").count
268
+ # => 17
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Architecture
274
+
275
+ ```
276
+ your_rails_app/
277
+ app/
278
+ models/fosm/ ← Your FOSM models (generated)
279
+ invoice.rb
280
+ controllers/fosm/ ← Your FOSM controllers (generated)
281
+ invoice_controller.rb
282
+ views/fosm/ ← Your FOSM views (generated, customizable)
283
+ invoice/
284
+ agents/fosm/ ← Your Gemlings AI agents (generated)
285
+ invoice_agent.rb
286
+ config/
287
+ routes/fosm.rb ← Route registration (auto-updated by generator)
288
+ initializers/fosm.rb ← Engine configuration
289
+
290
+ # Engine provides (from gem):
291
+ app/models/fosm/
292
+ transition_log.rb ← Shared audit trail
293
+ webhook_subscription.rb
294
+ app/controllers/fosm/admin/
295
+ dashboard_controller.rb
296
+ ...
297
+ lib/fosm/
298
+ lifecycle.rb ← The DSL concern
299
+ agent.rb ← Gemlings base agent
300
+ engine.rb
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Requirements
306
+
307
+ - Ruby >= 3.1
308
+ - Rails >= 8.1
309
+ - Any SQL database supported by Rails (SQLite, PostgreSQL, MySQL)
310
+ - `ANTHROPIC_API_KEY` (or another provider key) to use the AI agent chat — see `/fosm/admin/settings` for status
311
+
312
+ `gemlings` is bundled automatically as a required dependency. No separate configuration needed unless you want to choose a different LLM provider.
313
+
314
+ ---
315
+
316
+ ## Contributing
317
+
318
+ FOSM is open source and welcomes contributions. See [AGENTS.md](AGENTS.md) for a deep explanation of the design philosophy and how to extend the engine thoughtfully.
319
+
320
+ ## License
321
+
322
+ FSL-1.1-Apache-2.0 — Copyright 2026 [Abhishek Parolkar](https://parolkar.com) and INLOOP.STUDIO PTE LTD. See [LICENSE](LICENSE) for details.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,242 @@
1
+ module Fosm
2
+ module Admin
3
+ class AgentsController < BaseController
4
+ before_action :load_app
5
+
6
+ def show
7
+ @tools = derive_tool_definitions
8
+ @system_prompt = derive_system_prompt
9
+ @agent_class = begin
10
+ "Fosm::#{@model_class.name.demodulize}Agent".constantize
11
+ rescue NameError
12
+ nil
13
+ end
14
+ end
15
+
16
+ # ── Chat ──────────────────────────────────────────────────────────────────
17
+
18
+ def chat
19
+ @history = chat_history
20
+ end
21
+
22
+ def chat_send
23
+ message = params[:message].to_s.strip
24
+ return render json: { error: "Message cannot be blank" }, status: :bad_request if message.blank?
25
+
26
+ agent = fetch_or_build_agent
27
+
28
+ # reset: true starts fresh each turn (avoids trailing-whitespace issues with
29
+ # multi-turn context). Visual history is maintained in the session.
30
+ result = agent.run(message, reset: true, return_full_result: true)
31
+
32
+ # Persist agent instance so next message continues the same conversation
33
+ store_agent(agent)
34
+
35
+ steps = Array(result.steps).map { |s|
36
+ h = s.to_h
37
+ # Ensure tool_calls are plain hashes for JSON serialization
38
+ if h[:tool_calls]
39
+ h[:tool_calls] = h[:tool_calls].map { |tc|
40
+ { name: tc.function.name, args: tc.function.arguments }
41
+ } rescue h[:tool_calls].map(&:to_s)
42
+ end
43
+ h
44
+ }
45
+
46
+ output = result.output.to_s
47
+
48
+ # Store chat history in Rails.cache (not session cookie) to avoid
49
+ # ActionDispatch::Cookies::CookieOverflow — agent responses can be large.
50
+ history = chat_history
51
+ history << { role: "user", content: message }
52
+ history << { role: "agent", content: output, timing: result.timing.round(2) }
53
+ save_chat_history(history.last(10))
54
+
55
+ render json: { output: output, steps: steps,
56
+ token_usage: result.token_usage.to_h,
57
+ timing: result.timing.round(2) }
58
+ rescue => e
59
+ render json: { error: "#{e.class}: #{e.message}" }, status: :unprocessable_entity
60
+ end
61
+
62
+ def chat_reset
63
+ ::Rails.cache.delete(chat_history_key)
64
+ ::Rails.cache.delete(agent_cache_key)
65
+ render json: { ok: true }
66
+ end
67
+
68
+ def agent_invoke
69
+ tool = params[:tool].to_s
70
+ id = params[:record_id].presence
71
+ filter = params[:filter].presence
72
+
73
+ result = invoke_tool(tool, id, filter)
74
+ render json: result
75
+ rescue => e
76
+ render json: { error: e.message }, status: :unprocessable_entity
77
+ end
78
+
79
+ private
80
+
81
+ def load_app
82
+ @slug = params[:slug]
83
+ @model_class = Fosm::Registry.find(@slug)
84
+ render plain: "FOSM app '#{@slug}' not found", status: :not_found unless @model_class
85
+ @lifecycle = @model_class.fosm_lifecycle
86
+ @mn = @model_class.name.demodulize.underscore
87
+ end
88
+
89
+ # Derives tool metadata from the lifecycle — no Gemlings required.
90
+ def derive_tool_definitions
91
+ plural = @mn.pluralize
92
+ tools = [
93
+ {
94
+ name: "list_#{plural}",
95
+ description: "List #{plural} with their current state. Pass state= to filter.",
96
+ params: { state: "Optional: filter by state name (e.g. 'draft')" },
97
+ requires_id: false,
98
+ category: :read
99
+ },
100
+ {
101
+ name: "get_#{@mn}",
102
+ description: "Get a #{@mn} by ID with current state and available events.",
103
+ params: { id: "Record ID" },
104
+ requires_id: true,
105
+ category: :read
106
+ },
107
+ {
108
+ name: "available_events_for_#{@mn}",
109
+ description: "Check which lifecycle events can fire from the current state. Always call before firing.",
110
+ params: { id: "Record ID" },
111
+ requires_id: true,
112
+ category: :read
113
+ },
114
+ {
115
+ name: "transition_history_for_#{@mn}",
116
+ description: "Full immutable audit trail of every state transition for this record.",
117
+ params: { id: "Record ID" },
118
+ requires_id: true,
119
+ category: :read
120
+ }
121
+ ]
122
+
123
+ @lifecycle.events.each do |event|
124
+ guard_note = event.guards.any? ? " Guards: #{event.guards.map(&:name).join(', ')}." : ""
125
+ side_note = event.side_effects.any? ? " Side-effects: #{event.side_effects.map(&:name).join(', ')}." : ""
126
+ tools << {
127
+ name: "#{event.name}_#{@mn}",
128
+ description: "Fire '#{event.name}'. Valid from [#{event.from_states.join(' | ')}] → #{event.to_state}.#{guard_note}#{side_note}",
129
+ params: { id: "Record ID" },
130
+ requires_id: true,
131
+ event: event.name,
132
+ category: :mutate
133
+ }
134
+ end
135
+
136
+ tools
137
+ end
138
+
139
+ def derive_system_prompt
140
+ state_names = @lifecycle.state_names.join(", ")
141
+ terminal = @lifecycle.states.select(&:terminal?).map(&:name).join(", ")
142
+ event_names = @lifecycle.event_names.join(", ")
143
+
144
+ <<~PROMPT.strip
145
+ You are a FOSM AI agent managing #{@model_class.name.demodulize.pluralize.humanize}.
146
+
147
+ ARCHITECTURE CONSTRAINTS:
148
+ 1. State changes happen ONLY via lifecycle event tools. Never use direct updates.
149
+ 2. Valid states: #{state_names}
150
+ 3. Terminal states (irreversible): #{terminal.presence || "none"}
151
+ 4. Available lifecycle events: #{event_names}
152
+ 5. ALWAYS call available_events_for_#{@mn}(id:) before firing any event.
153
+ 6. If a tool returns { success: false }, DO NOT retry — report the error.
154
+ 7. Records in a terminal state cannot transition further — accept this.
155
+ PROMPT
156
+ end
157
+
158
+ def invoke_tool(tool, id, filter)
159
+ plural = @mn.pluralize
160
+ klass = @model_class
161
+
162
+ case tool
163
+ when "list_#{plural}"
164
+ records = filter.present? ? klass.where(state: filter) : klass.order(created_at: :desc).limit(20)
165
+ { result: records.map { |r| safe_attrs(r) } }
166
+
167
+ when "get_#{@mn}"
168
+ record = klass.find(id)
169
+ { result: safe_attrs(record).merge(available_events: record.available_events) }
170
+
171
+ when "available_events_for_#{@mn}"
172
+ record = klass.find(id)
173
+ { result: { id: record.id, current_state: record.state, available_events: record.available_events } }
174
+
175
+ when "transition_history_for_#{@mn}"
176
+ logs = Fosm::TransitionLog.for_record(klass.name, id).recent
177
+ { result: logs.map { |t|
178
+ { event: t.event_name, from: t.from_state, to: t.to_state,
179
+ actor: t.actor_label || t.actor_type, at: t.created_at.iso8601 }
180
+ } }
181
+
182
+ else
183
+ # fire event: tool name pattern is "event_name_#{mn}"
184
+ event_name = tool.delete_suffix("_#{@mn}").to_sym
185
+ record = klass.find(id)
186
+ record.fire!(event_name, actor: :agent)
187
+ { result: { success: true, id: record.id, new_state: record.reload.state } }
188
+ end
189
+ rescue ActiveRecord::RecordNotFound
190
+ { error: "Record ##{id} not found" }
191
+ rescue Fosm::Error => e
192
+ { error: e.message, success: false }
193
+ end
194
+
195
+ def agent_cache_key
196
+ "fosm_agent_#{session.id}_#{@slug}"
197
+ end
198
+
199
+ def chat_history_key
200
+ "fosm_chat_#{session.id}_#{@slug}"
201
+ end
202
+
203
+ def chat_history
204
+ ::Rails.cache.read(chat_history_key) || []
205
+ end
206
+
207
+ def save_chat_history(history)
208
+ ::Rails.cache.write(chat_history_key, history, expires_in: 4.hours)
209
+ end
210
+
211
+ def fetch_or_build_agent
212
+ ::Rails.cache.read(agent_cache_key) || build_fosm_agent
213
+ end
214
+
215
+ def store_agent(agent)
216
+ ::Rails.cache.write(agent_cache_key, agent, expires_in: 2.hours)
217
+ rescue
218
+ # Marshal serialization may fail for complex objects — that's OK,
219
+ # next message will start a fresh agent with no prior memory
220
+ end
221
+
222
+ def build_fosm_agent
223
+ klass = begin
224
+ "Fosm::#{@model_class.name.demodulize}Agent".constantize
225
+ rescue NameError
226
+ # Build an ad-hoc anonymous agent class for this model
227
+ anon = Class.new(Fosm::Agent)
228
+ anon.model_class(@model_class)
229
+ anon
230
+ end
231
+ klass.build_agent
232
+ end
233
+
234
+ def safe_attrs(record)
235
+ record.attributes.except("created_by_id").tap do |h|
236
+ h["created_at"] = h["created_at"]&.iso8601
237
+ h["updated_at"] = h["updated_at"]&.iso8601
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,34 @@
1
+ module Fosm
2
+ module Admin
3
+ class AppsController < BaseController
4
+ def index
5
+ redirect_to fosm.admin_root_path
6
+ end
7
+
8
+ def show
9
+ @slug = params[:slug]
10
+ @model_class = Fosm::Registry.find(@slug)
11
+ return render plain: "FOSM app '#{@slug}' not found", status: :not_found unless @model_class
12
+
13
+ @lifecycle = @model_class.fosm_lifecycle
14
+ @diagram_data = @lifecycle.to_diagram_data
15
+
16
+ @state_counts = @lifecycle.state_names.index_with { |s| @model_class.where(state: s).count }
17
+ @recent_transitions = Fosm::TransitionLog.for_app(@model_class).recent.limit(20)
18
+ @total = @model_class.count
19
+
20
+ # Stuck records: in a non-terminal state, no transition in 7 days
21
+ non_terminal_states = @lifecycle.states.reject(&:terminal?).map { |s| s.name.to_s }
22
+ # Load record_ids as array and cast to match the model's PK type (record_id
23
+ # is stored as varchar in transition_logs but the model PK may be bigint).
24
+ pk_type = @model_class.columns_hash[@model_class.primary_key]&.sql_type_metadata&.type
25
+ stuck_ids = Fosm::TransitionLog.for_app(@model_class)
26
+ .where("created_at < ?", 7.days.ago)
27
+ .distinct
28
+ .pluck(:record_id)
29
+ stuck_ids = stuck_ids.map(&:to_i) if pk_type == :integer
30
+ @stuck_count = @model_class.where(state: non_terminal_states).where.not(id: stuck_ids).count
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ module Fosm
2
+ module Admin
3
+ class BaseController < Fosm::ApplicationController
4
+ layout -> { Fosm.config.admin_layout }
5
+
6
+ before_action :fosm_authorize_admin
7
+
8
+ private
9
+
10
+ def fosm_authorize_admin
11
+ instance_exec(&Fosm.config.admin_authorize)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ module Fosm
2
+ module Admin
3
+ class DashboardController < BaseController
4
+ def index
5
+ @apps = Fosm::Registry.all.map do |slug, model_class|
6
+ lifecycle = model_class.fosm_lifecycle
7
+ state_counts = lifecycle.state_names.index_with { |state_name|
8
+ model_class.where(state: state_name).count
9
+ }
10
+ {
11
+ slug: slug,
12
+ model_class: model_class,
13
+ name: model_class.name.demodulize.humanize,
14
+ state_counts: state_counts,
15
+ total: model_class.count,
16
+ recent_transitions: Fosm::TransitionLog.for_app(model_class).recent.limit(3)
17
+ }
18
+ end
19
+
20
+ @total_transitions = Fosm::TransitionLog.count
21
+ @recent_transitions = Fosm::TransitionLog.recent.limit(10)
22
+ end
23
+ end
24
+ end
25
+ end