ace-support-config 0.10.2 → 0.16.3
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 +106 -0
- data/README.md +16 -1
- data/docs/demo/ace-config-bootstrap-root-files.tape.yml +31 -0
- data/docs/demo/ace-config-getting-started.tape.yml +3 -3
- data/docs/usage.md +52 -5
- data/lib/ace/support/config/cli.rb +56 -12
- data/lib/ace/support/config/molecules/project_config_scanner.rb +1 -1
- data/lib/ace/support/config/molecules/setup_doctor_reporter.rb +270 -0
- data/lib/ace/support/config/organisms/config_synchronizer.rb +197 -0
- data/lib/ace/support/config/organisms/setup_doctor.rb +1069 -0
- data/lib/ace/support/config/version.rb +1 -1
- data/lib/ace/support/config.rb +3 -1
- metadata +6 -3
- data/lib/ace/support/config/organisms/config_initializer.rb +0 -116
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "rubygems"
|
|
7
|
+
require "thread"
|
|
8
|
+
require "time"
|
|
9
|
+
require "yaml"
|
|
10
|
+
require_relative "../molecules/setup_doctor_reporter"
|
|
11
|
+
require_relative "../models/config_templates"
|
|
12
|
+
|
|
13
|
+
module Ace
|
|
14
|
+
module Support
|
|
15
|
+
module Config
|
|
16
|
+
module Organisms
|
|
17
|
+
class SetupDoctor
|
|
18
|
+
PROVIDER_GEM = "ace-llm-providers-cli"
|
|
19
|
+
PASS = "pass"
|
|
20
|
+
WARN = "warn"
|
|
21
|
+
BLOCKER = "blocker"
|
|
22
|
+
SKIP = "skip"
|
|
23
|
+
INFO = "info"
|
|
24
|
+
STATUS_GLYPHS = {PASS => "✓", WARN => "✗", BLOCKER => "✗", SKIP => "○", INFO => "○", "running" => "○"}.freeze
|
|
25
|
+
STATUS_COLORS = {PASS => "\e[32m", WARN => "\e[31m", BLOCKER => "\e[31m", SKIP => "\e[33m", INFO => "\e[36m", "running" => "\e[33m"}.freeze
|
|
26
|
+
ANSI_RESET = "\e[0m"
|
|
27
|
+
|
|
28
|
+
CORE_ROLES = %w[commit doctor].freeze
|
|
29
|
+
UTILITY_ROLE_GROUPS = %w[_utility _utility-lite].freeze
|
|
30
|
+
ROLE_REFERENCE_PATTERN = /\brole:([A-Za-z0-9_-]+)\b/
|
|
31
|
+
|
|
32
|
+
def run(json: false, no_probe: false, probe: false, hygiene: false, verbose: false, colors: true, quiet: false, io: $stdout)
|
|
33
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
checks = []
|
|
35
|
+
stream = !json && !quiet
|
|
36
|
+
|
|
37
|
+
append_check(checks, check_artifact_hygiene, stream: stream, io: io)
|
|
38
|
+
|
|
39
|
+
package_check = check_provider_package
|
|
40
|
+
append_check(checks, package_check, stream: stream, io: io)
|
|
41
|
+
|
|
42
|
+
discovery_check = check_provider_discovery
|
|
43
|
+
append_check(checks, discovery_check, stream: stream, io: io)
|
|
44
|
+
|
|
45
|
+
append_check(checks, check_config_defaults, stream: stream, io: io)
|
|
46
|
+
|
|
47
|
+
provider_context = load_provider_context if package_check[:status] != BLOCKER
|
|
48
|
+
|
|
49
|
+
checks << check_alias_hygiene(provider_context)
|
|
50
|
+
|
|
51
|
+
role_health_check = check_role_health(provider_context)
|
|
52
|
+
append_check(checks, role_health_check, stream: stream, io: io)
|
|
53
|
+
checks << check_role_hygiene(provider_context)
|
|
54
|
+
append_check(checks, check_skill_sync, stream: stream, io: io)
|
|
55
|
+
utility_provider_targets = utility_provider_targets(provider_context)
|
|
56
|
+
|
|
57
|
+
append_check(checks, check_probe_readiness(
|
|
58
|
+
provider_context,
|
|
59
|
+
no_probe: no_probe,
|
|
60
|
+
probe: probe && !no_probe,
|
|
61
|
+
role_targets: utility_provider_targets,
|
|
62
|
+
structural_blockers: health_blocking?(checks),
|
|
63
|
+
progress_io: (stream ? io : nil)
|
|
64
|
+
), stream: stream, io: io)
|
|
65
|
+
|
|
66
|
+
result = build_summary(checks).merge(
|
|
67
|
+
valid: !health_blocking?(checks),
|
|
68
|
+
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at,
|
|
69
|
+
stats: build_stats(checks)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
unless quiet
|
|
73
|
+
io.puts Molecules::SetupDoctorReporter.format_results(
|
|
74
|
+
result,
|
|
75
|
+
format: (json ? :json : :terminal),
|
|
76
|
+
hygiene: hygiene,
|
|
77
|
+
verbose: verbose,
|
|
78
|
+
colors: colors && !json
|
|
79
|
+
)
|
|
80
|
+
flush_io(io)
|
|
81
|
+
end
|
|
82
|
+
health_blocking?(checks) ? 1 : 0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def check_artifact_hygiene
|
|
88
|
+
root = project_root
|
|
89
|
+
gitignore_path = File.join(root, ".gitignore")
|
|
90
|
+
unless File.exist?(gitignore_path)
|
|
91
|
+
return check(
|
|
92
|
+
id: "artifact-hygiene",
|
|
93
|
+
kind: "health",
|
|
94
|
+
status: BLOCKER,
|
|
95
|
+
message: ".gitignore is missing at project root",
|
|
96
|
+
next_action: "Create #{gitignore_path} and add .ace-local/."
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
content = File.read(gitignore_path)
|
|
101
|
+
if gitignore_entry_present?(content, ".ace-local/")
|
|
102
|
+
check(id: "artifact-hygiene", kind: "health", status: PASS, message: ".ace-local/ is ignored")
|
|
103
|
+
else
|
|
104
|
+
check(
|
|
105
|
+
id: "artifact-hygiene",
|
|
106
|
+
kind: "health",
|
|
107
|
+
status: BLOCKER,
|
|
108
|
+
message: ".ace-local/ is not ignored",
|
|
109
|
+
next_action: "Add .ace-local/ to #{gitignore_path}."
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def check_provider_package
|
|
115
|
+
installed = Gem::Specification.find_all_by_name(PROVIDER_GEM).any?
|
|
116
|
+
return check(id: "provider-package", kind: "health", status: PASS, message: "CLI provider package #{PROVIDER_GEM} is available") if installed
|
|
117
|
+
|
|
118
|
+
check(
|
|
119
|
+
id: "provider-package",
|
|
120
|
+
kind: "health",
|
|
121
|
+
status: BLOCKER,
|
|
122
|
+
message: "CLI provider package missing: #{PROVIDER_GEM}",
|
|
123
|
+
next_action: "Install #{PROVIDER_GEM} and run bundle install."
|
|
124
|
+
)
|
|
125
|
+
rescue => e
|
|
126
|
+
check(
|
|
127
|
+
id: "provider-package",
|
|
128
|
+
kind: "health",
|
|
129
|
+
status: WARN,
|
|
130
|
+
message: "Unable to verify provider package availability: #{e.message}",
|
|
131
|
+
next_action: "Verify #{PROVIDER_GEM} is installed."
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def check_provider_discovery
|
|
136
|
+
_out, err, status = Open3.capture3("ace-llm", "--list-providers")
|
|
137
|
+
return check(id: "provider-discovery", kind: "health", status: PASS, message: "Provider discovery completed") if status.success?
|
|
138
|
+
|
|
139
|
+
check(
|
|
140
|
+
id: "provider-discovery",
|
|
141
|
+
kind: "health",
|
|
142
|
+
status: BLOCKER,
|
|
143
|
+
message: "Provider discovery failed",
|
|
144
|
+
next_action: discovery_next_action(err)
|
|
145
|
+
)
|
|
146
|
+
rescue Errno::ENOENT
|
|
147
|
+
check(
|
|
148
|
+
id: "provider-discovery",
|
|
149
|
+
kind: "health",
|
|
150
|
+
status: BLOCKER,
|
|
151
|
+
message: "ace-llm command is unavailable",
|
|
152
|
+
next_action: "Install ace-llm and #{PROVIDER_GEM}, then rerun ace-config doctor."
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_config_defaults
|
|
157
|
+
summary = collect_config_defaults_summary
|
|
158
|
+
check(
|
|
159
|
+
id: "config-defaults",
|
|
160
|
+
kind: "info",
|
|
161
|
+
status: INFO,
|
|
162
|
+
message: "Config defaults comparison completed (#{summary[:customized]} customized, #{summary[:default]} default)",
|
|
163
|
+
details: summary[:details],
|
|
164
|
+
summary: summary
|
|
165
|
+
)
|
|
166
|
+
rescue => e
|
|
167
|
+
check(
|
|
168
|
+
id: "config-defaults",
|
|
169
|
+
kind: "info",
|
|
170
|
+
status: INFO,
|
|
171
|
+
message: "Config defaults comparison skipped: #{e.message}",
|
|
172
|
+
next_action: "Run ace-config diff --one-line to inspect config drift."
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def check_skill_sync
|
|
177
|
+
out, err, status = Open3.capture3("ace-handbook", "status", "--format", "json")
|
|
178
|
+
unless status.success?
|
|
179
|
+
return check(
|
|
180
|
+
id: "skill-sync",
|
|
181
|
+
kind: "health",
|
|
182
|
+
status: WARN,
|
|
183
|
+
message: "Provider skill sync check failed",
|
|
184
|
+
next_action: "Run ace-handbook status to inspect provider skill projections.",
|
|
185
|
+
details: [err.to_s.strip, out.to_s.strip].reject(&:empty?)
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
snapshot = JSON.parse(out)
|
|
190
|
+
providers = Array(snapshot["providers"])
|
|
191
|
+
drifted = providers.select do |entry|
|
|
192
|
+
entry.fetch("missing", 0).to_i.positive? ||
|
|
193
|
+
entry.fetch("outdated", 0).to_i.positive? ||
|
|
194
|
+
entry.fetch("extra", 0).to_i.positive?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if drifted.empty?
|
|
198
|
+
total_skills = snapshot.dig("canonical", "total").to_i
|
|
199
|
+
return check(
|
|
200
|
+
id: "skill-sync",
|
|
201
|
+
kind: "health",
|
|
202
|
+
status: PASS,
|
|
203
|
+
message: "Provider skills are in sync (#{providers.length} providers, #{total_skills} skills)",
|
|
204
|
+
skill_sync: {providers: providers, canonical_total: total_skills}
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
check(
|
|
209
|
+
id: "skill-sync",
|
|
210
|
+
kind: "health",
|
|
211
|
+
status: WARN,
|
|
212
|
+
message: "Provider skill sync drift detected (#{drifted.length}/#{providers.length} providers)",
|
|
213
|
+
next_action: "Run ace-handbook sync to refresh provider-native skills.",
|
|
214
|
+
details: drifted.map { |entry| skill_sync_detail(entry) },
|
|
215
|
+
skill_sync: {providers: providers, drifted: drifted}
|
|
216
|
+
)
|
|
217
|
+
rescue Errno::ENOENT
|
|
218
|
+
check(
|
|
219
|
+
id: "skill-sync",
|
|
220
|
+
kind: "health",
|
|
221
|
+
status: WARN,
|
|
222
|
+
message: "Provider skill sync check unavailable: ace-handbook command is missing",
|
|
223
|
+
next_action: "Install ace-handbook, then rerun ace-config doctor."
|
|
224
|
+
)
|
|
225
|
+
rescue JSON::ParserError => e
|
|
226
|
+
check(
|
|
227
|
+
id: "skill-sync",
|
|
228
|
+
kind: "health",
|
|
229
|
+
status: WARN,
|
|
230
|
+
message: "Provider skill sync check returned invalid JSON: #{e.message}",
|
|
231
|
+
next_action: "Run ace-handbook status --format json to inspect provider skill projections."
|
|
232
|
+
)
|
|
233
|
+
rescue => e
|
|
234
|
+
check(
|
|
235
|
+
id: "skill-sync",
|
|
236
|
+
kind: "health",
|
|
237
|
+
status: WARN,
|
|
238
|
+
message: "Provider skill sync check failed: #{e.message}",
|
|
239
|
+
next_action: "Run ace-handbook status to inspect provider skill projections."
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def check_alias_hygiene(provider_context)
|
|
244
|
+
unless provider_context
|
|
245
|
+
return check(
|
|
246
|
+
id: "alias-readiness",
|
|
247
|
+
kind: "hygiene",
|
|
248
|
+
status: WARN,
|
|
249
|
+
message: "Alias readiness check skipped: provider context unavailable",
|
|
250
|
+
next_action: "Ensure ace-llm is installed and provider discovery succeeds."
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
stale = find_stale_aliases(provider_context)
|
|
255
|
+
if stale.empty?
|
|
256
|
+
return check(
|
|
257
|
+
id: "alias-readiness",
|
|
258
|
+
kind: "hygiene",
|
|
259
|
+
status: PASS,
|
|
260
|
+
message: "Configured model aliases resolve"
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
check(
|
|
265
|
+
id: "alias-readiness",
|
|
266
|
+
kind: "hygiene",
|
|
267
|
+
status: WARN,
|
|
268
|
+
message: "Unsupported alias mappings detected (#{stale.length})",
|
|
269
|
+
next_action: "Update aliases to declared provider models.",
|
|
270
|
+
details: stale.map { |item| "#{item[:provider]}:#{item[:alias]} -> #{item[:resolved]}" }
|
|
271
|
+
)
|
|
272
|
+
rescue => e
|
|
273
|
+
check(
|
|
274
|
+
id: "alias-readiness",
|
|
275
|
+
kind: "hygiene",
|
|
276
|
+
status: WARN,
|
|
277
|
+
message: "Alias readiness check failed: #{e.message}",
|
|
278
|
+
next_action: "Review llm/providers alias configuration."
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def check_role_health(provider_context)
|
|
283
|
+
unless provider_context
|
|
284
|
+
return check(
|
|
285
|
+
id: "role-defaults",
|
|
286
|
+
kind: "health",
|
|
287
|
+
status: WARN,
|
|
288
|
+
message: "Role default readiness skipped: provider context unavailable",
|
|
289
|
+
next_action: "Ensure ace-llm is installed and provider discovery succeeds."
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
registry = provider_context[:registry]
|
|
294
|
+
role_config = load_role_config
|
|
295
|
+
roles = CORE_ROLES
|
|
296
|
+
problems = []
|
|
297
|
+
targets = []
|
|
298
|
+
|
|
299
|
+
roles.each do |role|
|
|
300
|
+
candidates = role_config.candidates_for(role)
|
|
301
|
+
unless candidates
|
|
302
|
+
problems << "role:#{role} is referenced but not defined"
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
validations = candidates.first(2).map { |candidate| validate_role_candidate(role, candidate, registry) }
|
|
307
|
+
targets.concat(validations.filter_map { |item| item[:target] if item[:status] == PASS })
|
|
308
|
+
|
|
309
|
+
unless validations.any? { |item| item[:status] == PASS }
|
|
310
|
+
problems.concat(validations.reject { |item| item[:status] == PASS }.map { |item| item[:message] })
|
|
311
|
+
problems << "role:#{role} has no ready provider in its first two candidates"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
if problems.any?
|
|
316
|
+
return check(
|
|
317
|
+
id: "role-defaults",
|
|
318
|
+
status: BLOCKER,
|
|
319
|
+
kind: "health",
|
|
320
|
+
message: "Core role readiness failed (#{problems.uniq.length})",
|
|
321
|
+
next_action: "Update core llm.roles so setup workflows have a usable model path.",
|
|
322
|
+
details: problems.uniq,
|
|
323
|
+
targets: dedupe_targets(targets)
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
check(
|
|
328
|
+
id: "role-defaults",
|
|
329
|
+
kind: "health",
|
|
330
|
+
status: PASS,
|
|
331
|
+
message: "Core role defaults resolve",
|
|
332
|
+
targets: dedupe_targets(targets)
|
|
333
|
+
)
|
|
334
|
+
rescue => e
|
|
335
|
+
check(
|
|
336
|
+
id: "role-defaults",
|
|
337
|
+
kind: "health",
|
|
338
|
+
status: WARN,
|
|
339
|
+
message: "Role default readiness check failed: #{e.message}",
|
|
340
|
+
next_action: "Review llm.roles and provider configuration."
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def check_role_hygiene(provider_context)
|
|
345
|
+
unless provider_context
|
|
346
|
+
return check(
|
|
347
|
+
id: "role-hygiene",
|
|
348
|
+
kind: "hygiene",
|
|
349
|
+
status: WARN,
|
|
350
|
+
message: "Role hygiene skipped: provider context unavailable",
|
|
351
|
+
next_action: "Ensure ace-llm is installed and provider discovery succeeds."
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
registry = provider_context[:registry]
|
|
356
|
+
role_config = load_role_config
|
|
357
|
+
findings = []
|
|
358
|
+
|
|
359
|
+
used_role_names(role_config).each do |role|
|
|
360
|
+
candidates = role_config.candidates_for(role)
|
|
361
|
+
unless candidates
|
|
362
|
+
findings << "role:#{role} is referenced but not defined"
|
|
363
|
+
next
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
candidates.first(2).each do |candidate|
|
|
367
|
+
validation = validate_role_candidate(role, candidate, registry)
|
|
368
|
+
findings << validation[:message] unless validation[:status] == PASS
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
if findings.any?
|
|
373
|
+
return check(
|
|
374
|
+
id: "role-hygiene",
|
|
375
|
+
kind: "hygiene",
|
|
376
|
+
status: WARN,
|
|
377
|
+
message: "Role/default hygiene findings detected (#{findings.uniq.length})",
|
|
378
|
+
next_action: "Review llm.roles and update stale or misspelled role references.",
|
|
379
|
+
details: findings.uniq
|
|
380
|
+
)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
check(
|
|
384
|
+
id: "role-hygiene",
|
|
385
|
+
kind: "hygiene",
|
|
386
|
+
status: PASS,
|
|
387
|
+
message: "Role/default hygiene looks clean"
|
|
388
|
+
)
|
|
389
|
+
rescue => e
|
|
390
|
+
check(
|
|
391
|
+
id: "role-hygiene",
|
|
392
|
+
kind: "hygiene",
|
|
393
|
+
status: WARN,
|
|
394
|
+
message: "Role hygiene check failed: #{e.message}",
|
|
395
|
+
next_action: "Review llm.roles and provider configuration."
|
|
396
|
+
)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def check_probe_readiness(provider_context, no_probe:, probe:, role_targets:, structural_blockers:, progress_io: nil)
|
|
400
|
+
if no_probe
|
|
401
|
+
return check(
|
|
402
|
+
id: "probe-readiness",
|
|
403
|
+
kind: "health",
|
|
404
|
+
status: SKIP,
|
|
405
|
+
message: "Live provider probes disabled by --no-probe"
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
if structural_blockers
|
|
409
|
+
return check(
|
|
410
|
+
id: "probe-readiness",
|
|
411
|
+
kind: "health",
|
|
412
|
+
status: SKIP,
|
|
413
|
+
message: "Live provider probes skipped because setup blockers exist",
|
|
414
|
+
next_action: "Fix blocker checks, then rerun ace-config doctor --probe."
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
unless provider_context
|
|
418
|
+
return check(
|
|
419
|
+
id: "probe-readiness",
|
|
420
|
+
kind: "health",
|
|
421
|
+
status: WARN,
|
|
422
|
+
message: "Probe readiness skipped: provider context unavailable",
|
|
423
|
+
next_action: "Verify provider discovery first."
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
targets = order_probe_targets(dedupe_targets(role_targets), provider_context)
|
|
428
|
+
if targets.empty?
|
|
429
|
+
return check(
|
|
430
|
+
id: "probe-readiness",
|
|
431
|
+
kind: "health",
|
|
432
|
+
status: WARN,
|
|
433
|
+
message: "No resolved utility provider targets available for probes",
|
|
434
|
+
next_action: "Define llm.roles._utility or llm.roles.commit, then rerun ace-config doctor."
|
|
435
|
+
)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
progress = provider_progress(progress_io, targets)
|
|
439
|
+
progress&.start
|
|
440
|
+
|
|
441
|
+
outcomes = run_probe_targets(targets, progress: progress)
|
|
442
|
+
pass_count = outcomes.count { |outcome| outcome[:status] == PASS }
|
|
443
|
+
total_count = outcomes.length
|
|
444
|
+
details = progress_io ? [] : ping_detail_lines(outcomes)
|
|
445
|
+
if pass_count == total_count && total_count.positive?
|
|
446
|
+
return check(
|
|
447
|
+
id: "probe-readiness",
|
|
448
|
+
kind: "health",
|
|
449
|
+
status: PASS,
|
|
450
|
+
message: "Utility provider pings completed (#{pass_count}/#{total_count} passed)",
|
|
451
|
+
details: details,
|
|
452
|
+
outcomes: outcomes
|
|
453
|
+
)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
if pass_count.positive?
|
|
457
|
+
return check(
|
|
458
|
+
id: "probe-readiness",
|
|
459
|
+
kind: "health",
|
|
460
|
+
status: WARN,
|
|
461
|
+
message: "Utility provider pings partially completed (#{pass_count}/#{total_count} passed)",
|
|
462
|
+
details: details,
|
|
463
|
+
outcomes: outcomes,
|
|
464
|
+
next_action: "At least one utility provider works; inspect failed providers if you need full redundancy."
|
|
465
|
+
)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
next_actions = outcomes.filter_map { |o| o[:next_action] }.uniq
|
|
469
|
+
check(
|
|
470
|
+
id: "probe-readiness",
|
|
471
|
+
kind: "health",
|
|
472
|
+
status: WARN,
|
|
473
|
+
message: "Utility provider pings failed (0/#{total_count} passed)",
|
|
474
|
+
next_action: next_actions.first || "Authenticate at least one provider and rerun ace-config doctor.",
|
|
475
|
+
outcomes: outcomes,
|
|
476
|
+
details: details.empty? ? next_actions : details
|
|
477
|
+
)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def run_probe_targets(targets, progress: nil)
|
|
481
|
+
outcomes = Array.new(targets.length)
|
|
482
|
+
queue = Queue.new
|
|
483
|
+
targets.each_with_index { |target, index| queue << [index, target] }
|
|
484
|
+
|
|
485
|
+
threads = targets.length.times.map do
|
|
486
|
+
Thread.new do
|
|
487
|
+
loop do
|
|
488
|
+
index, target = queue.pop(true)
|
|
489
|
+
outcome = run_probe_target(target)
|
|
490
|
+
outcomes[index] = outcome
|
|
491
|
+
progress&.finish(index, outcome)
|
|
492
|
+
rescue ThreadError
|
|
493
|
+
break
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
threads.each(&:join)
|
|
498
|
+
|
|
499
|
+
outcomes.compact
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def run_probe_target(target)
|
|
503
|
+
selector = target[:selector] || [target[:provider], target[:model]].compact.join(":")
|
|
504
|
+
label = target[:label] || selector
|
|
505
|
+
timeout_seconds = target[:timeout_seconds] || 15
|
|
506
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
507
|
+
out, err, status = Open3.capture3(
|
|
508
|
+
"ace-llm",
|
|
509
|
+
label.to_s,
|
|
510
|
+
"ping",
|
|
511
|
+
"--no-fallback",
|
|
512
|
+
"--quiet",
|
|
513
|
+
"--timeout",
|
|
514
|
+
timeout_seconds.to_s,
|
|
515
|
+
"--max-tokens",
|
|
516
|
+
"4"
|
|
517
|
+
)
|
|
518
|
+
if status.success?
|
|
519
|
+
{
|
|
520
|
+
status: PASS,
|
|
521
|
+
provider: target[:provider].to_s,
|
|
522
|
+
label: label.to_s,
|
|
523
|
+
selector: selector.to_s,
|
|
524
|
+
provider_kind: target[:provider_kind],
|
|
525
|
+
timeout_seconds: timeout_seconds,
|
|
526
|
+
elapsed_ms: elapsed_ms(started_at)
|
|
527
|
+
}
|
|
528
|
+
else
|
|
529
|
+
failure_text = "#{out}\n#{err}"
|
|
530
|
+
{
|
|
531
|
+
status: WARN,
|
|
532
|
+
provider: target[:provider].to_s,
|
|
533
|
+
label: label.to_s,
|
|
534
|
+
selector: selector.to_s,
|
|
535
|
+
provider_kind: target[:provider_kind],
|
|
536
|
+
timeout_seconds: timeout_seconds,
|
|
537
|
+
failure_type: timeout_error?(failure_text) ? "timeout" : "error",
|
|
538
|
+
next_action: ping_next_action(failure_text, selector: selector)
|
|
539
|
+
}
|
|
540
|
+
end
|
|
541
|
+
rescue => e
|
|
542
|
+
{
|
|
543
|
+
status: WARN,
|
|
544
|
+
provider: target[:provider].to_s,
|
|
545
|
+
label: target[:label].to_s,
|
|
546
|
+
selector: selector.to_s,
|
|
547
|
+
provider_kind: target[:provider_kind],
|
|
548
|
+
timeout_seconds: timeout_seconds,
|
|
549
|
+
failure_type: timeout_error?(e.message) ? "timeout" : "error",
|
|
550
|
+
next_action: ping_next_action(e.message, selector: selector)
|
|
551
|
+
}
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def elapsed_ms(started_at)
|
|
555
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def ping_detail_lines(outcomes)
|
|
559
|
+
outcomes.map do |outcome|
|
|
560
|
+
elapsed = outcome[:elapsed_ms] ? " in #{outcome[:elapsed_ms]}ms" : ""
|
|
561
|
+
target = target_display(outcome)
|
|
562
|
+
if outcome[:status] == PASS
|
|
563
|
+
"#{target} responded#{elapsed}"
|
|
564
|
+
elsif outcome[:failure_type] == "timeout"
|
|
565
|
+
"#{target} timed out after #{outcome[:timeout_seconds]}s"
|
|
566
|
+
else
|
|
567
|
+
"#{target} failed"
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def provider_progress(io, targets)
|
|
573
|
+
return nil unless io
|
|
574
|
+
|
|
575
|
+
ProviderProgress.new(self, io, targets)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def ping_next_action(text, selector: nil)
|
|
579
|
+
if auth_related_error?(text)
|
|
580
|
+
"Authenticate at least one utility provider and rerun."
|
|
581
|
+
else
|
|
582
|
+
target = selector ? " #{selector}" : ""
|
|
583
|
+
"Run ace-llm#{target} \"ping\" --no-fallback to inspect provider setup."
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def load_provider_context
|
|
588
|
+
require "ace/llm"
|
|
589
|
+
require "ace/llm/molecules/client_registry"
|
|
590
|
+
|
|
591
|
+
registry = Ace::LLM::Molecules::ClientRegistry.new
|
|
592
|
+
{
|
|
593
|
+
registry: registry,
|
|
594
|
+
providers: registry.list_providers_with_status,
|
|
595
|
+
aliases: registry.available_aliases
|
|
596
|
+
}
|
|
597
|
+
rescue LoadError, StandardError
|
|
598
|
+
nil
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def load_role_config
|
|
602
|
+
require "ace/llm"
|
|
603
|
+
require "ace/llm/models/role_config"
|
|
604
|
+
|
|
605
|
+
Ace::LLM::Models::RoleConfig.from_hash(Ace::LLM.configuration.get("llm.roles"))
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def used_role_names(_role_config)
|
|
609
|
+
(CORE_ROLES + role_references_from_config_files).uniq.sort
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def utility_provider_targets(provider_context)
|
|
613
|
+
return [] unless provider_context
|
|
614
|
+
|
|
615
|
+
registry = provider_context[:registry]
|
|
616
|
+
role_config = load_role_config
|
|
617
|
+
utility_role = UTILITY_ROLE_GROUPS.find { |role| role_config.candidates_for(role) }
|
|
618
|
+
candidates = Array(role_config.candidates_for(utility_role)) + Array(role_config.candidates_for("commit"))
|
|
619
|
+
candidates.filter_map do |candidate|
|
|
620
|
+
parse_role_provider_target("utility", candidate, registry)
|
|
621
|
+
end
|
|
622
|
+
rescue
|
|
623
|
+
[]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def parse_role_provider_target(role, candidate, registry)
|
|
627
|
+
require "ace/llm"
|
|
628
|
+
require "ace/llm/molecules/llm_alias_resolver"
|
|
629
|
+
require "ace/llm/molecules/provider_model_parser"
|
|
630
|
+
|
|
631
|
+
alias_resolver = Ace::LLM::Molecules::LlmAliasResolver.new(registry: registry)
|
|
632
|
+
parser = Ace::LLM::Molecules::ProviderModelParser.new(alias_resolver: alias_resolver, registry: registry)
|
|
633
|
+
parsed = parser.parse(candidate.to_s)
|
|
634
|
+
return nil if parsed.invalid?
|
|
635
|
+
|
|
636
|
+
provider = parsed.provider.to_s
|
|
637
|
+
model = parsed.model.to_s
|
|
638
|
+
selector = model.empty? ? provider : "#{provider}:#{model}"
|
|
639
|
+
{role: role.to_s, provider: provider, model: model, selector: selector, label: candidate.to_s}
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def order_probe_targets(targets, provider_context)
|
|
643
|
+
targets.map.with_index do |target, index|
|
|
644
|
+
provider_kind = provider_kind(target[:provider], provider_context)
|
|
645
|
+
timeout_seconds = (provider_kind == "cli") ? 30 : 15
|
|
646
|
+
target.merge(provider_kind: provider_kind, timeout_seconds: timeout_seconds, _order: index)
|
|
647
|
+
end.sort_by do |target|
|
|
648
|
+
[(target[:provider_kind] == "api") ? 0 : 1, target[:_order]]
|
|
649
|
+
end.map { |target| target.reject { |key, _| key == :_order } }
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def provider_kind(provider_name, provider_context)
|
|
653
|
+
registry = provider_context&.fetch(:registry, nil)
|
|
654
|
+
provider_config = registry&.respond_to?(:get_provider) ? registry.get_provider(provider_name) : nil
|
|
655
|
+
klass = provider_config&.fetch("class", "").to_s
|
|
656
|
+
gem_name = provider_config&.fetch("gem", "").to_s
|
|
657
|
+
return "cli" if gem_name == PROVIDER_GEM || klass.include?("Providers::CLI")
|
|
658
|
+
|
|
659
|
+
"api"
|
|
660
|
+
rescue
|
|
661
|
+
"api"
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def role_references_from_config_files
|
|
665
|
+
config_files.flat_map do |path|
|
|
666
|
+
data = YAML.safe_load_file(path, aliases: true)
|
|
667
|
+
extract_role_references(data)
|
|
668
|
+
rescue Psych::Exception, Errno::ENOENT, Errno::EACCES
|
|
669
|
+
[]
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def config_files
|
|
674
|
+
root = project_root
|
|
675
|
+
patterns = [
|
|
676
|
+
File.join(root, ".ace", "**", "*.yml"),
|
|
677
|
+
File.join(root, ".ace", "**", "*.yaml"),
|
|
678
|
+
File.join(root, "*", ".ace-defaults", "**", "*.yml"),
|
|
679
|
+
File.join(root, "*", ".ace-defaults", "**", "*.yaml")
|
|
680
|
+
]
|
|
681
|
+
patterns.flat_map { |pattern| Dir.glob(pattern) }.select { |path| File.file?(path) }.uniq.sort
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def extract_role_references(value)
|
|
685
|
+
case value
|
|
686
|
+
when Hash
|
|
687
|
+
value.values.flat_map { |nested| extract_role_references(nested) }
|
|
688
|
+
when Array
|
|
689
|
+
value.flat_map { |nested| extract_role_references(nested) }
|
|
690
|
+
when String
|
|
691
|
+
value.scan(ROLE_REFERENCE_PATTERN).flatten
|
|
692
|
+
else
|
|
693
|
+
[]
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def validate_role_candidate(role, candidate, registry)
|
|
698
|
+
require "ace/llm"
|
|
699
|
+
require "ace/llm/molecules/llm_alias_resolver"
|
|
700
|
+
require "ace/llm/molecules/provider_model_parser"
|
|
701
|
+
|
|
702
|
+
alias_resolver = Ace::LLM::Molecules::LlmAliasResolver.new(registry: registry)
|
|
703
|
+
parser = Ace::LLM::Molecules::ProviderModelParser.new(alias_resolver: alias_resolver, registry: registry)
|
|
704
|
+
parsed = parser.parse(candidate.to_s)
|
|
705
|
+
if parsed.invalid?
|
|
706
|
+
return {
|
|
707
|
+
status: BLOCKER,
|
|
708
|
+
message: "role:#{role} candidate #{candidate} is invalid: #{parsed.error}"
|
|
709
|
+
}
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
provider = parsed.provider.to_s
|
|
713
|
+
model = parsed.model.to_s
|
|
714
|
+
unless Array(registry.models_for_provider(provider)).map(&:to_s).include?(model)
|
|
715
|
+
return {
|
|
716
|
+
status: BLOCKER,
|
|
717
|
+
message: "role:#{role} candidate #{candidate} resolves to unsupported model #{provider}:#{model}"
|
|
718
|
+
}
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
target = {role: role.to_s, provider: provider, model: model, selector: "#{provider}:#{model}"}
|
|
722
|
+
unless registry.provider_available?(provider)
|
|
723
|
+
return {
|
|
724
|
+
status: WARN,
|
|
725
|
+
message: "role:#{role} candidate #{candidate} provider #{provider} is unavailable",
|
|
726
|
+
target: target
|
|
727
|
+
}
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
if registry.provider_api_key_required?(provider) && !registry.provider_api_key_present?(provider)
|
|
731
|
+
return {
|
|
732
|
+
status: WARN,
|
|
733
|
+
message: "role:#{role} candidate #{candidate} provider #{provider} is missing credentials",
|
|
734
|
+
target: target
|
|
735
|
+
}
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
{status: PASS, target: target}
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def dedupe_targets(targets)
|
|
742
|
+
seen = {}
|
|
743
|
+
targets.each_with_object([]) do |target, result|
|
|
744
|
+
provider = target[:provider].to_s
|
|
745
|
+
next if seen[provider]
|
|
746
|
+
|
|
747
|
+
seen[provider] = true
|
|
748
|
+
result << target
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def target_display(target)
|
|
753
|
+
label = target[:label].to_s
|
|
754
|
+
selector = target[:selector].to_s
|
|
755
|
+
label = selector if label.empty?
|
|
756
|
+
selector.empty? || selector == label ? label : "#{label} (#{selector})"
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def format_probe_line(target, status:, color: false, elapsed_ms: nil)
|
|
760
|
+
glyph = status_glyph(status)
|
|
761
|
+
glyph = colorize(glyph, status) if color
|
|
762
|
+
suffix = if status == PASS && elapsed_ms
|
|
763
|
+
" in #{elapsed_ms}ms"
|
|
764
|
+
elsif status == WARN && target[:failure_type] == "timeout"
|
|
765
|
+
" timed out after #{target[:timeout_seconds]}s"
|
|
766
|
+
elsif status == WARN
|
|
767
|
+
" failed"
|
|
768
|
+
else
|
|
769
|
+
""
|
|
770
|
+
end
|
|
771
|
+
"#{glyph} #{target_display(target)}#{suffix}"
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def status_glyph(status)
|
|
775
|
+
STATUS_GLYPHS.fetch(status, STATUS_GLYPHS[WARN])
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def colorize(value, status)
|
|
779
|
+
"#{STATUS_COLORS.fetch(status, "")}#{value}#{ANSI_RESET}"
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
class ProviderProgress
|
|
783
|
+
def initialize(doctor, io, targets)
|
|
784
|
+
@doctor = doctor
|
|
785
|
+
@io = io
|
|
786
|
+
@targets = targets
|
|
787
|
+
@tty = io.respond_to?(:tty?) && io.tty?
|
|
788
|
+
@line_count = 0
|
|
789
|
+
@mutex = Mutex.new
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def start
|
|
793
|
+
@io.puts "RUN Utility provider pings running (0/#{@targets.length} passed)"
|
|
794
|
+
@targets.each do |target|
|
|
795
|
+
@io.puts " #{format_line(target, status: "running")}"
|
|
796
|
+
end
|
|
797
|
+
@line_count = @targets.length
|
|
798
|
+
flush
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def finish(index, outcome)
|
|
802
|
+
@mutex.synchronize do
|
|
803
|
+
if @tty
|
|
804
|
+
rewrite_line(index, outcome)
|
|
805
|
+
else
|
|
806
|
+
@io.puts " #{format_line(outcome, status: outcome[:status], elapsed_ms: outcome[:elapsed_ms])}"
|
|
807
|
+
end
|
|
808
|
+
flush
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
private
|
|
813
|
+
|
|
814
|
+
def rewrite_line(index, outcome)
|
|
815
|
+
return append_line(outcome) if @line_count.zero?
|
|
816
|
+
|
|
817
|
+
up = @line_count - index
|
|
818
|
+
@io.print "\e[#{up}A" if up.positive?
|
|
819
|
+
@io.print "\r\e[2K #{format_line(outcome, status: outcome[:status], elapsed_ms: outcome[:elapsed_ms])}\n"
|
|
820
|
+
down = up - 1
|
|
821
|
+
@io.print "\e[#{down}B" if down.positive?
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def append_line(outcome)
|
|
825
|
+
@io.puts " #{format_line(outcome, status: outcome[:status], elapsed_ms: outcome[:elapsed_ms])}"
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def format_line(target, status:, elapsed_ms: nil)
|
|
829
|
+
@doctor.send(:format_probe_line, target, status: status, color: @tty, elapsed_ms: elapsed_ms)
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def flush
|
|
833
|
+
@io.flush if @io.respond_to?(:flush)
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
def find_stale_aliases(provider_context)
|
|
838
|
+
providers = provider_context[:providers]
|
|
839
|
+
aliases = provider_context[:aliases] || {}
|
|
840
|
+
provider_models = providers.to_h do |provider|
|
|
841
|
+
[provider[:name].to_s, Array(provider[:models]).map(&:to_s)]
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
stale = []
|
|
845
|
+
|
|
846
|
+
model_aliases = aliases[:model] || aliases["model"] || {}
|
|
847
|
+
model_aliases.each do |provider_name, mapping|
|
|
848
|
+
(mapping || {}).each do |alias_name, model_name|
|
|
849
|
+
next if valid_provider_model_target?(provider_models, provider_name, model_name)
|
|
850
|
+
|
|
851
|
+
stale << {provider: provider_name.to_s, alias: alias_name.to_s, resolved: model_name.to_s}
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
global_aliases = aliases[:global] || aliases["global"] || {}
|
|
856
|
+
registry = provider_context[:registry]
|
|
857
|
+
global_aliases.each do |alias_name, target|
|
|
858
|
+
provider_name, model_name = parse_provider_model_target(resolve_alias_target(registry, target))
|
|
859
|
+
next unless provider_name && model_name
|
|
860
|
+
next if valid_provider_model_target?(provider_models, provider_name, model_name)
|
|
861
|
+
|
|
862
|
+
stale << {provider: provider_name, alias: alias_name.to_s, resolved: model_name}
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
stale
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def append_check(checks, check_row, stream: false, io: nil, skip_ids: [])
|
|
869
|
+
checks << check_row
|
|
870
|
+
output_progress_check(check_row, io: io) if stream && io && !Array(skip_ids).include?(check_row[:id])
|
|
871
|
+
check_row
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def output_progress_check(check_row, io:)
|
|
875
|
+
return if check_row[:kind] == "hygiene"
|
|
876
|
+
|
|
877
|
+
io.puts "#{check_row[:status].upcase} #{check_row[:message]}"
|
|
878
|
+
flush_io(io)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def build_summary(checks)
|
|
882
|
+
health_checks = checks.select { |check_row| check_row[:kind] == "health" }
|
|
883
|
+
info_checks = checks.select { |check_row| check_row[:kind] == "info" }
|
|
884
|
+
hygiene_checks = checks.select { |check_row| check_row[:kind] == "hygiene" }
|
|
885
|
+
{
|
|
886
|
+
generated_at: Time.now.utc.iso8601,
|
|
887
|
+
blocker_count: health_checks.count { |check_row| check_row[:status] == BLOCKER },
|
|
888
|
+
warning_count: health_checks.count { |check_row| check_row[:status] == WARN },
|
|
889
|
+
info_count: info_checks.length,
|
|
890
|
+
health: {
|
|
891
|
+
blocker_count: health_checks.count { |check_row| check_row[:status] == BLOCKER },
|
|
892
|
+
warning_count: health_checks.count { |check_row| check_row[:status] == WARN }
|
|
893
|
+
},
|
|
894
|
+
info: {
|
|
895
|
+
count: info_checks.length
|
|
896
|
+
},
|
|
897
|
+
hygiene: {
|
|
898
|
+
finding_count: hygiene_finding_count(hygiene_checks),
|
|
899
|
+
warning_count: hygiene_checks.count { |check_row| check_row[:status] == WARN },
|
|
900
|
+
blocker_count: hygiene_checks.count { |check_row| check_row[:status] == BLOCKER }
|
|
901
|
+
},
|
|
902
|
+
checks: checks
|
|
903
|
+
}
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
def build_stats(checks)
|
|
907
|
+
health_checks = checks.select { |check_row| check_row[:kind] == "health" }
|
|
908
|
+
info_checks = checks.select { |check_row| check_row[:kind] == "info" }
|
|
909
|
+
probe_check = health_checks.find { |check_row| check_row[:id] == "probe-readiness" }
|
|
910
|
+
provider_outcomes = Array(probe_check&.fetch(:outcomes, []))
|
|
911
|
+
config_check = info_checks.find { |check_row| check_row[:id] == "config-defaults" }
|
|
912
|
+
skill_sync_check = health_checks.find { |check_row| check_row[:id] == "skill-sync" }
|
|
913
|
+
{
|
|
914
|
+
health_checks: health_checks.length,
|
|
915
|
+
info_checks: info_checks.length,
|
|
916
|
+
provider_targets: provider_outcomes.length,
|
|
917
|
+
provider_passed: provider_outcomes.count { |outcome| outcome[:status] == PASS },
|
|
918
|
+
hygiene_findings: hygiene_finding_count(checks.select { |check_row| check_row[:kind] == "hygiene" }),
|
|
919
|
+
config_defaults: config_check&.fetch(:summary, nil),
|
|
920
|
+
skill_sync: skill_sync_check&.fetch(:skill_sync, nil)
|
|
921
|
+
}
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def health_blocking?(checks)
|
|
925
|
+
checks.any? { |check_row| check_row[:kind] == "health" && check_row[:status] == BLOCKER }
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def flush_io(io)
|
|
929
|
+
io.flush if io.respond_to?(:flush)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def hygiene_finding_count(checks)
|
|
933
|
+
checks.sum do |check_row|
|
|
934
|
+
details = Array(check_row[:details])
|
|
935
|
+
if details.any?
|
|
936
|
+
details.length
|
|
937
|
+
elsif check_row[:status] == PASS
|
|
938
|
+
0
|
|
939
|
+
else
|
|
940
|
+
1
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def check(id:, kind: "health", status:, message:, next_action: nil, **extra)
|
|
946
|
+
{id: id, kind: kind, status: status, message: message, next_action: next_action}.merge(extra)
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def discovery_next_action(stderr_output)
|
|
950
|
+
text = stderr_output.to_s
|
|
951
|
+
if text.include?(PROVIDER_GEM)
|
|
952
|
+
"Install #{PROVIDER_GEM} and rerun ace-llm --list-providers."
|
|
953
|
+
else
|
|
954
|
+
"Run ace-llm --list-providers to inspect provider setup, then rerun ace-config doctor."
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def auth_related_error?(text)
|
|
959
|
+
value = text.to_s.downcase
|
|
960
|
+
value.include?("credential") ||
|
|
961
|
+
value.include?("api key") ||
|
|
962
|
+
value.include?("auth") ||
|
|
963
|
+
value.include?("login")
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def timeout_error?(text)
|
|
967
|
+
value = text.to_s.downcase
|
|
968
|
+
value.include?("timed out") || value.include?("timeout")
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
def collect_config_defaults_summary
|
|
972
|
+
root = project_root
|
|
973
|
+
details = []
|
|
974
|
+
customized = 0
|
|
975
|
+
default = 0
|
|
976
|
+
files = 0
|
|
977
|
+
|
|
978
|
+
Models::ConfigTemplates.all_gems.each do |gem_name|
|
|
979
|
+
source_dir = Models::ConfigTemplates.example_dir_for(gem_name)
|
|
980
|
+
next unless source_dir && Dir.exist?(source_dir)
|
|
981
|
+
|
|
982
|
+
gem_files = Dir.glob(File.join(source_dir, "**", "*")).select { |path| File.file?(path) }
|
|
983
|
+
next if gem_files.empty?
|
|
984
|
+
|
|
985
|
+
gem_customized = 0
|
|
986
|
+
gem_default = 0
|
|
987
|
+
gem_files.each do |source_file|
|
|
988
|
+
relative = Pathname.new(source_file).relative_path_from(Pathname.new(source_dir)).to_s
|
|
989
|
+
target_file = File.join(root, ".ace", relative)
|
|
990
|
+
files += 1
|
|
991
|
+
if File.exist?(target_file) && File.read(source_file) != File.read(target_file)
|
|
992
|
+
customized += 1
|
|
993
|
+
gem_customized += 1
|
|
994
|
+
else
|
|
995
|
+
default += 1
|
|
996
|
+
gem_default += 1
|
|
997
|
+
end
|
|
998
|
+
rescue
|
|
999
|
+
default += 1
|
|
1000
|
+
gem_default += 1
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
details << "#{gem_name}: #{gem_customized} customized, #{gem_default} default"
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
{files: files, customized: customized, default: default, details: details}
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def skill_sync_detail(entry)
|
|
1010
|
+
provider = entry.fetch("provider")
|
|
1011
|
+
expected = entry.fetch("expected", 0).to_i
|
|
1012
|
+
in_sync = entry.fetch("in_sync", 0).to_i
|
|
1013
|
+
missing = entry.fetch("missing", 0).to_i
|
|
1014
|
+
outdated = entry.fetch("outdated", 0).to_i
|
|
1015
|
+
extra = entry.fetch("extra", 0).to_i
|
|
1016
|
+
"#{provider}: #{in_sync}/#{expected} in sync, #{missing} missing, #{outdated} outdated, #{extra} extra"
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
def project_root
|
|
1020
|
+
Ace::Support::Config.find_project_root(start_path: Dir.pwd) || Dir.pwd
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def gitignore_entry_present?(content, entry)
|
|
1024
|
+
target = canonical_ignore_token(entry)
|
|
1025
|
+
content.each_line.any? do |line|
|
|
1026
|
+
normalized = canonical_ignore_token(line)
|
|
1027
|
+
next false if normalized.nil?
|
|
1028
|
+
|
|
1029
|
+
normalized == target || normalized.start_with?("#{target}/")
|
|
1030
|
+
end
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
def valid_provider_model_target?(provider_models, provider_name, model_name)
|
|
1034
|
+
expected = provider_models[provider_name.to_s] || []
|
|
1035
|
+
!expected.empty? && expected.include?(model_name.to_s)
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
def resolve_alias_target(registry, target)
|
|
1039
|
+
value = target.to_s
|
|
1040
|
+
return value unless registry&.respond_to?(:resolve_alias)
|
|
1041
|
+
|
|
1042
|
+
registry.resolve_alias(value).to_s
|
|
1043
|
+
rescue StandardError
|
|
1044
|
+
value
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def parse_provider_model_target(value)
|
|
1048
|
+
parts = value.to_s.split(":", 2)
|
|
1049
|
+
return [nil, nil] if parts.length != 2
|
|
1050
|
+
|
|
1051
|
+
provider, model = parts
|
|
1052
|
+
return [nil, nil] if provider.to_s.empty? || model.to_s.empty?
|
|
1053
|
+
|
|
1054
|
+
[provider.to_s, model.to_s]
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
def canonical_ignore_token(value)
|
|
1058
|
+
token = value.to_s.strip
|
|
1059
|
+
return nil if token.empty? || token.start_with?("#") || token.start_with?("!")
|
|
1060
|
+
|
|
1061
|
+
token = token.delete_prefix("/")
|
|
1062
|
+
token = token.delete_suffix("/")
|
|
1063
|
+
token
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
end
|