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,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,6 @@
1
+ class SolidLoopAddExecutionGuards < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_column :solid_loop_loops, :execution_token, :string
4
+ add_column :solid_loop_tool_calls, :executed_at, :datetime
5
+ end
6
+ 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`