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,166 @@
1
+ <div class="p-6 space-y-8">
2
+ <div class="flex items-center gap-3">
3
+ <%= link_to "← #{@model_class.name.demodulize.humanize}", fosm.admin_app_path(@slug), class: "text-sm text-blue-600 hover:underline" %>
4
+ </div>
5
+
6
+ <!-- Header -->
7
+ <div class="flex items-start justify-between">
8
+ <div>
9
+ <h1 class="text-2xl font-bold text-gray-900"><%= @model_class.name.demodulize.humanize %> Agent</h1>
10
+ <p class="text-sm text-gray-500 mt-1">
11
+ <% if @agent_class %>
12
+ <span class="text-green-600 font-medium">&#10003; <%= @agent_class.name %></span>
13
+ &middot; model: <code class="bg-gray-100 px-1 rounded text-xs"><%= @agent_class.default_model %></code>
14
+ <% else %>
15
+ <span class="text-amber-600">No agent class found.</span>
16
+ Define <code class="bg-gray-100 px-1 rounded text-xs">Fosm::<%= @model_class.name.demodulize %>Agent &lt; Fosm::Agent</code>
17
+ in <code class="bg-gray-100 px-1 rounded text-xs">app/agents/fosm/<%= @mn %>_agent.rb</code>
18
+ <% end %>
19
+ </p>
20
+ </div>
21
+ <div class="text-xs px-2 py-1 rounded border bg-green-50 border-green-200 text-green-700">
22
+ &#10003; Gemlings <%= Gemlings::VERSION rescue "loaded" %>
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Chat CTA -->
27
+ <div class="flex items-center justify-between bg-purple-50 border border-purple-200 rounded-lg px-5 py-3">
28
+ <div>
29
+ <p class="text-sm font-medium text-purple-900">Chat with this agent</p>
30
+ <p class="text-xs text-purple-600 mt-0.5">Multi-turn conversation · agent uses lifecycle tools only · reasoning visible</p>
31
+ </div>
32
+ <%= link_to "Chat with Agent →",
33
+ fosm.agent_chat_admin_app_path(@slug),
34
+ class: "text-sm bg-purple-700 text-white px-4 py-2 rounded-lg hover:bg-purple-800 shrink-0" %>
35
+ </div>
36
+
37
+ <!-- Tool catalog -->
38
+ <div class="bg-white border border-gray-200 rounded-lg p-5 space-y-4">
39
+ <div>
40
+ <h2 class="font-semibold text-gray-900">Auto-generated Tools</h2>
41
+ <p class="text-xs text-gray-400 mt-0.5">
42
+ These tools are derived from the lifecycle definition and exposed to any Gemlings agent.
43
+ The agent can <strong>only</strong> act through these tools — bounded autonomy.
44
+ </p>
45
+ </div>
46
+
47
+ <% read_tools = @tools.select { |t| t[:category] == :read } %>
48
+ <% mutate_tools = @tools.select { |t| t[:category] == :mutate } %>
49
+
50
+ <div class="space-y-3">
51
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Read tools (safe, no state change)</p>
52
+ <% read_tools.each do |tool| %>
53
+ <div class="flex items-start gap-3 text-sm border-l-2 border-blue-200 pl-3 py-1">
54
+ <div class="flex-1">
55
+ <code class="font-mono text-blue-700 text-xs"><%= tool[:name] %></code>
56
+ <p class="text-gray-500 text-xs mt-0.5"><%= tool[:description] %></p>
57
+ </div>
58
+ <div class="text-xs text-gray-400 font-mono shrink-0">
59
+ <% tool[:params].each do |k, v| %>
60
+ <span><%= k %>: <em><%= v %></em></span>
61
+ <% end %>
62
+ </div>
63
+ </div>
64
+ <% end %>
65
+ </div>
66
+
67
+ <div class="space-y-3">
68
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Mutate tools (fire lifecycle events)</p>
69
+ <% mutate_tools.each do |tool| %>
70
+ <div class="flex items-start gap-3 text-sm border-l-2 border-orange-200 pl-3 py-1">
71
+ <div class="flex-1">
72
+ <code class="font-mono text-orange-700 text-xs"><%= tool[:name] %></code>
73
+ <p class="text-gray-500 text-xs mt-0.5"><%= tool[:description] %></p>
74
+ </div>
75
+ <div class="text-xs text-gray-400 font-mono shrink-0">id: <em>Record ID</em></div>
76
+ </div>
77
+ <% end %>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Direct Tool Tester -->
82
+ <div class="bg-white border border-gray-200 rounded-lg p-5 space-y-4">
83
+ <div>
84
+ <h2 class="font-semibold text-gray-900">Tool Tester</h2>
85
+ <p class="text-xs text-gray-400 mt-0.5">Invoke tools directly — no Gemlings or LLM required. Results show exactly what the agent would receive.</p>
86
+ </div>
87
+
88
+ <div class="flex gap-3 flex-wrap items-end">
89
+ <div>
90
+ <label class="block text-xs text-gray-500 mb-1">Tool</label>
91
+ <select id="tool-select" class="text-sm border border-gray-200 rounded px-2 py-1.5 min-w-64">
92
+ <optgroup label="Read tools">
93
+ <% @tools.select { |t| t[:category] == :read }.each do |tool| %>
94
+ <option value="<%= tool[:name] %>" data-needs-id="<%= tool[:requires_id] %>">
95
+ <%= tool[:name] %>
96
+ </option>
97
+ <% end %>
98
+ </optgroup>
99
+ <optgroup label="Mutate tools (fire events)">
100
+ <% @tools.select { |t| t[:category] == :mutate }.each do |tool| %>
101
+ <option value="<%= tool[:name] %>" data-needs-id="true">
102
+ <%= tool[:name] %>
103
+ </option>
104
+ <% end %>
105
+ </optgroup>
106
+ </select>
107
+ </div>
108
+
109
+ <div id="id-field">
110
+ <label class="block text-xs text-gray-500 mb-1">Record ID / State filter</label>
111
+ <input id="record-id" type="text" placeholder="e.g. 1 or 'draft'"
112
+ class="text-sm border border-gray-200 rounded px-2 py-1.5 w-40" />
113
+ </div>
114
+
115
+ <button id="invoke-btn" onclick="invokeTool()"
116
+ class="text-sm bg-gray-900 text-white px-4 py-1.5 rounded hover:bg-gray-700">
117
+ Run Tool
118
+ </button>
119
+ </div>
120
+
121
+ <div id="tool-result" class="hidden">
122
+ <p class="text-xs text-gray-500 mb-1">Result</p>
123
+ <pre id="result-content" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs font-mono overflow-auto max-h-64"></pre>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- System Prompt -->
128
+ <div class="bg-white border border-gray-200 rounded-lg p-5 space-y-3">
129
+ <h2 class="font-semibold text-gray-900">System Prompt</h2>
130
+ <p class="text-xs text-gray-400">This is the system instruction injected into the LLM when using <code>build_agent</code>. It communicates FOSM constraints so the agent respects the state machine.</p>
131
+ <pre class="bg-gray-50 border border-gray-200 rounded p-3 text-xs font-mono whitespace-pre-wrap"><%= @system_prompt %></pre>
132
+ </div>
133
+ </div>
134
+
135
+ <script>
136
+ async function invokeTool() {
137
+ const tool = document.getElementById('tool-select').value;
138
+ const id = document.getElementById('record-id').value;
139
+ const btn = document.getElementById('invoke-btn');
140
+ const resultBox = document.getElementById('tool-result');
141
+ const resultContent = document.getElementById('result-content');
142
+
143
+ btn.disabled = true;
144
+ btn.textContent = 'Running…';
145
+
146
+ try {
147
+ const resp = await fetch('<%= fosm.agent_invoke_admin_app_path(@slug) %>', {
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
152
+ },
153
+ body: JSON.stringify({ tool, record_id: id, filter: id })
154
+ });
155
+ const data = await resp.json();
156
+ resultContent.textContent = JSON.stringify(data, null, 2);
157
+ resultBox.classList.remove('hidden');
158
+ } catch(e) {
159
+ resultContent.textContent = 'Error: ' + e.message;
160
+ resultBox.classList.remove('hidden');
161
+ } finally {
162
+ btn.disabled = false;
163
+ btn.textContent = 'Run Tool';
164
+ }
165
+ }
166
+ </script>
@@ -0,0 +1,114 @@
1
+ <div class="p-6 space-y-8">
2
+ <div class="flex items-center gap-3">
3
+ <%= link_to "← FOSM Admin", fosm.admin_root_path, class: "text-sm text-blue-600 hover:underline" %>
4
+ </div>
5
+
6
+ <div class="flex items-start justify-between">
7
+ <div>
8
+ <h1 class="text-2xl font-bold text-gray-900"><%= @model_class.name.demodulize.humanize %></h1>
9
+ <p class="text-sm text-gray-500"><%= @model_class.name %> &middot; <%= @total %> total records</p>
10
+ <% if @stuck_count > 0 %>
11
+ <p class="text-sm text-amber-600 mt-1">&#x26A0; <%= @stuck_count %> record<%= @stuck_count == 1 ? "" : "s" %> may be stuck (no transition in 7+ days)</p>
12
+ <% end %>
13
+ </div>
14
+ <div class="flex gap-2 shrink-0">
15
+ <%= link_to "View all #{@model_class.name.demodulize.humanize.pluralize}",
16
+ main_app.send("fosm_#{@slug.pluralize}_path"),
17
+ class: "text-sm border border-gray-200 bg-white text-gray-700 px-3 py-1.5 rounded hover:bg-gray-50" %>
18
+ <%= link_to "+ New #{@model_class.name.demodulize.humanize}",
19
+ main_app.send("new_fosm_#{@slug}_path"),
20
+ class: "text-sm bg-gray-900 text-white px-3 py-1.5 rounded hover:bg-gray-700" %>
21
+ <%= link_to "Agent Tools",
22
+ fosm.agent_admin_app_path(@slug),
23
+ class: "text-sm border border-purple-200 bg-purple-50 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-100" %>
24
+ </div>
25
+ </div>
26
+
27
+ <!-- State distribution -->
28
+ <div class="bg-white border border-gray-200 rounded-lg p-5">
29
+ <h2 class="font-semibold text-gray-900 mb-4">State Distribution</h2>
30
+ <div class="space-y-2">
31
+ <% @state_counts.each do |state_name, count| %>
32
+ <% state_def = @lifecycle.find_state(state_name) %>
33
+ <div class="flex items-center gap-3">
34
+ <span class="w-28 text-sm text-gray-600 flex items-center gap-1">
35
+ <%= state_name %>
36
+ <% if state_def&.initial? %><span class="text-xs text-blue-500">(initial)</span><% end %>
37
+ <% if state_def&.terminal? %><span class="text-xs text-red-500">(terminal)</span><% end %>
38
+ </span>
39
+ <div class="flex-1 h-2 bg-gray-100 rounded">
40
+ <% pct = @total > 0 ? (count.to_f / @total * 100).round : 0 %>
41
+ <div class="h-2 bg-blue-500 rounded" style="width: <%= pct %>%"></div>
42
+ </div>
43
+ <span class="text-sm font-medium text-gray-900 w-8 text-right"><%= count %></span>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ </div>
48
+
49
+ <!-- Lifecycle definition -->
50
+ <div class="bg-white border border-gray-200 rounded-lg p-5">
51
+ <h2 class="font-semibold text-gray-900 mb-4">Lifecycle Definition</h2>
52
+ <table class="w-full text-sm">
53
+ <thead class="text-gray-500 text-xs uppercase border-b">
54
+ <tr>
55
+ <th class="pb-2 text-left">Event</th>
56
+ <th class="pb-2 text-left">From</th>
57
+ <th class="pb-2 text-left">To</th>
58
+ <th class="pb-2 text-left">Guards</th>
59
+ <th class="pb-2 text-left">Side Effects</th>
60
+ </tr>
61
+ </thead>
62
+ <tbody class="divide-y divide-gray-50">
63
+ <% @lifecycle.events.each do |event| %>
64
+ <tr>
65
+ <td class="py-2 font-medium text-gray-900"><%= event.name %></td>
66
+ <td class="py-2 text-orange-600"><%= event.from_states.join(", ") %></td>
67
+ <td class="py-2 text-green-600"><%= event.to_state %></td>
68
+ <td class="py-2 text-gray-500"><%= event.guards.map(&:name).join(", ").presence || "—" %></td>
69
+ <td class="py-2 text-gray-500"><%= event.side_effects.map(&:name).join(", ").presence || "—" %></td>
70
+ </tr>
71
+ <% end %>
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+
76
+ <!-- Recent transitions -->
77
+ <div class="bg-white border border-gray-200 rounded-lg p-5">
78
+ <h2 class="font-semibold text-gray-900 mb-4">Recent Transitions</h2>
79
+ <% if @recent_transitions.any? %>
80
+ <table class="w-full text-sm">
81
+ <thead class="text-gray-500 text-xs uppercase border-b">
82
+ <tr>
83
+ <th class="pb-2 text-left">Record ID</th>
84
+ <th class="pb-2 text-left">Event</th>
85
+ <th class="pb-2 text-left">From &rarr; To</th>
86
+ <th class="pb-2 text-left">Actor</th>
87
+ <th class="pb-2 text-left">When</th>
88
+ </tr>
89
+ </thead>
90
+ <tbody class="divide-y divide-gray-50">
91
+ <% @recent_transitions.each do |t| %>
92
+ <tr>
93
+ <td class="py-2 text-gray-600">
94
+ <%= link_to "##{t.record_id}",
95
+ main_app.send("fosm_#{@slug}_path", t.record_id),
96
+ class: "text-blue-600 hover:underline" %>
97
+ </td>
98
+ <td class="py-2 font-medium"><%= t.event_name %></td>
99
+ <td class="py-2">
100
+ <span class="text-orange-600"><%= t.from_state %></span>
101
+ <span class="text-gray-400"> &rarr; </span>
102
+ <span class="text-green-600"><%= t.to_state %></span>
103
+ </td>
104
+ <td class="py-2 text-gray-500"><%= t.actor_label || t.actor_type || "—" %></td>
105
+ <td class="py-2 text-gray-400"><%= t.created_at.strftime("%b %d %H:%M") %></td>
106
+ </tr>
107
+ <% end %>
108
+ </tbody>
109
+ </table>
110
+ <% else %>
111
+ <p class="text-sm text-gray-400">No transitions recorded yet.</p>
112
+ <% end %>
113
+ </div>
114
+ </div>
@@ -0,0 +1,82 @@
1
+ <div class="p-6 space-y-8">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">FOSM Admin</h1>
4
+ <p class="text-sm text-gray-500 mt-1">
5
+ <%= @total_transitions %> total transitions logged &middot;
6
+ <%= @apps.size %> registered app<%= @apps.size == 1 ? "" : "s" %>
7
+ </p>
8
+ </div>
9
+
10
+ <% if @apps.empty? %>
11
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
12
+ <h2 class="font-semibold text-gray-700">No FOSM apps registered</h2>
13
+ <p class="text-sm text-gray-500 mt-2">
14
+ Run <code class="bg-gray-100 px-1 rounded">rails generate fosm:app my_app</code> to create one.
15
+ </p>
16
+ </div>
17
+ <% else %>
18
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
19
+ <% @apps.each do |app| %>
20
+ <div class="bg-white border border-gray-200 rounded-lg p-5 space-y-4">
21
+ <div class="flex items-center justify-between">
22
+ <h2 class="font-semibold text-gray-900"><%= app[:name] %></h2>
23
+ <span class="text-xs text-gray-400"><%= app[:total] %> records</span>
24
+ </div>
25
+
26
+ <div class="space-y-1">
27
+ <% app[:state_counts].each do |state, count| %>
28
+ <div class="flex items-center justify-between text-sm">
29
+ <span class="text-gray-600"><%= state %></span>
30
+ <span class="font-medium text-gray-900">
31
+ <%= count %>
32
+ <% if app[:total] > 0 %>
33
+ <span class="text-gray-400 font-normal">(<%= (count.to_f / app[:total] * 100).round %>%)</span>
34
+ <% end %>
35
+ </span>
36
+ </div>
37
+ <div class="h-1 bg-gray-100 rounded">
38
+ <% pct = app[:total] > 0 ? (count.to_f / app[:total] * 100).round : 0 %>
39
+ <div class="h-1 bg-blue-500 rounded" style="width: <%= pct %>%"></div>
40
+ </div>
41
+ <% end %>
42
+ </div>
43
+
44
+ <%= link_to "View Details →", fosm.admin_app_path(app[:slug]), class: "text-sm text-blue-600 hover:underline" %>
45
+ </div>
46
+ <% end %>
47
+ </div>
48
+
49
+ <div>
50
+ <h2 class="text-lg font-semibold text-gray-900 mb-3">Recent Transitions</h2>
51
+ <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
52
+ <table class="w-full text-sm">
53
+ <thead class="bg-gray-50 text-gray-500 text-xs uppercase">
54
+ <tr>
55
+ <th class="px-4 py-2 text-left">Record</th>
56
+ <th class="px-4 py-2 text-left">Event</th>
57
+ <th class="px-4 py-2 text-left">From &rarr; To</th>
58
+ <th class="px-4 py-2 text-left">Actor</th>
59
+ <th class="px-4 py-2 text-left">When</th>
60
+ </tr>
61
+ </thead>
62
+ <tbody class="divide-y divide-gray-100">
63
+ <% @recent_transitions.each do |t| %>
64
+ <tr>
65
+ <td class="px-4 py-2 text-gray-600"><%= t.record_type.demodulize %> #<%= t.record_id %></td>
66
+ <td class="px-4 py-2 font-medium text-gray-900"><%= t.event_name %></td>
67
+ <td class="px-4 py-2 text-gray-600">
68
+ <span class="text-orange-600"><%= t.from_state %></span>
69
+ <span class="text-gray-400"> &rarr; </span>
70
+ <span class="text-green-600"><%= t.to_state %></span>
71
+ </td>
72
+ <td class="px-4 py-2 text-gray-500"><%= t.actor_label || t.actor_type || "—" %></td>
73
+ <td class="px-4 py-2 text-gray-400 text-xs"><%= t.created_at.strftime("%b %d %H:%M") %></td>
74
+ </tr>
75
+ <% end %>
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+ <%= link_to "View all transitions →", fosm.admin_transitions_path, class: "text-sm text-blue-600 hover:underline mt-2 block" %>
80
+ </div>
81
+ <% end %>
82
+ </div>
@@ -0,0 +1,63 @@
1
+ <div class="p-6 space-y-6">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">FOSM Settings</h1>
4
+ <p class="text-sm text-gray-500 mt-1">Runtime configuration for the FOSM engine. Edit <code class="bg-gray-100 px-1 rounded text-xs">config/initializers/fosm.rb</code> to change these.</p>
5
+ </div>
6
+
7
+ <!-- LLM Provider Settings -->
8
+ <div class="bg-white border border-gray-200 rounded-lg p-5 space-y-4">
9
+ <div>
10
+ <h2 class="font-semibold text-gray-900">LLM Provider Settings</h2>
11
+ <p class="text-xs text-gray-400 mt-0.5">
12
+ API keys read from environment variables at runtime. Keys are never stored or displayed —
13
+ only presence is shown. Required for <code class="bg-gray-100 px-0.5 rounded">Fosm::Agent#build_agent</code>.
14
+ </p>
15
+ </div>
16
+
17
+ <div class="divide-y divide-gray-100">
18
+ <% @llm_providers.each do |provider| %>
19
+ <div class="flex items-center justify-between py-3">
20
+ <div class="flex items-center gap-3">
21
+ <% if provider[:configured] %>
22
+ <span class="w-2 h-2 rounded-full bg-green-400 shrink-0"></span>
23
+ <% else %>
24
+ <span class="w-2 h-2 rounded-full bg-gray-200 shrink-0"></span>
25
+ <% end %>
26
+ <div>
27
+ <p class="text-sm font-medium text-gray-800"><%= provider[:name] %></p>
28
+ <p class="text-xs text-gray-400 font-mono"><%= provider[:env_key] %></p>
29
+ </div>
30
+ </div>
31
+ <div class="text-right">
32
+ <% if provider[:configured] %>
33
+ <span class="text-xs font-medium text-green-700 bg-green-50 border border-green-200 px-2 py-0.5 rounded">Configured</span>
34
+ <p class="text-xs text-gray-400 mt-0.5"><%= provider[:hint] %></p>
35
+ <% else %>
36
+ <span class="text-xs text-gray-400 bg-gray-50 border border-gray-200 px-2 py-0.5 rounded">Not set</span>
37
+ <p class="text-xs text-gray-300 mt-0.5">Set <code class="bg-gray-50 px-0.5"><%= provider[:env_key] %></code> to enable</p>
38
+ <% end %>
39
+ </div>
40
+ </div>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Engine Configuration -->
46
+ <div class="bg-white border border-gray-200 rounded-lg p-5 space-y-4">
47
+ <div>
48
+ <h2 class="font-semibold text-gray-900">Engine Configuration</h2>
49
+ <p class="text-xs text-gray-400 mt-0.5">Active <code class="bg-gray-100 px-0.5 rounded">Fosm.config</code> values at runtime.</p>
50
+ </div>
51
+
52
+ <table class="w-full text-sm">
53
+ <tbody class="divide-y divide-gray-50">
54
+ <% @fosm_config.each do |key, value| %>
55
+ <tr>
56
+ <td class="py-2 pr-4 font-mono text-xs text-gray-500 w-48"><%= key %></td>
57
+ <td class="py-2 font-mono text-xs text-gray-800 break-all"><%= value %></td>
58
+ </tr>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+ </div>
@@ -0,0 +1,65 @@
1
+ <div class="p-6 space-y-6">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Transition Log</h1>
4
+ <p class="text-sm text-gray-500 mt-1">Complete audit trail of every FOSM state transition</p>
5
+ </div>
6
+
7
+ <!-- Filters -->
8
+ <%= form_with url: fosm.admin_transitions_path, method: :get, class: "flex gap-3 flex-wrap" do |f| %>
9
+ <%= f.select :model, [["All apps", nil]] + @model_names.map { |n| [n.demodulize, n] },
10
+ { selected: params[:model] }, class: "text-sm border border-gray-200 rounded px-2 py-1" %>
11
+ <%= f.select :actor, [["All actors", nil], ["Human", "human"], ["AI Agent", "agent"]],
12
+ { selected: params[:actor] }, class: "text-sm border border-gray-200 rounded px-2 py-1" %>
13
+ <%= f.submit "Filter", class: "text-sm bg-gray-800 text-white px-3 py-1 rounded hover:bg-gray-700" %>
14
+ <%= link_to "Clear", fosm.admin_transitions_path, class: "text-sm text-gray-500 py-1" %>
15
+ <% end %>
16
+
17
+ <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
18
+ <table class="w-full text-sm">
19
+ <thead class="bg-gray-50 text-gray-500 text-xs uppercase">
20
+ <tr>
21
+ <th class="px-4 py-2 text-left">Record</th>
22
+ <th class="px-4 py-2 text-left">Event</th>
23
+ <th class="px-4 py-2 text-left">From &rarr; To</th>
24
+ <th class="px-4 py-2 text-left">Actor</th>
25
+ <th class="px-4 py-2 text-left">When</th>
26
+ </tr>
27
+ </thead>
28
+ <tbody class="divide-y divide-gray-100">
29
+ <% @transitions.each do |t| %>
30
+ <tr>
31
+ <td class="px-4 py-2 text-gray-600">
32
+ <%= t.record_type.demodulize %> #<%= t.record_id %>
33
+ </td>
34
+ <td class="px-4 py-2 font-medium text-gray-900"><%= t.event_name %></td>
35
+ <td class="px-4 py-2">
36
+ <span class="text-orange-600"><%= t.from_state %></span>
37
+ <span class="text-gray-400"> &rarr; </span>
38
+ <span class="text-green-600"><%= t.to_state %></span>
39
+ </td>
40
+ <td class="px-4 py-2">
41
+ <% if t.by_agent? %>
42
+ <span class="text-purple-600 text-xs font-medium bg-purple-50 px-2 py-0.5 rounded">AI Agent</span>
43
+ <% else %>
44
+ <span class="text-gray-500"><%= t.actor_label || t.actor_type || "—" %></span>
45
+ <% end %>
46
+ </td>
47
+ <td class="px-4 py-2 text-gray-400 text-xs"><%= t.created_at.strftime("%b %d %Y %H:%M:%S") %></td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+
54
+ <% if @total_pages > 1 %>
55
+ <div class="flex items-center gap-3 text-sm text-gray-600">
56
+ <% if @current_page > 1 %>
57
+ <%= link_to "← Prev", fosm.admin_transitions_path(params.permit(:model, :event, :actor).merge(page: @current_page - 1)), class: "text-blue-600 hover:underline" %>
58
+ <% end %>
59
+ <span>Page <%= @current_page %> of <%= @total_pages %> (<%= @total_count %> total)</span>
60
+ <% if @current_page < @total_pages %>
61
+ <%= link_to "Next →", fosm.admin_transitions_path(params.permit(:model, :event, :actor).merge(page: @current_page + 1)), class: "text-blue-600 hover:underline" %>
62
+ <% end %>
63
+ </div>
64
+ <% end %>
65
+ </div>
@@ -0,0 +1,51 @@
1
+ <div class="p-6 space-y-6">
2
+ <div class="flex items-center justify-between">
3
+ <div>
4
+ <h1 class="text-2xl font-bold text-gray-900">Webhooks</h1>
5
+ <p class="text-sm text-gray-500 mt-1">Configure HTTP callbacks for FOSM transitions</p>
6
+ </div>
7
+ <%= link_to "Add Webhook", fosm.new_admin_webhook_path, class: "text-sm bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" %>
8
+ </div>
9
+
10
+ <% if @webhooks.empty? %>
11
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
12
+ <p class="text-gray-600 font-medium">No webhooks configured</p>
13
+ <p class="text-sm text-gray-500 mt-1">Add a webhook to receive HTTP notifications when FOSM events fire.</p>
14
+ </div>
15
+ <% else %>
16
+ <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
17
+ <table class="w-full text-sm">
18
+ <thead class="bg-gray-50 text-gray-500 text-xs uppercase">
19
+ <tr>
20
+ <th class="px-4 py-2 text-left">App</th>
21
+ <th class="px-4 py-2 text-left">Event</th>
22
+ <th class="px-4 py-2 text-left">URL</th>
23
+ <th class="px-4 py-2 text-left">Status</th>
24
+ <th class="px-4 py-2 text-left">Actions</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody class="divide-y divide-gray-100">
28
+ <% @webhooks.each do |webhook| %>
29
+ <tr>
30
+ <td class="px-4 py-2 text-gray-600"><%= webhook.model_class_name.demodulize %></td>
31
+ <td class="px-4 py-2 font-medium"><%= webhook.event_name %></td>
32
+ <td class="px-4 py-2 text-gray-600 font-mono text-xs truncate max-w-xs"><%= webhook.url %></td>
33
+ <td class="px-4 py-2">
34
+ <% if webhook.active? %>
35
+ <span class="text-green-600 text-xs font-medium">Active</span>
36
+ <% else %>
37
+ <span class="text-gray-400 text-xs">Disabled</span>
38
+ <% end %>
39
+ </td>
40
+ <td class="px-4 py-2">
41
+ <%= button_to "Delete", fosm.admin_webhook_path(webhook), method: :delete,
42
+ data: { confirm: "Delete this webhook?" },
43
+ class: "text-xs text-red-600 hover:underline" %>
44
+ </td>
45
+ </tr>
46
+ <% end %>
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+ <% end %>
51
+ </div>
@@ -0,0 +1,45 @@
1
+ <div class="p-6 max-w-lg space-y-6">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Add Webhook</h1>
4
+ <p class="text-sm text-gray-500 mt-1">FOSM will POST a JSON payload to this URL when the event fires.</p>
5
+ </div>
6
+
7
+ <%= form_with model: @webhook, url: fosm.admin_webhooks_path, class: "space-y-4" do |f| %>
8
+ <div>
9
+ <label class="block text-sm font-medium text-gray-700 mb-1">FOSM App</label>
10
+ <%= f.select :model_class_name,
11
+ @apps.map { |slug, klass| [klass.name.demodulize.humanize, klass.name] },
12
+ { prompt: "Select an app" },
13
+ class: "w-full border border-gray-200 rounded px-3 py-2 text-sm" %>
14
+ </div>
15
+
16
+ <div>
17
+ <label class="block text-sm font-medium text-gray-700 mb-1">Event</label>
18
+ <%= f.text_field :event_name, placeholder: "e.g. send_invoice, pay, cancel",
19
+ class: "w-full border border-gray-200 rounded px-3 py-2 text-sm" %>
20
+ </div>
21
+
22
+ <div>
23
+ <label class="block text-sm font-medium text-gray-700 mb-1">Webhook URL</label>
24
+ <%= f.url_field :url, placeholder: "https://your-app.com/webhooks/fosm",
25
+ class: "w-full border border-gray-200 rounded px-3 py-2 text-sm" %>
26
+ </div>
27
+
28
+ <div>
29
+ <label class="block text-sm font-medium text-gray-700 mb-1">Secret Token (optional)</label>
30
+ <%= f.text_field :secret_token, placeholder: "Used for X-FOSM-Signature HMAC verification",
31
+ class: "w-full border border-gray-200 rounded px-3 py-2 text-sm" %>
32
+ <p class="text-xs text-gray-500 mt-1">FOSM will include a HMAC-SHA256 signature header if set.</p>
33
+ </div>
34
+
35
+ <div class="flex items-center gap-2">
36
+ <%= f.check_box :active, class: "rounded" %>
37
+ <label class="text-sm text-gray-700">Active</label>
38
+ </div>
39
+
40
+ <div class="flex gap-3 pt-2">
41
+ <%= f.submit "Create Webhook", class: "bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700" %>
42
+ <%= link_to "Cancel", fosm.admin_webhooks_path, class: "text-sm text-gray-500 py-2" %>
43
+ </div>
44
+ <% end %>
45
+ </div>
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>FOSM Admin</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta charset="utf-8">
7
+ <%= csrf_meta_tags %>
8
+ <%= csp_meta_tag %>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ </head>
11
+ <body class="bg-gray-50 font-sans">
12
+ <div class="min-h-screen flex">
13
+ <!-- Sidebar -->
14
+ <div class="w-48 bg-white border-r border-gray-200 flex flex-col">
15
+ <div class="p-4 border-b border-gray-200">
16
+ <span class="font-bold text-gray-900 text-sm">FOSM</span>
17
+ <span class="text-xs text-gray-400 block">Admin</span>
18
+ </div>
19
+ <nav class="p-3 space-y-1 flex-1">
20
+ <%= link_to "Dashboard", fosm.admin_root_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
21
+ <%= link_to "Transitions", fosm.admin_transitions_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
22
+ <%= link_to "Webhooks", fosm.admin_webhooks_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
23
+ </nav>
24
+ <div class="p-3 border-t border-gray-100">
25
+ <%= link_to "Settings", fosm.admin_settings_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Main content -->
30
+ <div class="flex-1 overflow-auto">
31
+ <% if notice.present? %>
32
+ <div class="bg-green-50 border-b border-green-200 px-6 py-3 text-sm text-green-700"><%= notice %></div>
33
+ <% end %>
34
+ <% if alert.present? %>
35
+ <div class="bg-red-50 border-b border-red-200 px-6 py-3 text-sm text-red-700"><%= alert %></div>
36
+ <% end %>
37
+ <%= yield %>
38
+ </div>
39
+ </div>
40
+ </body>
41
+ </html>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Fosm rails</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "fosm/rails/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>