solid_loop 0.0.4 → 0.0.5
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 +4 -4
- data/CHANGELOG.md +445 -0
- data/README.md +305 -4
- data/Rakefile +5 -4
- data/app/controllers/solid_loop/application_controller.rb +6 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +167 -22
- data/app/controllers/solid_loop/events_controller.rb +9 -1
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +12 -0
- data/app/controllers/solid_loop/messages_controller.rb +11 -1
- data/app/controllers/solid_loop/tool_calls_controller.rb +32 -0
- data/app/helpers/solid_loop/application_helper.rb +4 -1
- data/app/helpers/solid_loop/metrics_helper.rb +232 -0
- data/app/jobs/solid_loop/janitor_job.rb +24 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +2 -2
- data/app/models/solid_loop/base.rb +89 -6
- data/app/models/solid_loop/loop.rb +22 -0
- data/app/models/solid_loop/message.rb +33 -7
- data/app/models/solid_loop/tool_call.rb +7 -1
- data/app/services/solid_loop/adapters/native.rb +156 -23
- data/app/services/solid_loop/dialects/anthropic.rb +43 -10
- data/app/services/solid_loop/dialects/gemini.rb +55 -15
- data/app/services/solid_loop/dialects/open_ai.rb +17 -5
- data/app/services/solid_loop/dialects/reasoning_packer.rb +72 -7
- data/app/services/solid_loop/llm_usage_parser/llama.rb +18 -3
- data/app/services/solid_loop/mcp_session_initializer.rb +1 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +1 -1
- data/app/services/solid_loop/middlewares/error_handling.rb +5 -1
- data/app/services/solid_loop/middlewares/event_logging.rb +3 -3
- data/app/services/solid_loop/middlewares/message_building.rb +38 -4
- data/app/services/solid_loop/middlewares/response_parsing.rb +19 -3
- data/app/views/layouts/solid_loop/admin.html.erb +134 -23
- data/app/views/solid_loop/dashboard/index.html.erb +330 -41
- data/app/views/solid_loop/events/index.html.erb +17 -1
- data/app/views/solid_loop/loops/index.html.erb +40 -3
- data/app/views/solid_loop/loops/show.html.erb +1 -1
- data/app/views/solid_loop/mcp_sessions/index.html.erb +40 -2
- data/app/views/solid_loop/messages/_message.html.erb +26 -35
- data/app/views/solid_loop/messages/index.html.erb +16 -2
- data/app/views/solid_loop/tool_calls/index.html.erb +18 -3
- data/db/migrate/20260819000100_solid_loop_add_retention_indexes.rb +22 -0
- data/docs/contributing/coverage.md +8 -8
- data/docs/decisions/mcp-server.md +4 -2
- data/docs/decisions/reasoning_persistence.md +120 -4
- data/docs/guides/dialects.md +1 -1
- data/docs/guides/mcp_transports.md +72 -0
- data/docs/validation.md +85 -0
- data/lib/solid_loop/configuration.rb +93 -0
- data/lib/solid_loop/engine.rb +8 -0
- data/lib/solid_loop/janitor.rb +110 -0
- data/lib/solid_loop/mcp/toolset.rb +119 -31
- data/lib/solid_loop/pipeline/builder.rb +31 -10
- data/lib/solid_loop/redaction.rb +26 -0
- data/lib/solid_loop/version.rb +1 -1
- data/lib/solid_loop.rb +47 -0
- metadata +7 -2
- data/lib/tasks/coverage.rake +0 -206
data/lib/tasks/coverage.rake
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
|
|
3
|
-
namespace :coverage do
|
|
4
|
-
GROUPS = %w[
|
|
5
|
-
app/controllers app/models app/services app/helpers
|
|
6
|
-
app/jobs app/mailers app/queries app/channels
|
|
7
|
-
app/concerns app/views
|
|
8
|
-
].freeze
|
|
9
|
-
|
|
10
|
-
desc "Parse coverage/coverage.json and print summary. Args: sort (worst|partial|uncovered|full), filter, git (staged|head|branch)"
|
|
11
|
-
task :report, [:sort, :filter, :git] do |_t, args|
|
|
12
|
-
CoverageReporter.report(args)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
class CoverageReporter
|
|
16
|
-
COVERAGE_PATH = File.expand_path("../../coverage/coverage.json", __dir__)
|
|
17
|
-
|
|
18
|
-
def self.report(args)
|
|
19
|
-
new(args).report
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def initialize(args)
|
|
23
|
-
@args = args
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def report
|
|
27
|
-
unless File.exist?(COVERAGE_PATH)
|
|
28
|
-
abort "coverage/coverage.json not found. Run tests first:\n bundle exec rspec"
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
data = JSON.parse(File.read(COVERAGE_PATH))
|
|
32
|
-
raw_coverage = parse_coverage(data)
|
|
33
|
-
|
|
34
|
-
git_mode = @args[:git]
|
|
35
|
-
git_changed_lines = git_mode ? load_git_changes(git_mode) : {}
|
|
36
|
-
|
|
37
|
-
if git_mode && git_changed_lines.empty?
|
|
38
|
-
puts "No changed files found for git mode: #{git_mode}"
|
|
39
|
-
return
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
filter_pattern = @args[:filter] ? Regexp.new(@args[:filter], Regexp::IGNORECASE) : nil
|
|
43
|
-
file_stats = []
|
|
44
|
-
total_lines = 0
|
|
45
|
-
covered_lines = 0
|
|
46
|
-
|
|
47
|
-
raw_coverage.each do |file_path, line_data|
|
|
48
|
-
next unless file_path.include?("/app/")
|
|
49
|
-
|
|
50
|
-
lines = extract_lines(line_data)
|
|
51
|
-
next if lines.nil?
|
|
52
|
-
|
|
53
|
-
short = file_path.sub(%r{.*/(?=app/)}, "")
|
|
54
|
-
next if filter_pattern && short !~ filter_pattern
|
|
55
|
-
next if git_mode && !git_changed_lines.key?(short)
|
|
56
|
-
|
|
57
|
-
stat = git_mode ? stat_for_changed(short, lines, git_changed_lines[short]) : stat_for_all(short, lines)
|
|
58
|
-
next if stat.nil?
|
|
59
|
-
|
|
60
|
-
total_lines += stat[:total]
|
|
61
|
-
covered_lines += stat[:covered]
|
|
62
|
-
file_stats << stat
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
if file_stats.empty?
|
|
66
|
-
puts "No matching files found."
|
|
67
|
-
return
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
overall = total_lines > 0 ? (covered_lines.to_f / total_lines * 100).round(2) : 0.0
|
|
71
|
-
header = ["Coverage Report", filter_pattern && "[filter: #{@args[:filter]}]", git_mode && "[git: #{git_mode}]"].compact.join(" ")
|
|
72
|
-
|
|
73
|
-
puts "=" * 60
|
|
74
|
-
puts " #{header}"
|
|
75
|
-
puts " #{covered_lines}/#{total_lines} lines (#{overall}%)"
|
|
76
|
-
puts "=" * 60
|
|
77
|
-
|
|
78
|
-
sort_mode = (@args[:sort] || "worst").to_sym
|
|
79
|
-
|
|
80
|
-
file_stats.group_by { |f| f[:group] }.sort.each do |group_name, files|
|
|
81
|
-
g_total = files.sum { |f| f[:total] }
|
|
82
|
-
g_covered = files.sum { |f| f[:covered] }
|
|
83
|
-
g_pct = g_total > 0 ? (g_covered.to_f / g_total * 100).round(1) : 0.0
|
|
84
|
-
|
|
85
|
-
filtered = apply_sort(files, sort_mode)
|
|
86
|
-
next if filtered.empty?
|
|
87
|
-
|
|
88
|
-
label = git_mode ? "#{group_name} (changed lines)" : group_name
|
|
89
|
-
puts "\n--- #{label} (#{g_pct}% | #{g_covered}/#{g_total}) ---"
|
|
90
|
-
filtered.each do |f|
|
|
91
|
-
tag = f[:changed_only] ? " [changed]" : ""
|
|
92
|
-
puts " #{f[:pct].to_s.rjust(5)}% #{f[:path]}#{tag} (#{f[:missed]} missed of #{f[:total]})"
|
|
93
|
-
next if f[:missed_lines].empty?
|
|
94
|
-
puts " uncovered: #{format_line_ranges(f[:missed_lines])}"
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
puts
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
def parse_coverage(data)
|
|
104
|
-
if data["files"].is_a?(Array)
|
|
105
|
-
data["files"].each_with_object({}) { |f, h| h[f["filename"]] = f["coverage"] }
|
|
106
|
-
else
|
|
107
|
-
data["coverage"] || {}
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def extract_lines(line_data)
|
|
112
|
-
case line_data
|
|
113
|
-
when Hash then line_data["lines"]
|
|
114
|
-
when Array then line_data
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def stat_for_changed(short, lines, changed_line_nums)
|
|
119
|
-
relevant = lines.each_with_index.select { |l, i| !l.nil? && changed_line_nums.include?(i + 1) }
|
|
120
|
-
cov = relevant.count { |l, _| l > 0 }
|
|
121
|
-
miss = relevant.count { |l, _| l == 0 }
|
|
122
|
-
return nil if (cov + miss) == 0
|
|
123
|
-
build_stat(short, cov, miss, relevant.select { |l, _| l == 0 }.map { |_, i| i + 1 }.sort, changed_only: true)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def stat_for_all(short, lines)
|
|
127
|
-
relevant = lines.compact
|
|
128
|
-
cov = relevant.count { |l| l > 0 }
|
|
129
|
-
miss = relevant.count { |l| l == 0 }
|
|
130
|
-
miss_lines = lines.each_with_index.select { |l, _| l == 0 }.map { |_, i| i + 1 }.sort
|
|
131
|
-
return nil if (cov + miss) == 0
|
|
132
|
-
build_stat(short, cov, miss, miss_lines, changed_only: false)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def build_stat(short, cov, miss, miss_lines, changed_only:)
|
|
136
|
-
tot = cov + miss
|
|
137
|
-
{
|
|
138
|
-
path: short,
|
|
139
|
-
pct: (cov.to_f / tot * 100).round(1),
|
|
140
|
-
covered: cov,
|
|
141
|
-
total: tot,
|
|
142
|
-
missed: miss,
|
|
143
|
-
missed_lines: miss_lines,
|
|
144
|
-
group: detect_group(short),
|
|
145
|
-
changed_only: changed_only
|
|
146
|
-
}
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def detect_group(short)
|
|
150
|
-
GROUPS.find { |prefix| short.start_with?(prefix) } || "app/other"
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def apply_sort(files, mode)
|
|
154
|
-
case mode
|
|
155
|
-
when :uncovered then files.select { |f| f[:pct] == 0.0 }.sort_by { |f| -f[:total] }
|
|
156
|
-
when :partial then files.select { |f| f[:pct] > 0 && f[:pct] < 100 }.sort_by { |f| f[:pct] }
|
|
157
|
-
when :full then files.select { |f| f[:pct] == 100.0 }.sort_by { |f| -f[:total] }
|
|
158
|
-
else files.sort_by { |f| f[:pct] }
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def format_line_ranges(line_nums)
|
|
163
|
-
return "" if line_nums.empty?
|
|
164
|
-
ranges, start = [], line_nums[0]
|
|
165
|
-
finish = start
|
|
166
|
-
line_nums[1..].each do |n|
|
|
167
|
-
n == finish + 1 ? (finish = n) : (ranges << range_str(start, finish); start = finish = n)
|
|
168
|
-
end
|
|
169
|
-
(ranges << range_str(start, finish)).join(", ")
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def range_str(s, f) = s == f ? s.to_s : "#{s}-#{f}"
|
|
173
|
-
|
|
174
|
-
def load_git_changes(mode)
|
|
175
|
-
diff_output = case mode
|
|
176
|
-
when "staged" then `git diff --cached --diff-filter=ACMR --unified=0 -- app/`
|
|
177
|
-
when "unstaged" then `git diff --diff-filter=ACMR --unified=0 -- app/`
|
|
178
|
-
when "head"
|
|
179
|
-
`git diff HEAD~1...HEAD --diff-filter=ACMR --unified=0 -- app/`
|
|
180
|
-
when "branch"
|
|
181
|
-
branch = `git rev-parse --abbrev-ref HEAD`.strip
|
|
182
|
-
base = `git merge-base main #{branch} 2>/dev/null`.strip
|
|
183
|
-
base = `git merge-base master #{branch} 2>/dev/null`.strip if base.empty?
|
|
184
|
-
base.empty? ? `git diff HEAD --diff-filter=ACMR --unified=0 -- app/` \
|
|
185
|
-
: `git diff #{base}...HEAD --diff-filter=ACMR --unified=0 -- app/`
|
|
186
|
-
else
|
|
187
|
-
`git diff HEAD --diff-filter=ACMR --unified=0 -- app/`
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
result = {}
|
|
191
|
-
current_file = nil
|
|
192
|
-
diff_output.each_line do |line|
|
|
193
|
-
if line =~ %r{^\+\+\+ b/(app/.+)$}
|
|
194
|
-
current_file = $1.strip
|
|
195
|
-
result[current_file] ||= []
|
|
196
|
-
elsif line =~ /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
197
|
-
next unless current_file
|
|
198
|
-
start_line = $1.to_i
|
|
199
|
-
count = [($2 || "1").to_i, 1].max
|
|
200
|
-
result[current_file].concat((start_line...(start_line + count)).to_a)
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
result
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
end
|