solid_loop 0.0.4

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 (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +156 -0
  3. data/CODE_OF_CONDUCT.md +67 -0
  4. data/CONTRIBUTING.md +82 -0
  5. data/MIT-LICENSE +21 -0
  6. data/README.md +483 -0
  7. data/Rakefile +11 -0
  8. data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
  9. data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
  10. data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
  11. data/app/assets/stylesheets/solid_loop/application.css +15 -0
  12. data/app/controllers/solid_loop/application_controller.rb +29 -0
  13. data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
  14. data/app/controllers/solid_loop/events_controller.rb +12 -0
  15. data/app/controllers/solid_loop/loops_controller.rb +77 -0
  16. data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
  17. data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
  18. data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
  19. data/app/controllers/solid_loop/messages_controller.rb +12 -0
  20. data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
  21. data/app/helpers/solid_loop/application_helper.rb +125 -0
  22. data/app/jobs/solid_loop/application_job.rb +13 -0
  23. data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
  24. data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
  25. data/app/jobs/solid_loop/reaper_job.rb +20 -0
  26. data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
  27. data/app/models/solid_loop/application_record.rb +5 -0
  28. data/app/models/solid_loop/base.rb +190 -0
  29. data/app/models/solid_loop/event.rb +7 -0
  30. data/app/models/solid_loop/loop.rb +225 -0
  31. data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
  32. data/app/models/solid_loop/mcp_session.rb +12 -0
  33. data/app/models/solid_loop/mcp_tool.rb +26 -0
  34. data/app/models/solid_loop/message.rb +69 -0
  35. data/app/models/solid_loop/tool_call.rb +42 -0
  36. data/app/queries/solid_loop/admin/base_query.rb +23 -0
  37. data/app/queries/solid_loop/admin/events_query.rb +31 -0
  38. data/app/queries/solid_loop/admin/loops_query.rb +38 -0
  39. data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
  40. data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
  41. data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
  42. data/app/queries/solid_loop/admin/messages_query.rb +37 -0
  43. data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
  44. data/app/services/solid_loop/adapters/native.rb +170 -0
  45. data/app/services/solid_loop/dialects/anthropic.rb +160 -0
  46. data/app/services/solid_loop/dialects/gemini.rb +122 -0
  47. data/app/services/solid_loop/dialects/open_ai.rb +62 -0
  48. data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
  49. data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
  50. data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
  51. data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
  52. data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
  53. data/app/services/solid_loop/llm_usage_parser.rb +19 -0
  54. data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
  55. data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
  56. data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
  57. data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
  58. data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
  59. data/app/services/solid_loop/middlewares/message_building.rb +92 -0
  60. data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
  61. data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
  62. data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
  63. data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
  64. data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
  65. data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
  66. data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
  67. data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
  68. data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
  69. data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
  70. data/app/views/layouts/solid_loop/admin.html.erb +416 -0
  71. data/app/views/layouts/solid_loop/application.html.erb +17 -0
  72. data/app/views/solid_loop/dashboard/index.html.erb +184 -0
  73. data/app/views/solid_loop/events/index.html.erb +47 -0
  74. data/app/views/solid_loop/events/show.html.erb +76 -0
  75. data/app/views/solid_loop/loops/index.html.erb +83 -0
  76. data/app/views/solid_loop/loops/show.html.erb +148 -0
  77. data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
  78. data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
  79. data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
  80. data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
  81. data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
  82. data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
  83. data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
  84. data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
  85. data/app/views/solid_loop/messages/_message.html.erb +75 -0
  86. data/app/views/solid_loop/messages/index.html.erb +80 -0
  87. data/app/views/solid_loop/messages/show.html.erb +121 -0
  88. data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
  89. data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
  90. data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
  91. data/config/routes.rb +27 -0
  92. data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
  93. data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
  94. data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
  95. data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
  96. data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
  97. data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
  98. data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
  99. data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
  100. data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
  101. data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
  102. data/docs/contributing/coverage.md +64 -0
  103. data/docs/decisions/durable_attempt_lease.md +364 -0
  104. data/docs/decisions/mcp-only-tooling.md +135 -0
  105. data/docs/decisions/mcp-server.md +223 -0
  106. data/docs/decisions/reasoning_persistence.md +51 -0
  107. data/docs/decisions/ruby_llm_rejected.md +35 -0
  108. data/docs/guides/dialects.md +55 -0
  109. data/docs/guides/llm_middlewares.md +294 -0
  110. data/docs/guides/mcp_transports.md +374 -0
  111. data/docs/guides/tool_middlewares.md +148 -0
  112. data/lib/solid_loop/configuration.rb +175 -0
  113. data/lib/solid_loop/engine.rb +14 -0
  114. data/lib/solid_loop/lease_heartbeat.rb +94 -0
  115. data/lib/solid_loop/lease_renewer.rb +347 -0
  116. data/lib/solid_loop/llm_metrics.rb +16 -0
  117. data/lib/solid_loop/mcp/call_context.rb +19 -0
  118. data/lib/solid_loop/mcp/client_factory.rb +61 -0
  119. data/lib/solid_loop/mcp/http_transport.rb +52 -0
  120. data/lib/solid_loop/mcp/principal.rb +82 -0
  121. data/lib/solid_loop/mcp/result.rb +13 -0
  122. data/lib/solid_loop/mcp/server.rb +246 -0
  123. data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
  124. data/lib/solid_loop/mcp/toolset.rb +347 -0
  125. data/lib/solid_loop/mcp/transport.rb +20 -0
  126. data/lib/solid_loop/mcp.rb +25 -0
  127. data/lib/solid_loop/mcp_client.rb +176 -0
  128. data/lib/solid_loop/pipeline/builder.rb +38 -0
  129. data/lib/solid_loop/pipeline/context.rb +61 -0
  130. data/lib/solid_loop/pipeline/tool_context.rb +53 -0
  131. data/lib/solid_loop/pipeline.rb +40 -0
  132. data/lib/solid_loop/reaper.rb +313 -0
  133. data/lib/solid_loop/version.rb +3 -0
  134. data/lib/solid_loop.rb +94 -0
  135. data/lib/tasks/coverage.rake +206 -0
  136. data/lib/tasks/solid_loop_tasks.rake +4 -0
  137. metadata +228 -0
@@ -0,0 +1,416 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>SolidLoop Admin</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <%= javascript_include_tag "solid_loop/chart.umd.min" %>
10
+ <%= javascript_include_tag "solid_loop/chartjs-adapter-date-fns.bundle.min" %>
11
+ <%= javascript_include_tag "solid_loop/chartkick.min" %>
12
+
13
+ <% if defined?(Turbo) %>
14
+ <%= javascript_import_module_tag "application" rescue nil %>
15
+ <% end %>
16
+
17
+ <style>
18
+ :root {
19
+ --sl-bg-color: #0e1621;
20
+ --sl-sidebar-bg: #17212b;
21
+ --sl-hover-bg: #202b36;
22
+ --sl-border-color: #101921;
23
+ --sl-text-color: #f5f5f5;
24
+ --sl-muted-text: #7f91a4;
25
+ --sl-msg-in: #182533;
26
+ --sl-msg-out: #2b5278;
27
+ --sl-accent: #5288c1;
28
+ --sl-success: #31b545;
29
+ --sl-error: #ff6b6b;
30
+ --sl-pending: #f1c40f;
31
+ --sl-radius: 12px;
32
+ }
33
+
34
+ * { box-sizing: border-box; margin: 0; padding: 0; }
35
+
36
+ body {
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
38
+ background-color: var(--sl-bg-color);
39
+ color: var(--sl-text-color);
40
+ height: 100vh;
41
+ display: flex;
42
+ flex-direction: column;
43
+ overflow: hidden;
44
+ }
45
+
46
+ /* Navbar Block */
47
+ .sl-navbar {
48
+ height: 60px;
49
+ background-color: var(--sl-sidebar-bg);
50
+ border-bottom: 1px solid var(--sl-border-color);
51
+ display: flex;
52
+ align-items: center;
53
+ padding: 0 20px;
54
+ gap: 30px;
55
+ flex-shrink: 0;
56
+ }
57
+
58
+ .sl-navbar__brand {
59
+ font-weight: bold;
60
+ font-size: 1.2em;
61
+ color: var(--sl-accent);
62
+ text-decoration: none;
63
+ }
64
+
65
+ .sl-navbar__links {
66
+ display: flex;
67
+ gap: 20px;
68
+ }
69
+
70
+ .sl-navbar__link {
71
+ color: var(--sl-muted-text);
72
+ text-decoration: none;
73
+ font-size: 0.95em;
74
+ transition: color 0.2s;
75
+ }
76
+
77
+ .sl-navbar__link:hover,
78
+ .sl-navbar__link--active {
79
+ color: var(--sl-text-color);
80
+ }
81
+
82
+ /* App Container Block */
83
+ .sl-app-container {
84
+ display: flex;
85
+ flex: 1;
86
+ overflow: hidden;
87
+ }
88
+
89
+ .sl-content-area {
90
+ flex: 1;
91
+ overflow-y: auto;
92
+ padding: 20px;
93
+ }
94
+
95
+ /* Card Block */
96
+ .sl-card {
97
+ background-color: var(--sl-sidebar-bg);
98
+ border-radius: var(--sl-radius);
99
+ padding: 20px;
100
+ margin-bottom: 20px;
101
+ border: 1px solid var(--sl-border-color);
102
+ }
103
+
104
+ /* Table Block */
105
+ .sl-table {
106
+ width: 100%;
107
+ border-collapse: collapse;
108
+ margin-top: 10px;
109
+ }
110
+
111
+ .sl-table__th,
112
+ .sl-table__td {
113
+ padding: 12px;
114
+ text-align: left;
115
+ border-bottom: 1px solid var(--sl-border-color);
116
+ }
117
+
118
+ .sl-table__th {
119
+ color: var(--sl-muted-text);
120
+ font-weight: 500;
121
+ font-size: 0.9em;
122
+ text-transform: uppercase;
123
+ }
124
+
125
+ /* Badge Block */
126
+ .sl-badge {
127
+ padding: 4px 8px;
128
+ border-radius: 4px;
129
+ font-size: 0.8em;
130
+ font-weight: 600;
131
+ display: inline-block;
132
+ }
133
+
134
+ .sl-badge--pending, .sl-badge--queued { background-color: rgba(241, 196, 15, 0.2); color: var(--sl-pending); }
135
+ .sl-badge--processing, .sl-badge--running { background-color: rgba(82, 136, 193, 0.2); color: var(--sl-accent); }
136
+ .sl-badge--completed, .sl-badge--success { background-color: rgba(49, 181, 69, 0.2); color: var(--sl-success); }
137
+ .sl-badge--failed, .sl-badge--error { background-color: rgba(255, 107, 107, 0.2); color: var(--sl-error); }
138
+ .sl-badge--paused, .sl-badge--frozen { background-color: rgba(133, 77, 14, 0.2); color: #eab308; }
139
+
140
+ .sl-badge--live {
141
+ background-color: rgba(255, 107, 107, 0.2);
142
+ color: var(--sl-error);
143
+ animation: sl-pulse 2s infinite;
144
+ }
145
+
146
+ @keyframes sl-pulse {
147
+ 0% { opacity: 1; }
148
+ 50% { opacity: 0.5; }
149
+ 100% { opacity: 1; }
150
+ }
151
+
152
+ /* Button Block */
153
+ .sl-btn {
154
+ padding: 6px 12px;
155
+ border-radius: 6px;
156
+ border: 1px solid var(--sl-border-color);
157
+ background: var(--sl-hover-bg);
158
+ color: var(--sl-text-color);
159
+ text-decoration: none;
160
+ cursor: pointer;
161
+ font-size: 0.9em;
162
+ display: inline-block;
163
+ }
164
+
165
+ .sl-btn:hover {
166
+ background-color: var(--sl-accent);
167
+ }
168
+
169
+ .sl-btn--primary {
170
+ background-color: var(--sl-accent);
171
+ border-color: var(--sl-accent);
172
+ }
173
+
174
+ .sl-btn--small { padding: 3px 8px; font-size: 0.8em; }
175
+
176
+ .sl-btn--disabled {
177
+ opacity: 0.5;
178
+ pointer-events: none;
179
+ }
180
+
181
+ /* Flash Message Block */
182
+ .sl-flash-container {
183
+ position: fixed;
184
+ top: 70px;
185
+ right: 20px;
186
+ z-index: 9999;
187
+ max-width: 400px;
188
+ }
189
+
190
+ .sl-flash {
191
+ padding: 12px 20px;
192
+ border-radius: var(--sl-radius);
193
+ margin-bottom: 10px;
194
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
195
+ border: 1px solid rgba(255,255,255,0.1);
196
+ color: white;
197
+ }
198
+
199
+ .sl-flash--notice { background: var(--sl-success); }
200
+ .sl-flash--alert { background: var(--sl-error); }
201
+
202
+ /* Utilities */
203
+ .sl-mt-10 { margin-top: 10px; }
204
+ .sl-mt-15 { margin-top: 15px; }
205
+ .sl-pre-wrap { white-space: pre-wrap; }
206
+
207
+ .sl-json {
208
+ font-family: monospace;
209
+ font-size: 0.85rem;
210
+ white-space: pre-wrap;
211
+ word-break: break-word;
212
+ background: var(--sl-bg-color);
213
+ padding: 10px;
214
+ border-radius: 6px;
215
+ border: 1px solid var(--sl-border-color);
216
+ }
217
+
218
+ /* Layout Patterns */
219
+ .sl-page-header {
220
+ display: flex;
221
+ justify-content: space-between;
222
+ align-items: center;
223
+ margin-bottom: 20px;
224
+ }
225
+
226
+ .sl-page-header__title {
227
+ margin-bottom: 5px;
228
+ }
229
+
230
+ .sl-page-header__meta {
231
+ color: var(--sl-muted-text);
232
+ }
233
+
234
+ .sl-page-header__actions {
235
+ display: flex;
236
+ gap: 10px;
237
+ align-items: center;
238
+ }
239
+
240
+ .sl-layout-split {
241
+ display: flex;
242
+ gap: 20px;
243
+ }
244
+
245
+ .sl-layout-split__main {
246
+ flex: 1;
247
+ min-width: 0;
248
+ }
249
+
250
+ .sl-layout-split__sidebar {
251
+ width: 350px;
252
+ flex-shrink: 0;
253
+ }
254
+
255
+ /* Dashboard Stats */
256
+ .sl-grid-row {
257
+ display: flex;
258
+ gap: 20px;
259
+ margin-bottom: 20px;
260
+ }
261
+
262
+ .sl-stat-card {
263
+ flex: 1;
264
+ text-align: center;
265
+ }
266
+
267
+ .sl-stat-card__label {
268
+ color: var(--sl-muted-text);
269
+ font-size: 0.9em;
270
+ text-transform: uppercase;
271
+ }
272
+
273
+ .sl-stat-card__value {
274
+ font-size: 1.8em;
275
+ font-weight: bold;
276
+ margin-top: 5px;
277
+ }
278
+
279
+ .sl-stat-card__value--accent { color: var(--sl-accent); }
280
+ .sl-stat-card__value--error { color: var(--sl-error); }
281
+
282
+ .sl-chart-container {
283
+ height: 300px;
284
+ margin-top: 15px;
285
+ }
286
+
287
+ /* Messages */
288
+ .sl-message {
289
+ border-left: 4px solid var(--sl-muted-text);
290
+ }
291
+
292
+ .sl-message--user { border-left-color: var(--sl-success); }
293
+ .sl-message--assistant { border-left-color: var(--sl-accent); }
294
+ .sl-message--tool { border-left-color: var(--sl-pending); }
295
+
296
+ .sl-message__header {
297
+ display: flex;
298
+ justify-content: space-between;
299
+ align-items: center;
300
+ margin-bottom: 10px;
301
+ }
302
+
303
+ .sl-message__role {
304
+ text-transform: uppercase;
305
+ font-size: 0.85em;
306
+ color: var(--sl-muted-text);
307
+ }
308
+
309
+ .sl-message__time {
310
+ font-size: 0.8em;
311
+ color: var(--sl-muted-text);
312
+ }
313
+
314
+ .sl-message__tools {
315
+ margin-top: 10px;
316
+ color: var(--sl-pending);
317
+ }
318
+
319
+ .sl-message__reasoning {
320
+ margin-top: 10px;
321
+ padding-left: 10px;
322
+ border-left: 2px solid var(--sl-border-color);
323
+ color: var(--sl-muted-text);
324
+ }
325
+
326
+ .sl-message__footer {
327
+ margin-top: 10px;
328
+ padding-top: 8px;
329
+ border-top: 1px solid var(--sl-border-color);
330
+ display: flex;
331
+ flex-wrap: wrap;
332
+ gap: 15px;
333
+ font-size: 0.8em;
334
+ color: var(--sl-muted-text);
335
+ }
336
+
337
+ .sl-message__footer-item {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: 4px;
341
+ }
342
+
343
+ .sl-message__footer-value {
344
+ color: var(--sl-text-color);
345
+ font-weight: 500;
346
+ }
347
+
348
+ /* Pagination */
349
+ .sl-pagination {
350
+ display: flex;
351
+ justify-content: space-between;
352
+ align-items: center;
353
+ gap: 12px;
354
+ margin-top: 16px;
355
+ }
356
+
357
+ .sl-pagination__info {
358
+ color: var(--sl-muted-text);
359
+ font-size: 0.9em;
360
+ }
361
+
362
+ .sl-pagination__links {
363
+ display: flex;
364
+ gap: 8px;
365
+ }
366
+ </style>
367
+ </head>
368
+ <body>
369
+ <nav class="sl-navbar">
370
+ <a href="<%= solid_loop.dashboard_path %>" class="sl-navbar__brand">SolidLoop</a>
371
+ <div class="sl-navbar__links">
372
+ <% [
373
+ ['Dashboard', solid_loop.dashboard_path],
374
+ ['Loops', solid_loop.loops_path],
375
+ ['Messages', solid_loop.messages_path],
376
+ ['Tool Calls', solid_loop.tool_calls_path],
377
+ ['MCP Sessions', solid_loop.mcp_sessions_path],
378
+ ['MCP Tools', solid_loop.mcp_tools_path],
379
+ ['Inbound MCP', solid_loop.mcp_inbound_sessions_path],
380
+ ['Events', solid_loop.events_path]
381
+ ].each do |label, path| %>
382
+ <a href="<%= path %>" class="sl-navbar__link <%= 'sl-navbar__link--active' if request.path.start_with?(path) %>">
383
+ <%= label %>
384
+ </a>
385
+ <% end %>
386
+ </div>
387
+
388
+ <div style="margin-left: auto;">
389
+ <%= form_with url: loops_path, method: :get, style: "display: flex; gap: 5px;" do |f| %>
390
+ <%= f.text_field :loop_id_search, name: :loop_id, placeholder: "Loop ID...", class: "sl-btn sl-btn--small", style: "width: 100px; background: var(--sl-bg-color);" %>
391
+ <%= f.submit "Search", class: "sl-btn sl-btn--small sl-btn--primary" %>
392
+ <% end %>
393
+ </div>
394
+ </nav>
395
+
396
+ <div class="sl-app-container">
397
+ <div class="sl-content-area">
398
+ <div class="sl-flash-container">
399
+ <% if flash[:notice] %>
400
+ <div class="sl-flash sl-flash--notice">
401
+ <%= flash[:notice] %>
402
+ </div>
403
+ <% end %>
404
+
405
+ <% if flash[:alert] %>
406
+ <div class="sl-flash sl-flash--alert">
407
+ <%= flash[:alert] %>
408
+ </div>
409
+ <% end %>
410
+ </div>
411
+
412
+ <%= yield %>
413
+ </div>
414
+ </div>
415
+ </body>
416
+ </html>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Solid loop</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "solid_loop/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,184 @@
1
+ <div class="sl-page-header">
2
+ <h2>Dashboard</h2>
3
+ <div class="sl-page-header__actions">
4
+ <div class="sl-period-toggle">
5
+ <%= link_to "Hour", dashboard_path(period: :hour),
6
+ class: "sl-btn sl-btn--small #{'sl-btn--primary' if @period == 'hour'}" %>
7
+ <%= link_to "Day", dashboard_path(period: :day),
8
+ class: "sl-btn sl-btn--small #{'sl-btn--primary' if @period == 'day'}" %>
9
+ <%= link_to "Week", dashboard_path(period: :week),
10
+ class: "sl-btn sl-btn--small #{'sl-btn--primary' if @period == 'week'}" %>
11
+ </div>
12
+ <button class="sl-btn sl-btn--small sl-poll-toggle">▶ Live</button>
13
+ </div>
14
+ </div>
15
+
16
+ <div class="sl-grid-row">
17
+ <div class="sl-card sl-stat-card">
18
+ <div class="sl-stat-card__label">LLM Calls</div>
19
+ <div id="stat-llm-calls" class="sl-stat-card__value">-</div>
20
+ </div>
21
+ <div class="sl-card sl-stat-card">
22
+ <div class="sl-stat-card__label">Tool Calls</div>
23
+ <div id="stat-tool-calls" class="sl-stat-card__value">-</div>
24
+ </div>
25
+ <div class="sl-card sl-stat-card">
26
+ <div class="sl-stat-card__label">Cost</div>
27
+ <div id="stat-cost" class="sl-stat-card__value sl-stat-card__value--accent">-</div>
28
+ </div>
29
+ <div class="sl-card sl-stat-card">
30
+ <div class="sl-stat-card__label">Failed Loops</div>
31
+ <div id="stat-failed" class="sl-stat-card__value sl-stat-card__value--error">-</div>
32
+ <div id="stat-failed-split" class="sl-stat-card__label" style="text-transform: none;">-</div>
33
+ </div>
34
+ </div>
35
+
36
+ <div class="sl-card">
37
+ <h3>Cost ($)</h3>
38
+ <div id="chart-cost" class="sl-chart-container"></div>
39
+ </div>
40
+
41
+ <div class="sl-grid-row">
42
+ <div class="sl-card sl-stat-card">
43
+ <h3>LLM Calls</h3>
44
+ <div id="chart-llm-calls" class="sl-chart-container"></div>
45
+ </div>
46
+ <div class="sl-card sl-stat-card">
47
+ <h3>Tool Calls</h3>
48
+ <div id="chart-tool-calls" class="sl-chart-container"></div>
49
+ </div>
50
+ </div>
51
+
52
+ <div class="sl-grid-row">
53
+ <div class="sl-card sl-stat-card">
54
+ <h3>Tokens</h3>
55
+ <div id="chart-tokens" class="sl-chart-container"></div>
56
+ </div>
57
+ <div class="sl-card sl-stat-card">
58
+ <h3>Top Agents by Cost</h3>
59
+ <div id="chart-top-agents" class="sl-chart-container"></div>
60
+ </div>
61
+ </div>
62
+
63
+ <script>
64
+ let pollTimer = null;
65
+ const period = "<%= @period %>";
66
+
67
+ const colors = {
68
+ accent: '#5288c1',
69
+ success: '#31b545',
70
+ error: '#ff6b6b',
71
+ text: '#f5f5f5',
72
+ muted: '#7f91a4'
73
+ };
74
+
75
+ const chartOptions = {
76
+ colors: [colors.accent, colors.success, colors.error],
77
+ library: {
78
+ plugins: {
79
+ legend: { labels: { color: colors.text } }
80
+ },
81
+ scales: {
82
+ x: { ticks: { color: colors.muted }, grid: { color: 'rgba(255,255,255,0.1)' } },
83
+ y: { ticks: { color: colors.muted }, grid: { color: 'rgba(255,255,255,0.1)' } }
84
+ }
85
+ }
86
+ };
87
+
88
+ // Client-side twin of the sl_money helper so the Cost KPI never reads
89
+ // "$0.00" for a real sub-cent run: 2 dp above a cent, more precision below.
90
+ function formatMoney(amount) {
91
+ const value = Number(amount) || 0;
92
+ if (value === 0) return '$0.00';
93
+ const abs = Math.abs(value);
94
+ let precision = 2;
95
+ if (abs < 0.1 && Math.abs(value - Number(value.toFixed(2))) > Number.EPSILON) {
96
+ precision = Math.min(Math.floor(-Math.log10(abs)) + 2, 8);
97
+ }
98
+ return '$' + value.toFixed(precision);
99
+ }
100
+
101
+ // Shared bucket formatting so bar/column charts label their time buckets the
102
+ // same human way the Cost time-axis line chart does, and stay chronological.
103
+ //
104
+ // The stats endpoint returns bucket data keyed by ISO timestamps
105
+ // (date_trunc'd to the current period). As a *categorical* axis (Column/Bar),
106
+ // Chartkick would render those keys verbatim and in hash order — hence the raw
107
+ // "2026-07-14T18:00:00.000Z" labels in an out-of-order sequence. We sort the
108
+ // pairs ascending by real time and replace each key with a period-appropriate
109
+ // human label ("3 PM" / "Jul 14" / week-of label).
110
+ function formatBucketLabel(date, period) {
111
+ if (period === 'week') {
112
+ // Match Chartkick's date-fns week axis: "week of MMM d".
113
+ const end = new Date(date);
114
+ end.setDate(end.getDate() + 6);
115
+ const opts = { month: 'short', day: 'numeric' };
116
+ return date.toLocaleDateString(undefined, opts) +
117
+ ' – ' + end.toLocaleDateString(undefined, opts);
118
+ }
119
+ if (period === 'day') {
120
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
121
+ }
122
+ // hour → "3 PM"
123
+ return date.toLocaleTimeString(undefined, { hour: 'numeric' });
124
+ }
125
+
126
+ // Turn an ISO-keyed hash into chronologically sorted [humanLabel, value] pairs.
127
+ function bucketSeries(hash, period) {
128
+ return Object.keys(hash)
129
+ .map(function (k) { return { t: new Date(k), v: hash[k] }; })
130
+ .sort(function (a, b) { return a.t - b.t; })
131
+ .map(function (row) { return [formatBucketLabel(row.t, period), row.v]; });
132
+ }
133
+
134
+ // Same treatment for a Chartkick multi-series ([{name, data: {iso: v}}]).
135
+ function bucketMultiSeries(series, period) {
136
+ return series.map(function (s) {
137
+ return { name: s.name, data: bucketSeries(s.data, period) };
138
+ });
139
+ }
140
+
141
+ document.querySelector('.sl-poll-toggle').addEventListener('click', function () {
142
+ if (pollTimer) {
143
+ clearInterval(pollTimer);
144
+ pollTimer = null;
145
+ this.textContent = '▶ Live';
146
+ this.classList.remove('sl-btn--primary');
147
+ } else {
148
+ fetchStats();
149
+ pollTimer = setInterval(fetchStats, 5000);
150
+ this.textContent = '⏸ Pause';
151
+ this.classList.add('sl-btn--primary');
152
+ }
153
+ });
154
+
155
+ function fetchStats() {
156
+ fetch(`<%= dashboard_stats_path(format: :json) %>?period=${period}`)
157
+ .then(r => r.json())
158
+ .then(data => updateCharts(data));
159
+ }
160
+
161
+ function updateCharts(data) {
162
+ document.getElementById('stat-llm-calls').textContent = data.summary.llm_calls;
163
+ document.getElementById('stat-tool-calls').textContent = data.summary.tool_calls;
164
+ document.getElementById('stat-cost').textContent = formatMoney(data.summary.cost);
165
+ document.getElementById('stat-failed').textContent = data.summary.failed;
166
+ const splitEl = document.getElementById('stat-failed-split');
167
+ if (data.summary.failed > 0) {
168
+ splitEl.textContent = `${data.summary.failed_agent} agent · ${data.summary.failed_infra} infra`;
169
+ } else {
170
+ splitEl.textContent = '';
171
+ }
172
+
173
+ new Chartkick.LineChart('chart-cost', data.cost, { ...chartOptions, prefix: '$' });
174
+ new Chartkick.ColumnChart('chart-llm-calls', bucketSeries(data.llm_calls, period), chartOptions);
175
+ new Chartkick.ColumnChart('chart-tool-calls', bucketSeries(data.tool_calls, period), chartOptions);
176
+ new Chartkick.BarChart('chart-tokens', bucketMultiSeries(data.tokens, period), chartOptions);
177
+ new Chartkick.BarChart('chart-top-agents', data.top_agents, { ...chartOptions, prefix: '$' });
178
+ }
179
+
180
+ // Initial load
181
+ document.addEventListener('DOMContentLoaded', () => {
182
+ fetchStats();
183
+ });
184
+ </script>
@@ -0,0 +1,47 @@
1
+ <div class="sl-card">
2
+ <%= form_with url: events_path, method: :get, class: "sl-page-header__actions", style: "justify-content: flex-start; flex-wrap: wrap;" do |f| %>
3
+ <div style="display: flex; align-items: center; gap: 8px;">
4
+ <span class="sl-stat-card__label">Loop ID:</span>
5
+ <%= f.text_field :loop_id, value: params[:loop_id], class: "sl-btn sl-btn--small", style: "width: 80px;", placeholder: "ID" %>
6
+ </div>
7
+ <div style="display: flex; align-items: center; gap: 8px; margin-left: 20px;">
8
+ <span class="sl-stat-card__label">Name:</span>
9
+ <%= f.text_field :name, value: params[:name], class: "sl-btn sl-btn--small", placeholder: "mcp_tool_call" %>
10
+ </div>
11
+ <div style="display: flex; align-items: center; gap: 8px; margin-left: 20px;">
12
+ <span class="sl-stat-card__label">Type:</span>
13
+ <%= f.text_field :eventable_type, value: params[:eventable_type], class: "sl-btn sl-btn--small", placeholder: "SolidLoop::ToolCall" %>
14
+ </div>
15
+ <%= f.submit "Filter", class: "sl-btn sl-btn--small sl-btn--primary", style: "margin-left: 12px;" %>
16
+ <% if params[:loop_id].present? || params[:name].present? || params[:eventable_type].present? %>
17
+ <%= link_to "Clear Filters", events_path, class: "sl-btn sl-btn--small", style: "margin-left: 12px;" %>
18
+ <% end %>
19
+ <% end %>
20
+ </div>
21
+
22
+ <table class="sl-table">
23
+ <thead>
24
+ <tr>
25
+ <th class="sl-table__th">ID</th>
26
+ <th class="sl-table__th">Type</th>
27
+ <th class="sl-table__th">Loop ID</th>
28
+ <th class="sl-table__th">Created At</th>
29
+ <th class="sl-table__th">Actions</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% @events.each do |event| %>
34
+ <tr>
35
+ <td class="sl-table__td"><%= event.id %></td>
36
+ <td class="sl-table__td"><span class="sl-badge sl-badge--processing"><%= event.name %></span></td>
37
+ <td class="sl-table__td"><%= link_to event.loop_id, loop_path(event.loop_id) if event.loop_id %></td>
38
+ <td class="sl-table__td"><%= event.created_at.strftime("%Y-%m-%d %H:%M") %></td>
39
+ <td class="sl-table__td">
40
+ <%= link_to "View", event_path(event), class: "sl-btn sl-btn--small" %>
41
+ </td>
42
+ </tr>
43
+ <% end %>
44
+ </tbody>
45
+ </table>
46
+
47
+ <%= render "solid_loop/shared/pagination", pagination: @pagination %>