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
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,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
|