返回 DeepSeek-Reasonix
doctor.go
根目录 / internal / cli / doctor.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "flag"
7 "fmt"
8 "os"
9 "strings"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/doctor"
14 "reasonix/internal/repair"
15 "reasonix/internal/sessioncatalog"
16 )
17
18 func doctorBillingCommand(args []string) int {
19 fs := flag.NewFlagSet("doctor billing", flag.ContinueOnError)
20 jsonOut := fs.Bool("json", false, "print billing diagnostics as JSON")
21 root := fs.String("root", ".", "project root for config resolution")
22 if code, ok := parseCommandFlags(fs, args); !ok {
23 return code
24 }
25 cfg, err := config.LoadForRoot(*root)
26 if err != nil {
27 // Still report with defaults so doctor stays useful offline.
28 cfg = config.Default()
29 }
30 report := doctor.CollectBilling(cfg)
31 if *jsonOut {
32 enc := json.NewEncoder(os.Stdout)
33 enc.SetIndent("", " ")
34 if err := enc.Encode(report); err != nil {
35 fmt.Fprintln(os.Stderr, err)
36 return 1
37 }
38 return 0
39 }
40 fmt.Print(doctor.RenderBillingText(report))
41 return 0
42 }
43
44 func doctorCommand(args []string, version string) int {
45 if len(args) > 0 && args[0] == "credentials" {
46 return doctorCredentialsCommand(args[1:])
47 }
48 if len(args) > 0 && args[0] == "catalogs" {
49 return doctorCatalogsCommand(args[1:])
50 }
51 if len(args) > 0 && args[0] == "sessions" {
52 return doctorSessionsCommand(args[1:])
53 }
54 if len(args) > 0 && args[0] == "quality" {
55 return doctorQualityCommand(args[1:], version)
56 }
57 if len(args) > 0 && args[0] == "session" {
58 return doctorSessionCommand(args[1:], version)
59 }
60 if len(args) > 0 && args[0] == "redact-sessions" {
61 return doctorRedactSessionsCommand(args[1:])
62 }
63 if len(args) > 0 && args[0] == "capabilities" {
64 return doctorCapabilitiesCommand(args[1:])
65 }
66 if len(args) > 0 && args[0] == "runtime" {
67 return doctorRuntimeCommand(args[1:])
68 }
69 if len(args) > 0 && args[0] == "billing" {
70 return doctorBillingCommand(args[1:])
71 }
72 if len(args) > 0 && args[0] == "repair" {
73 return doctorRepairCommand(args[1:])
74 }
75 fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
76 jsonOut := fs.Bool("json", false, "print diagnostics as JSON")
77 if code, ok := parseCommandFlags(fs, args); !ok {
78 return code
79 }
80
81 report := doctor.Collect(doctor.Options{Version: version})
82 if *jsonOut {
83 enc := json.NewEncoder(os.Stdout)
84 enc.SetIndent("", " ")
85 if err := enc.Encode(report); err != nil {
86 fmt.Fprintln(os.Stderr, err)
87 return 1
88 }
89 return 0
90 }
91 fmt.Print(doctor.RenderText(report))
92 return 0
93 }
94
95 func doctorCredentialsCommand(args []string) int {
96 fs := flag.NewFlagSet("doctor credentials", flag.ContinueOnError)
97 jsonOut := fs.Bool("json", false, "print credential diagnostics as JSON")
98 probe := fs.Bool("probe", false, "test temporary create and atomic rename in the credential directory")
99 repairCredentials := fs.Bool("repair", false, "conservatively repair current-user credential access")
100 dryRun := fs.Bool("dry-run", false, "preview credential repairs without changing files")
101 if code, ok := parseCommandFlags(fs, args); !ok {
102 return code
103 }
104 if fs.NArg() != 0 || (*dryRun && !*repairCredentials) {
105 fmt.Fprintln(os.Stderr, "usage: reasonix doctor credentials [--json] [--probe] [--repair [--dry-run]]")
106 return 2
107 }
108 report, err := config.DiagnoseCredentials(config.CredentialDiagnosticOptions{Probe: *probe, Repair: *repairCredentials, DryRun: *dryRun})
109 if err != nil {
110 fmt.Fprintln(os.Stderr, "error:", err)
111 return 1
112 }
113 if *jsonOut {
114 enc := json.NewEncoder(os.Stdout)
115 enc.SetIndent("", " ")
116 if err := enc.Encode(report); err != nil {
117 fmt.Fprintln(os.Stderr, err)
118 return 1
119 }
120 } else {
121 fmt.Println("Reasonix credential diagnostics")
122 fmt.Println(" path:", report.CredentialPath)
123 for _, check := range report.Checks {
124 fmt.Printf(" %-24s %-11s %s\n", check.ID, check.Status, check.Message)
125 }
126 for _, action := range report.Actions {
127 fmt.Println(" action:", action)
128 }
129 }
130 for _, check := range report.Checks {
131 if check.Status == "failed" {
132 return 1
133 }
134 }
135 return 0
136 }
137
138 func doctorSessionsCommand(args []string) int {
139 fs := flag.NewFlagSet("doctor sessions", flag.ContinueOnError)
140 jsonOut := fs.Bool("json", false, "print session catalog diagnostics as JSON")
141 if code, ok := parseCommandFlags(fs, args); !ok {
142 return code
143 }
144 if fs.NArg() != 0 {
145 fmt.Fprintln(os.Stderr, "usage: reasonix doctor sessions [--json]")
146 return 2
147 }
148 status, err := sessioncatalog.Inspect(context.Background(), sessioncatalog.DefaultPath())
149 if err != nil {
150 fmt.Fprintln(os.Stderr, "error:", err)
151 return 1
152 }
153 if *jsonOut {
154 enc := json.NewEncoder(os.Stdout)
155 enc.SetIndent("", " ")
156 if err := enc.Encode(status); err != nil {
157 fmt.Fprintln(os.Stderr, err)
158 return 1
159 }
160 if status.State == sessioncatalog.StateDegraded {
161 return 1
162 }
163 return 0
164 }
165 fmt.Println("Reasonix session catalog")
166 fmt.Printf(" state: %s\n", status.State)
167 fmt.Printf(" mode: %s\n", status.Mode)
168 fmt.Printf(" revision: %d\n", status.Revision)
169 fmt.Printf(" indexed: %d\n", status.Indexed)
170 fmt.Printf(" physical sessions: %d\n", status.PhysicalSessions)
171 fmt.Printf(" logical sessions: %d\n", status.LogicalSessions)
172 fmt.Printf(" repair pending: %d\n", status.RepairPending)
173 fmt.Printf(" repair: %d active, %d deferred, %d blocked\n",
174 status.RepairActive, status.RepairDeferred, status.RepairBlocked)
175 if len(status.RepairErrorKinds) > 0 {
176 fmt.Printf(" repair error kinds: %v\n", status.RepairErrorKinds)
177 }
178 if status.LastRepairDurationMS > 0 {
179 fmt.Printf(" last repair wave: %dms\n", status.LastRepairDurationMS)
180 }
181 fmt.Printf(" recovery: %d groups, %d branches, %d diverged, %d safe cleanup\n",
182 status.RecoveryGroups, status.RecoveryBranches, status.RecoveryDiverged, status.CleanupEligible)
183 if status.LastError != "" {
184 fmt.Printf(" note: %s\n", status.LastError)
185 }
186 if status.State == sessioncatalog.StateDegraded {
187 return 1
188 }
189 return 0
190 }
191
192 func doctorRepairCommand(args []string) int {
193 fs := flag.NewFlagSet("doctor repair", flag.ContinueOnError)
194 root := fs.String("root", ".", "project root to inspect")
195 apply := fs.Bool("apply", false, "quarantine invalid config and restore the last-known-good global snapshot")
196 includeProject := fs.Bool("project", false, "allow --apply to quarantine an invalid project reasonix.toml")
197 jsonOut := fs.Bool("json", false, "print result as JSON")
198 if code, ok := parseCommandFlags(fs, args); !ok {
199 return code
200 }
201 if fs.NArg() != 0 {
202 fmt.Fprintln(os.Stderr, "usage: reasonix doctor repair [--root PATH] [--apply] [--project] [--json]")
203 return 2
204 }
205 report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{
206 Root: *root,
207 Apply: *apply,
208 IncludeProject: *includeProject,
209 })
210 if err != nil {
211 fmt.Fprintln(os.Stderr, "error:", err)
212 return 1
213 }
214 if *jsonOut {
215 enc := json.NewEncoder(os.Stdout)
216 enc.SetIndent("", " ")
217 if err := enc.Encode(report); err != nil {
218 fmt.Fprintln(os.Stderr, err)
219 return 1
220 }
221 } else {
222 fmt.Println("Reasonix repair report")
223 for _, check := range report.Checks {
224 status := "ok"
225 if !check.Exists {
226 status = "missing (defaults apply)"
227 } else if !check.Valid {
228 status = "invalid: " + check.Error
229 }
230 fmt.Printf(" %-8s %s\n %s\n", check.Scope, status, check.Path)
231 }
232 for _, action := range report.Applied {
233 fmt.Println(" applied:", action)
234 }
235 if !*apply {
236 fmt.Println(" dry run; pass --apply to repair the global config")
237 }
238 }
239 for _, check := range report.Checks {
240 if check.Exists && !check.Valid {
241 return 1
242 }
243 }
244 return 0
245 }
246
247 func doctorQualityCommand(args []string, version string) int {
248 ref := ""
249 jsonOut := false
250 for _, arg := range args {
251 switch arg {
252 case "-h", "--help":
253 fmt.Fprintln(os.Stdout, "usage: reasonix doctor quality <branch-id-or-path> [--json]")
254 fmt.Fprintln(os.Stdout, "Prints a public-safe, content-free coding-quality summary for one session.")
255 return 0
256 case "--json":
257 jsonOut = true
258 default:
259 if strings.HasPrefix(arg, "-") || ref != "" {
260 fmt.Fprintln(os.Stderr, "usage: reasonix doctor quality <branch-id-or-path> [--json]")
261 return 2
262 }
263 ref = arg
264 }
265 }
266 if ref == "" {
267 fmt.Fprintln(os.Stderr, "usage: reasonix doctor quality <branch-id-or-path> [--json]")
268 return 2
269 }
270 report, err := doctor.CollectQuality(doctor.QualityOptions{Version: version, SessionRef: ref})
271 if err != nil {
272 fmt.Fprintln(os.Stderr, "error:", err)
273 return 1
274 }
275 if jsonOut {
276 enc := json.NewEncoder(os.Stdout)
277 enc.SetIndent("", " ")
278 if err := enc.Encode(report); err != nil {
279 fmt.Fprintln(os.Stderr, err)
280 return 1
281 }
282 return 0
283 }
284 fmt.Print(doctor.RenderQualityText(report))
285 return 0
286 }
287
288 type stringListFlag []string
289
290 func (f *stringListFlag) String() string {
291 if f == nil {
292 return ""
293 }
294 return strings.Join(*f, string(os.PathListSeparator))
295 }
296
297 func (f *stringListFlag) Set(value string) error {
298 value = strings.TrimSpace(value)
299 if value == "" {
300 return fmt.Errorf("empty path")
301 }
302 *f = append(*f, value)
303 return nil
304 }
305
306 func doctorRedactSessionsCommand(args []string) int {
307 fs := flag.NewFlagSet("doctor redact-sessions", flag.ContinueOnError)
308 var dirs stringListFlag
309 dryRun := fs.Bool("dry-run", false, "show how many session files would be redacted without writing")
310 jsonOut := fs.Bool("json", false, "print result as JSON")
311 fs.Var(&dirs, "dir", "session directory to scan; repeat to scan multiple directories")
312 if code, ok := parseCommandFlags(fs, args); !ok {
313 return code
314 }
315 if fs.NArg() != 0 {
316 fmt.Fprintln(os.Stderr, "usage: reasonix doctor redact-sessions [--dry-run] [--json] [--dir PATH]")
317 return 2
318 }
319 res := doctor.RedactSessions(doctor.RedactSessionsOptions{
320 Dirs: []string(dirs),
321 DryRun: *dryRun,
322 })
323 if *jsonOut {
324 enc := json.NewEncoder(os.Stdout)
325 enc.SetIndent("", " ")
326 if err := enc.Encode(res); err != nil {
327 fmt.Fprintln(os.Stderr, err)
328 return 1
329 }
330 } else {
331 action := "redacted"
332 if *dryRun {
333 action = "would redact"
334 }
335 fmt.Fprintf(os.Stdout, "session secret cleanup %s %d/%d files", action, res.FilesChanged, res.FilesScanned)
336 if res.FilesSkipped > 0 {
337 fmt.Fprintf(os.Stdout, " (%d skipped: active lease held)", res.FilesSkipped)
338 }
339 fmt.Fprintln(os.Stdout)
340 }
341 for _, msg := range res.Errors {
342 fmt.Fprintln(os.Stderr, "warning:", msg)
343 }
344 if len(res.Errors) > 0 {
345 return 1
346 }
347 return 0
348 }
349
350 func doctorSessionCommand(args []string, version string) int {
351 ref := ""
352 outPath := ""
353 exportPath := ""
354 for i := 0; i < len(args); i++ {
355 arg := args[i]
356 switch arg {
357 case "-h", "--help":
358 fmt.Fprintln(os.Stdout, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH] [--export-v1 PATH.jsonl]")
359 fmt.Fprintln(os.Stdout, "")
360 fmt.Fprintln(os.Stdout, "Bundles the session transcript, persistence sidecars, conflict diagnostics,")
361 fmt.Fprintln(os.Stdout, "and the recovery parent chain into a zip for support. Unlike `reasonix doctor`,")
362 fmt.Fprintln(os.Stdout, "bundled transcripts are NOT redacted; share only with a trusted support channel.")
363 fmt.Fprintln(os.Stdout, "--export-v1 instead writes the session's current version as a schema-1 session")
364 fmt.Fprintln(os.Stdout, "that releases before v1.39.0 can open.")
365 return 0
366 case "--export-v1":
367 i++
368 if i >= len(args) {
369 fmt.Fprintln(os.Stderr, "error: --export-v1 requires a destination .jsonl path")
370 return 2
371 }
372 exportPath = args[i]
373 case "--zip":
374 // The subcommand currently writes a zip by default. Keep --zip as an
375 // explicit, script-friendly marker so support replies can say exactly
376 // what to run.
377 case "--out":
378 i++
379 if i >= len(args) {
380 fmt.Fprintln(os.Stderr, "error: --out requires a path")
381 return 2
382 }
383 outPath = args[i]
384 default:
385 if v, ok := strings.CutPrefix(arg, "--out="); ok {
386 if v == "" {
387 fmt.Fprintln(os.Stderr, "error: --out requires a path")
388 return 2
389 }
390 outPath = v
391 continue
392 }
393 if strings.HasPrefix(arg, "-") {
394 fmt.Fprintf(os.Stderr, "error: unknown doctor session flag %s\n", arg)
395 return 2
396 }
397 if ref != "" {
398 fmt.Fprintln(os.Stderr, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
399 return 2
400 }
401 ref = arg
402 }
403 }
404 if ref == "" {
405 fmt.Fprintln(os.Stderr, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH] [--export-v1 PATH.jsonl]")
406 return 2
407 }
408 if exportPath != "" {
409 src, err := doctor.ResolveSessionRef(ref)
410 if err != nil {
411 fmt.Fprintln(os.Stderr, "error:", err)
412 return 1
413 }
414 if err := agent.ExportSessionSchemaOne(src, exportPath); err != nil {
415 fmt.Fprintln(os.Stderr, "error:", err)
416 return 1
417 }
418 fmt.Println(exportPath)
419 return 0
420 }
421 result, err := doctor.WriteSessionBundle(doctor.SessionBundleOptions{
422 Version: version,
423 SessionRef: ref,
424 OutputPath: outPath,
425 })
426 if err != nil {
427 fmt.Fprintln(os.Stderr, "error:", err)
428 return 1
429 }
430 fmt.Println(result.Path)
431 fmt.Fprintln(os.Stderr, "note: the bundle contains full session transcripts without redaction; share it only with a trusted support channel")
432 return 0
433 }
434
434 lines GO