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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- metadata +228 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<div class="sl-page-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="sl-page-header__title">Tool Call #<%= @tool_call.id %></h1>
|
|
4
|
+
<p class="sl-page-header__meta">Loop #<%= @tool_call.message&.loop_id %> | Function: <strong><%= @tool_call.function_name %></strong></p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="sl-page-header__actions">
|
|
7
|
+
<%= link_to "Back to Loop", loop_path(@tool_call.message.loop_id), class: "sl-btn" if @tool_call.message&.loop_id %>
|
|
8
|
+
<% if @tool_call.mcp_session_id %>
|
|
9
|
+
<%= link_to "Replay in Inspector", inspector_mcp_session_path(@tool_call.mcp_session_id, replay_tool_call_id: @tool_call.id), class: "sl-btn sl-btn--primary" %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="sl-layout-split">
|
|
15
|
+
<div class="sl-layout-split__main">
|
|
16
|
+
<div class="sl-card">
|
|
17
|
+
<h3>Arguments</h3>
|
|
18
|
+
<div class="sl-json sl-mt-10"><%= JSON.pretty_generate(@tool_call.arguments || {}) rescue @tool_call.arguments %></div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="sl-card">
|
|
22
|
+
<h3>Result</h3>
|
|
23
|
+
<div class="sl-json sl-mt-10 <%= 'sl-pre-wrap' if @tool_call.result&.include?("\n") %>"><%= @tool_call.result %></div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<% if @tool_call.error_message.present? %>
|
|
27
|
+
<div class="sl-card" style="border-left: 4px solid var(--sl-error);">
|
|
28
|
+
<h3 style="color: var(--sl-error);">Error Message</h3>
|
|
29
|
+
<div class="sl-json sl-mt-10" style="color: var(--sl-error);"><%= @tool_call.error_message %></div>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="sl-layout-split__sidebar">
|
|
35
|
+
<div class="sl-card">
|
|
36
|
+
<h3>Metadata</h3>
|
|
37
|
+
<div class="sl-mt-10">
|
|
38
|
+
<div class="sl-message__footer-item">
|
|
39
|
+
Status: <span class="sl-badge sl-badge--<%= @tool_call.error_message.present? ? 'error' : 'success' %>"><%= @tool_call.error_message.present? ? 'FAILED' : 'SUCCESS' %></span>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="sl-message__footer-item">
|
|
42
|
+
Duration: <span class="sl-message__footer-value"><%= number_with_precision(@tool_call.duration, precision: 3) %>s</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="sl-message__footer-item">
|
|
45
|
+
Created: <span class="sl-message__footer-value"><%= @tool_call.created_at.strftime("%Y-%m-%d %H:%M:%S") %></span>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="sl-card">
|
|
51
|
+
<h3>Relations</h3>
|
|
52
|
+
<div class="sl-mt-10" style="display: flex; flex-direction: column; gap: 8px;">
|
|
53
|
+
<%= link_to "Parent Message", message_path(@tool_call.message_id), class: "sl-btn sl-btn--small" if @tool_call.message_id %>
|
|
54
|
+
<%= link_to "MCP Session", mcp_session_path(@tool_call.mcp_session_id), class: "sl-btn sl-btn--small" if @tool_call.mcp_session_id %>
|
|
55
|
+
<%= link_to "Loop Details", loop_path(@tool_call.message.loop_id), class: "sl-btn sl-btn--small" if @tool_call.message&.loop_id %>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
SolidLoop::Engine.routes.draw do
|
|
2
|
+
root to: "dashboard#index", as: :dashboard
|
|
3
|
+
|
|
4
|
+
get "dashboard", to: "dashboard#index"
|
|
5
|
+
get "dashboard/stats", to: "dashboard#stats", as: :dashboard_stats
|
|
6
|
+
|
|
7
|
+
resources :loops, only: %i[index show] do
|
|
8
|
+
member do
|
|
9
|
+
patch :observe
|
|
10
|
+
patch :freeze
|
|
11
|
+
patch :unfreeze
|
|
12
|
+
patch :force_stop
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
resources :messages, only: %i[index show]
|
|
17
|
+
resources :tool_calls, only: %i[index show]
|
|
18
|
+
resources :mcp_sessions, only: %i[index show] do
|
|
19
|
+
member do
|
|
20
|
+
get :inspector
|
|
21
|
+
post :run_tool
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
resources :mcp_tools, only: %i[index show]
|
|
25
|
+
resources :mcp_inbound_sessions, only: %i[index show]
|
|
26
|
+
resources :events, only: %i[index show]
|
|
27
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
class SolidLoopInit < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :solid_loop_loops do |t|
|
|
4
|
+
t.references :subject, polymorphic: true
|
|
5
|
+
|
|
6
|
+
t.string :title
|
|
7
|
+
t.string :agent_class_name
|
|
8
|
+
t.string :status, default: "init", null: false
|
|
9
|
+
t.boolean :observe_enabled, default: false, null: false
|
|
10
|
+
t.datetime :frozen_at
|
|
11
|
+
|
|
12
|
+
t.jsonb :state, default: {}, null: false
|
|
13
|
+
t.jsonb :metadata, default: {}, null: false
|
|
14
|
+
|
|
15
|
+
t.integer :step_count, default: 0, null: false
|
|
16
|
+
t.integer :tokens_prompt, default: 0, null: false
|
|
17
|
+
t.integer :tokens_completion, default: 0, null: false
|
|
18
|
+
t.integer :tokens_total, default: 0, null: false
|
|
19
|
+
t.integer :tokens_prompt_cached, default: 0, null: false
|
|
20
|
+
|
|
21
|
+
t.float :duration_parsing, default: 0.0, null: false
|
|
22
|
+
t.float :duration_generation, default: 0.0, null: false
|
|
23
|
+
t.float :duration_tools, default: 0.0, null: false
|
|
24
|
+
t.float :duration_total, default: 0.0, null: false
|
|
25
|
+
|
|
26
|
+
t.decimal :cost, precision: 15, scale: 10, default: 0.0, null: false
|
|
27
|
+
|
|
28
|
+
t.text :error_message
|
|
29
|
+
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
add_index :solid_loop_loops, :status
|
|
33
|
+
|
|
34
|
+
create_table :solid_loop_mcp_sessions do |t|
|
|
35
|
+
t.references :loop, null: false, foreign_key: { to_table: :solid_loop_loops }
|
|
36
|
+
|
|
37
|
+
t.string :mcp_name, null: false
|
|
38
|
+
t.string :session_id, null: false
|
|
39
|
+
t.string :transport_kind, default: "http", null: false
|
|
40
|
+
|
|
41
|
+
t.jsonb :metadata, default: {}, null: false
|
|
42
|
+
|
|
43
|
+
t.timestamps
|
|
44
|
+
end
|
|
45
|
+
add_index :solid_loop_mcp_sessions, [ :loop_id, :mcp_name ], unique: true
|
|
46
|
+
|
|
47
|
+
create_table :solid_loop_mcp_tools do |t|
|
|
48
|
+
t.references :mcp_session, null: false, foreign_key: { to_table: :solid_loop_mcp_sessions }
|
|
49
|
+
|
|
50
|
+
t.string :name, null: false
|
|
51
|
+
t.jsonb :input_schema, default: {}, null: false
|
|
52
|
+
t.text :description
|
|
53
|
+
|
|
54
|
+
t.timestamps
|
|
55
|
+
end
|
|
56
|
+
add_index :solid_loop_mcp_tools, [ :mcp_session_id, :name ], unique: true
|
|
57
|
+
|
|
58
|
+
create_table :solid_loop_messages do |t|
|
|
59
|
+
t.references :loop, null: false, foreign_key: { to_table: :solid_loop_loops }
|
|
60
|
+
|
|
61
|
+
t.string :role, null: false
|
|
62
|
+
t.string :status, null: false
|
|
63
|
+
t.string :tool_call_id
|
|
64
|
+
|
|
65
|
+
t.jsonb :tool_calls_raw, default: [], null: false
|
|
66
|
+
t.jsonb :metadata, default: {}, null: false
|
|
67
|
+
|
|
68
|
+
t.integer :tokens_prompt, default: 0, null: false
|
|
69
|
+
t.integer :tokens_completion, default: 0, null: false
|
|
70
|
+
t.integer :tokens_total, default: 0, null: false
|
|
71
|
+
t.integer :tokens_prompt_cached, default: 0, null: false
|
|
72
|
+
|
|
73
|
+
t.float :tps, default: 0.0, null: false
|
|
74
|
+
t.float :ttft, default: 0.0, null: false
|
|
75
|
+
t.float :duration_thinking, default: 0.0, null: false
|
|
76
|
+
t.float :duration_generation, default: 0.0, null: false
|
|
77
|
+
|
|
78
|
+
t.decimal :cost, precision: 15, scale: 10, default: 0.0, null: false
|
|
79
|
+
|
|
80
|
+
t.boolean :is_hidden, default: false, null: false
|
|
81
|
+
|
|
82
|
+
t.text :content
|
|
83
|
+
t.text :reasoning_content
|
|
84
|
+
t.text :error_message
|
|
85
|
+
|
|
86
|
+
t.timestamps
|
|
87
|
+
end
|
|
88
|
+
add_index :solid_loop_messages, :tool_call_id
|
|
89
|
+
add_index :solid_loop_messages, :role
|
|
90
|
+
add_index :solid_loop_messages, [ :loop_id, :is_hidden ]
|
|
91
|
+
|
|
92
|
+
create_table :solid_loop_tool_calls do |t|
|
|
93
|
+
t.references :message, null: false, foreign_key: { to_table: :solid_loop_messages }
|
|
94
|
+
t.references :mcp_session, foreign_key: { to_table: :solid_loop_mcp_sessions }
|
|
95
|
+
|
|
96
|
+
t.string :tool_call_id, null: false
|
|
97
|
+
t.string :function_name, null: false
|
|
98
|
+
|
|
99
|
+
t.jsonb :arguments, default: {}, null: false
|
|
100
|
+
t.jsonb :metadata, default: {}, null: false
|
|
101
|
+
|
|
102
|
+
t.float :duration, default: 0.0, null: false
|
|
103
|
+
t.boolean :is_success
|
|
104
|
+
|
|
105
|
+
t.text :result
|
|
106
|
+
t.text :error_message
|
|
107
|
+
|
|
108
|
+
t.timestamps
|
|
109
|
+
end
|
|
110
|
+
add_index :solid_loop_tool_calls, [ :message_id, :tool_call_id ], unique: true
|
|
111
|
+
add_index :solid_loop_tool_calls, :metadata, using: :gin, opclass: :jsonb_path_ops
|
|
112
|
+
|
|
113
|
+
create_table :solid_loop_events do |t|
|
|
114
|
+
t.references :eventable, polymorphic: true, null: false
|
|
115
|
+
t.references :loop
|
|
116
|
+
t.references :message
|
|
117
|
+
|
|
118
|
+
t.string :name, null: false
|
|
119
|
+
t.string :http_method, null: false
|
|
120
|
+
t.string :tool_call_id
|
|
121
|
+
t.string :url
|
|
122
|
+
|
|
123
|
+
# Nullable on purpose: a planned wire-log storage change will stop
|
|
124
|
+
# persisting these payloads inline.
|
|
125
|
+
t.jsonb :request_data, default: {}
|
|
126
|
+
t.jsonb :response_data, default: {}
|
|
127
|
+
t.jsonb :headers, default: {}, null: false
|
|
128
|
+
|
|
129
|
+
t.float :duration, default: 0.0, null: false
|
|
130
|
+
t.text :log
|
|
131
|
+
|
|
132
|
+
t.timestamps
|
|
133
|
+
end
|
|
134
|
+
add_index :solid_loop_events, :tool_call_id
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class SolidLoopCreateMcpInboundSessions < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :solid_loop_mcp_inbound_sessions do |t|
|
|
4
|
+
t.string :server_name, null: false, index: true
|
|
5
|
+
t.string :session_id, null: false, index: { unique: true }
|
|
6
|
+
# Human-readable label of the authorizer's principal, for the admin UI.
|
|
7
|
+
t.string :principal
|
|
8
|
+
t.datetime :last_used_at
|
|
9
|
+
# DELETE terminates the session instead of destroying the row: a client
|
|
10
|
+
# must not be able to erase its own audit trail (events hang off this row).
|
|
11
|
+
t.datetime :terminated_at
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class SolidLoopAddMessageSteering < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
# Marks a user message that was enqueued as a "steering" instruction onto a
|
|
4
|
+
# live loop: staged (hidden) and editable/cancellable while `status: pending`,
|
|
5
|
+
# then materialized into the conversation (un-hidden, `status: success`) at the
|
|
6
|
+
# next turn boundary. The flag lets the host app surface still-pending steering
|
|
7
|
+
# messages distinctly from ordinary conversation and from permanently-hidden
|
|
8
|
+
# rows (e.g. compaction notes).
|
|
9
|
+
add_column :solid_loop_messages, :steering, :boolean, default: false, null: false
|
|
10
|
+
|
|
11
|
+
# Partial index to make "the loop's still-pending steering messages" cheap.
|
|
12
|
+
add_index :solid_loop_messages,
|
|
13
|
+
[ :loop_id, :id ],
|
|
14
|
+
where: "steering AND status = 'pending'",
|
|
15
|
+
name: "index_solid_loop_messages_on_pending_steering"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class SolidLoopAddMessageConversationOrder < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
# Explicit conversation position for a message, decoupled from its primary-key
|
|
4
|
+
# `id`. NULL for ordinary messages (they order by `id` as before); populated
|
|
5
|
+
# only for DELIVERED steering messages, where `id` (frozen at enqueue time) can
|
|
6
|
+
# fall between a half-open assistant(tool_calls) batch and its tool results. The
|
|
7
|
+
# live conversation orders by `COALESCE(conversation_order, id)`, and delivery
|
|
8
|
+
# allocates a fresh, monotonic value so a steering message always sorts after
|
|
9
|
+
# the whole resolved batch. See Loop#drain_pending_steering!.
|
|
10
|
+
add_column :solid_loop_messages, :conversation_order, :bigint
|
|
11
|
+
|
|
12
|
+
add_index :solid_loop_messages, [ :loop_id, :conversation_order ]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class SolidLoopAddMcpInboundSessionPrincipalKey < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
# Stable identity of the authenticated principal (NOT the display label in
|
|
4
|
+
# `principal`): every in-session POST/DELETE is scoped to it so principal B
|
|
5
|
+
# cannot ride principal A's session_id. Nullable + backfilled to the old
|
|
6
|
+
# label for rows minted before this column existed (see Mcp::Server).
|
|
7
|
+
# NOTE: the backfill is best-effort. For a bare-String authorizer the new key
|
|
8
|
+
# equals the old `#to_s`, so those sessions survive the migration. For an
|
|
9
|
+
# ActiveRecord-principal authorizer the new key is a GlobalID (stable), which
|
|
10
|
+
# will NOT match the backfilled `#to_s`, so legacy AR-principal sessions
|
|
11
|
+
# fail-closed (404) and simply re-initialize. Acceptable: inbound sessions are
|
|
12
|
+
# short-lived and re-init is transparent. No security exposure either way.
|
|
13
|
+
add_column :solid_loop_mcp_inbound_sessions, :principal_key, :string
|
|
14
|
+
|
|
15
|
+
# Stable per-mount/endpoint identity. `server_name` comes from the toolset
|
|
16
|
+
# CLASS, so two mounts of the same class shared one namespace; the mount key
|
|
17
|
+
# (defaults to server_name) separates them. Session lookup scopes to it.
|
|
18
|
+
add_column :solid_loop_mcp_inbound_sessions, :mount_key, :string
|
|
19
|
+
|
|
20
|
+
# Records the acting principal on each inbound event, so an audit row shows
|
|
21
|
+
# who actually invoked (not just who initialized the session).
|
|
22
|
+
add_column :solid_loop_events, :principal_key, :string
|
|
23
|
+
|
|
24
|
+
reversible do |dir|
|
|
25
|
+
dir.up do
|
|
26
|
+
execute <<~SQL.squish
|
|
27
|
+
UPDATE solid_loop_mcp_inbound_sessions
|
|
28
|
+
SET principal_key = principal
|
|
29
|
+
WHERE principal_key IS NULL
|
|
30
|
+
SQL
|
|
31
|
+
execute <<~SQL.squish
|
|
32
|
+
UPDATE solid_loop_mcp_inbound_sessions
|
|
33
|
+
SET mount_key = server_name
|
|
34
|
+
WHERE mount_key IS NULL
|
|
35
|
+
SQL
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
add_index :solid_loop_mcp_inbound_sessions,
|
|
40
|
+
[ :mount_key, :session_id ],
|
|
41
|
+
name: "index_solid_loop_mcp_inbound_sessions_on_mount_and_session"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class SolidLoopAddLlmLease < ActiveRecord::Migration[7.1]
|
|
2
|
+
# The LLM lease for the durable lease + reaper design
|
|
3
|
+
# (docs/decisions/durable_attempt_lease.md). LLM path only; the per-tool_call
|
|
4
|
+
# lease columns (leased_until / lease_token) are deliberately NOT added here —
|
|
5
|
+
# a separate migration adds them.
|
|
6
|
+
def up
|
|
7
|
+
# Non-NULL iff an LLM HTTP turn is in flight. The reaper reads
|
|
8
|
+
# (status, lease_expires_at) to find dead turns; NULL means "not in an LLM
|
|
9
|
+
# turn" (tool phase, or terminal). Nullable so pre-existing terminal loops
|
|
10
|
+
# correctly sit at NULL.
|
|
11
|
+
add_column :solid_loop_loops, :lease_expires_at, :datetime
|
|
12
|
+
|
|
13
|
+
add_index :solid_loop_loops, [ :status, :lease_expires_at ],
|
|
14
|
+
name: "index_solid_loop_loops_on_status_and_lease"
|
|
15
|
+
|
|
16
|
+
# A `running` loop that carries no execution_token cannot be fenced or
|
|
17
|
+
# reclaimed correctly (the token IS the LLM lease/generation fence). Heal any
|
|
18
|
+
# legacy such rows before adding the invariant so the constraint can be added
|
|
19
|
+
# VALID (single-consumer dev DB — no online rollout; see the ADR).
|
|
20
|
+
execute(<<~SQL)
|
|
21
|
+
UPDATE solid_loop_loops
|
|
22
|
+
SET status = 'failed',
|
|
23
|
+
error_message = COALESCE(error_message, 'reconciled: running without execution_token at lease migration'),
|
|
24
|
+
lease_expires_at = NULL
|
|
25
|
+
WHERE status = 'running' AND execution_token IS NULL
|
|
26
|
+
SQL
|
|
27
|
+
|
|
28
|
+
execute(<<~SQL)
|
|
29
|
+
ALTER TABLE solid_loop_loops
|
|
30
|
+
ADD CONSTRAINT running_requires_token
|
|
31
|
+
CHECK (status <> 'running' OR execution_token IS NOT NULL)
|
|
32
|
+
SQL
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def down
|
|
36
|
+
execute("ALTER TABLE solid_loop_loops DROP CONSTRAINT IF EXISTS running_requires_token")
|
|
37
|
+
remove_index :solid_loop_loops, name: "index_solid_loop_loops_on_status_and_lease"
|
|
38
|
+
remove_column :solid_loop_loops, :lease_expires_at
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class SolidLoopAddToolLease < ActiveRecord::Migration[7.1]
|
|
2
|
+
# The per-tool_call lease for the durable lease + reaper design
|
|
3
|
+
# (docs/decisions/durable_attempt_lease.md): a SECOND
|
|
4
|
+
# fence token that lets the reaper reclaim ONE dead parallel tool without
|
|
5
|
+
# fencing its healthy siblings (parallel tools are the shipped default:
|
|
6
|
+
# Base#sequential_tool_calls? => false).
|
|
7
|
+
#
|
|
8
|
+
# Additive, per the ADR: both columns are nullable. `executed_at` stays the
|
|
9
|
+
# terminal exactly-once-in-DB checkpoint; the lease is only load-bearing while
|
|
10
|
+
# a tool call is unresolved (executed_at IS NULL).
|
|
11
|
+
def up
|
|
12
|
+
# lease_token — the per-tool second fence token (a fresh uuid per claim).
|
|
13
|
+
# leased_until — when the lease expires; a healthy tool's client raises before
|
|
14
|
+
# this (duration derived to strictly outlive the tool timeout),
|
|
15
|
+
# so a live worker never holds an expired lease.
|
|
16
|
+
add_column :solid_loop_tool_calls, :lease_token, :string
|
|
17
|
+
add_column :solid_loop_tool_calls, :leased_until, :datetime
|
|
18
|
+
|
|
19
|
+
# The reaper (case 2) scans unresolved tool calls whose lease is expired or
|
|
20
|
+
# absent. The partial index keeps the scan cheap and excludes terminal
|
|
21
|
+
# (executed_at set) rows — they are never reclaimed.
|
|
22
|
+
add_index :solid_loop_tool_calls, :leased_until,
|
|
23
|
+
where: "executed_at IS NULL",
|
|
24
|
+
name: "index_solid_loop_tool_calls_on_leased_until_unresolved"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def down
|
|
28
|
+
remove_index :solid_loop_tool_calls,
|
|
29
|
+
name: "index_solid_loop_tool_calls_on_leased_until_unresolved"
|
|
30
|
+
remove_column :solid_loop_tool_calls, :leased_until
|
|
31
|
+
remove_column :solid_loop_tool_calls, :lease_token
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class SolidLoopAddLeaseRunningCheck < ActiveRecord::Migration[7.1]
|
|
2
|
+
# The lease-running CHECK (docs/decisions/durable_attempt_lease.md).
|
|
3
|
+
#
|
|
4
|
+
# `lease_expires_at` is non-NULL ONLY while an LLM HTTP turn is in flight — i.e.
|
|
5
|
+
# only while the loop is `running`. Every non-`running` transition (pause, stop,
|
|
6
|
+
# freeze, fail, complete, requeue, tool phase) is supposed to clear it. This
|
|
7
|
+
# additive CHECK makes that invariant enforced by construction, so a missed
|
|
8
|
+
# clear-site becomes a hard failure instead of a silently stuck lease the reaper
|
|
9
|
+
# re-selects forever.
|
|
10
|
+
#
|
|
11
|
+
# Additive per the ADR: heal any offending rows first (single-consumer dev DB —
|
|
12
|
+
# no online rollout), then add the constraint VALID.
|
|
13
|
+
def up
|
|
14
|
+
# Heal: any non-`running` loop still carrying a lease is stale — NULL it. (A
|
|
15
|
+
# terminal/paused loop is never in an LLM turn, so the lease is meaningless.)
|
|
16
|
+
execute(<<~SQL)
|
|
17
|
+
UPDATE solid_loop_loops
|
|
18
|
+
SET lease_expires_at = NULL
|
|
19
|
+
WHERE lease_expires_at IS NOT NULL AND status <> 'running'
|
|
20
|
+
SQL
|
|
21
|
+
|
|
22
|
+
execute(<<~SQL)
|
|
23
|
+
ALTER TABLE solid_loop_loops
|
|
24
|
+
ADD CONSTRAINT lease_requires_running
|
|
25
|
+
CHECK (lease_expires_at IS NULL OR status = 'running')
|
|
26
|
+
SQL
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def down
|
|
30
|
+
execute("ALTER TABLE solid_loop_loops DROP CONSTRAINT IF EXISTS lease_requires_running")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class SolidLoopAddToolLeasePairCheck < ActiveRecord::Migration[7.1]
|
|
2
|
+
# The tool-lease-pair CHECK (docs/decisions/durable_attempt_lease.md).
|
|
3
|
+
#
|
|
4
|
+
# `solid_loop_tool_calls.lease_token` and `.leased_until` are the two halves of
|
|
5
|
+
# ONE per-tool lease, but they were independently nullable. A malformed
|
|
6
|
+
# unresolved row with `lease_token` SET but `leased_until` NULL is never
|
|
7
|
+
# reclaimable: the reaper's `lease_token IS NULL OR leased_until < now` predicate
|
|
8
|
+
# is false (token present) OR NULL (NULL comparison) — never true — so the row
|
|
9
|
+
# can never be re-claimed, and `lease_held_by?` (requires `leased_until.present?`)
|
|
10
|
+
# rejects it too. The pair must be atomically both-set or both-NULL.
|
|
11
|
+
#
|
|
12
|
+
# Additive per the ADR (single-consumer dev DB — no online rollout): HEAL any
|
|
13
|
+
# mismatched UNRESOLVED row (clear BOTH halves so the reaper can reclaim it) then
|
|
14
|
+
# add the invariant VALID. Resolved rows (executed_at set) are left untouched —
|
|
15
|
+
# the lease is not load-bearing once a call is terminal, and a legacy resolved
|
|
16
|
+
# row carrying a half-lease is harmless — but the CHECK below still covers them,
|
|
17
|
+
# so heal any mismatched row regardless to keep the constraint addable.
|
|
18
|
+
def up
|
|
19
|
+
execute(<<~SQL)
|
|
20
|
+
UPDATE solid_loop_tool_calls
|
|
21
|
+
SET lease_token = NULL, leased_until = NULL
|
|
22
|
+
WHERE (lease_token IS NULL) <> (leased_until IS NULL)
|
|
23
|
+
SQL
|
|
24
|
+
|
|
25
|
+
execute(<<~SQL)
|
|
26
|
+
ALTER TABLE solid_loop_tool_calls
|
|
27
|
+
ADD CONSTRAINT tool_lease_pair_both_or_neither
|
|
28
|
+
CHECK ((lease_token IS NULL) = (leased_until IS NULL))
|
|
29
|
+
SQL
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def down
|
|
33
|
+
execute("ALTER TABLE solid_loop_tool_calls DROP CONSTRAINT IF EXISTS tool_lease_pair_both_or_neither")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Contributor Coverage Workflow
|
|
2
|
+
|
|
3
|
+
Use the coverage rake task to verify changed-line coverage after changing application code.
|
|
4
|
+
|
|
5
|
+
## Contributor Rule
|
|
6
|
+
|
|
7
|
+
Uncovered changed lines means the task is not complete. Add tests before closing the task.
|
|
8
|
+
|
|
9
|
+
## Run Tests First
|
|
10
|
+
|
|
11
|
+
If `coverage/coverage.json` is missing, run the relevant test suite first.
|
|
12
|
+
|
|
13
|
+
Common commands from `README.md`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle exec rspec
|
|
17
|
+
bundle exec appraisal rails-7-1 rspec
|
|
18
|
+
bundle exec appraisal rails-7-2 rspec
|
|
19
|
+
bundle exec appraisal rails-8-1 rspec
|
|
20
|
+
bundle exec appraisal rspec
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For the full local CI flow, use:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
make ci
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`make ci` does the following:
|
|
30
|
+
- removes `coverage/.resultset.json`
|
|
31
|
+
- runs the default RSpec suite with `COVERAGE_NAME=gem`
|
|
32
|
+
- runs the Rails 7.1 appraisal suite with `COVERAGE_NAME=rails-7-1`
|
|
33
|
+
- runs the Rails 7.2 appraisal suite with `COVERAGE_NAME=rails-7-2`
|
|
34
|
+
- runs the Rails 8.1 appraisal suite with `COVERAGE_NAME=rails-8-1`
|
|
35
|
+
- prints the merged coverage summary with `bundle exec rake 'coverage:report[sort]'`
|
|
36
|
+
|
|
37
|
+
Use `make ci` when you want the closest local approximation of the full gem validation workflow across supported Rails versions.
|
|
38
|
+
|
|
39
|
+
## Changed-Line Coverage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# staged changes only
|
|
43
|
+
rails 'coverage:report[worst,,staged]'
|
|
44
|
+
|
|
45
|
+
# all branch changes since main
|
|
46
|
+
rails 'coverage:report[worst,,branch]'
|
|
47
|
+
|
|
48
|
+
# last commit
|
|
49
|
+
rails 'coverage:report[worst,,head]'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Explore Weak Spots
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
rails 'coverage:report[partial]'
|
|
56
|
+
rails 'coverage:report[partial,service]'
|
|
57
|
+
rails 'coverage:report[worst,controller]'
|
|
58
|
+
rails coverage:report
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Source Of Truth
|
|
62
|
+
|
|
63
|
+
- Task implementation: `lib/tasks/coverage.rake`
|
|
64
|
+
- Coverage artifact: `coverage/coverage.json`
|