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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +445 -0
  3. data/README.md +305 -4
  4. data/Rakefile +5 -4
  5. data/app/controllers/solid_loop/application_controller.rb +6 -0
  6. data/app/controllers/solid_loop/dashboard_controller.rb +167 -22
  7. data/app/controllers/solid_loop/events_controller.rb +9 -1
  8. data/app/controllers/solid_loop/mcp_sessions_controller.rb +12 -0
  9. data/app/controllers/solid_loop/messages_controller.rb +11 -1
  10. data/app/controllers/solid_loop/tool_calls_controller.rb +32 -0
  11. data/app/helpers/solid_loop/application_helper.rb +4 -1
  12. data/app/helpers/solid_loop/metrics_helper.rb +232 -0
  13. data/app/jobs/solid_loop/janitor_job.rb +24 -0
  14. data/app/jobs/solid_loop/llm_completion_job.rb +2 -2
  15. data/app/models/solid_loop/base.rb +89 -6
  16. data/app/models/solid_loop/loop.rb +22 -0
  17. data/app/models/solid_loop/message.rb +33 -7
  18. data/app/models/solid_loop/tool_call.rb +7 -1
  19. data/app/services/solid_loop/adapters/native.rb +156 -23
  20. data/app/services/solid_loop/dialects/anthropic.rb +43 -10
  21. data/app/services/solid_loop/dialects/gemini.rb +55 -15
  22. data/app/services/solid_loop/dialects/open_ai.rb +17 -5
  23. data/app/services/solid_loop/dialects/reasoning_packer.rb +72 -7
  24. data/app/services/solid_loop/llm_usage_parser/llama.rb +18 -3
  25. data/app/services/solid_loop/mcp_session_initializer.rb +1 -0
  26. data/app/services/solid_loop/middlewares/agent_initialization.rb +1 -1
  27. data/app/services/solid_loop/middlewares/error_handling.rb +5 -1
  28. data/app/services/solid_loop/middlewares/event_logging.rb +3 -3
  29. data/app/services/solid_loop/middlewares/message_building.rb +38 -4
  30. data/app/services/solid_loop/middlewares/response_parsing.rb +19 -3
  31. data/app/views/layouts/solid_loop/admin.html.erb +134 -23
  32. data/app/views/solid_loop/dashboard/index.html.erb +330 -41
  33. data/app/views/solid_loop/events/index.html.erb +17 -1
  34. data/app/views/solid_loop/loops/index.html.erb +40 -3
  35. data/app/views/solid_loop/loops/show.html.erb +1 -1
  36. data/app/views/solid_loop/mcp_sessions/index.html.erb +40 -2
  37. data/app/views/solid_loop/messages/_message.html.erb +26 -35
  38. data/app/views/solid_loop/messages/index.html.erb +16 -2
  39. data/app/views/solid_loop/tool_calls/index.html.erb +18 -3
  40. data/db/migrate/20260819000100_solid_loop_add_retention_indexes.rb +22 -0
  41. data/docs/contributing/coverage.md +8 -8
  42. data/docs/decisions/mcp-server.md +4 -2
  43. data/docs/decisions/reasoning_persistence.md +120 -4
  44. data/docs/guides/dialects.md +1 -1
  45. data/docs/guides/mcp_transports.md +72 -0
  46. data/docs/validation.md +85 -0
  47. data/lib/solid_loop/configuration.rb +93 -0
  48. data/lib/solid_loop/engine.rb +8 -0
  49. data/lib/solid_loop/janitor.rb +110 -0
  50. data/lib/solid_loop/mcp/toolset.rb +119 -31
  51. data/lib/solid_loop/pipeline/builder.rb +31 -10
  52. data/lib/solid_loop/redaction.rb +26 -0
  53. data/lib/solid_loop/version.rb +1 -1
  54. data/lib/solid_loop.rb +47 -0
  55. metadata +7 -2
  56. data/lib/tasks/coverage.rake +0 -206
@@ -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