Skip to content

Commit cacd08d

Browse files
feat(doctor): add operational diagnostics and repair
Merge doctor diagnostics, safe repair, and MCP mem_doctor.
2 parents 68d618a + 4bf6c4d commit cacd08d

33 files changed

Lines changed: 3511 additions & 14 deletions

cmd/engram/doctor.go

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"strings"
9+
10+
"github.com/Gentleman-Programming/engram/internal/diagnostic"
11+
"github.com/Gentleman-Programming/engram/internal/store"
12+
)
13+
14+
func cmdDoctor(cfg store.Config) {
15+
if len(os.Args) > 2 && os.Args[2] == "repair" {
16+
cmdDoctorRepair(cfg)
17+
return
18+
}
19+
jsonOut := false
20+
project := ""
21+
check := ""
22+
for i := 2; i < len(os.Args); i++ {
23+
switch os.Args[i] {
24+
case "--json":
25+
jsonOut = true
26+
case "--project":
27+
if i+1 >= len(os.Args) {
28+
fmt.Fprintln(os.Stderr, "error: --project requires a value")
29+
exitFunc(1)
30+
return
31+
}
32+
project = os.Args[i+1]
33+
i++
34+
case "--check":
35+
if i+1 >= len(os.Args) {
36+
fmt.Fprintln(os.Stderr, "error: --check requires a value")
37+
exitFunc(1)
38+
return
39+
}
40+
check = os.Args[i+1]
41+
i++
42+
case "--help", "-h", "help":
43+
printDoctorUsage()
44+
return
45+
default:
46+
fmt.Fprintf(os.Stderr, "error: unknown doctor argument %q\n", os.Args[i])
47+
printDoctorUsage()
48+
exitFunc(1)
49+
return
50+
}
51+
}
52+
53+
project, _ = store.NormalizeProject(project)
54+
s, err := storeNew(cfg)
55+
if err != nil {
56+
fatal(err)
57+
return
58+
}
59+
defer s.Close()
60+
61+
report, err := runDiagnostics(context.Background(), s, strings.TrimSpace(project), strings.TrimSpace(check))
62+
if err != nil {
63+
report = diagnostic.ErrorReport(project, err)
64+
if jsonOut {
65+
writeDoctorJSON(report)
66+
} else {
67+
fmt.Fprintf(os.Stderr, "engram doctor failed: %s\n", err)
68+
}
69+
if errors.Is(err, diagnostic.ErrInvalidCheck) {
70+
exitFunc(1)
71+
}
72+
return
73+
}
74+
75+
if jsonOut {
76+
writeDoctorJSON(report)
77+
return
78+
}
79+
renderDoctorText(report)
80+
}
81+
82+
func printDoctorUsage() {
83+
fmt.Fprintln(os.Stdout, "usage: engram doctor [--json] [--project PROJECT] [--check CODE]")
84+
fmt.Fprintln(os.Stdout, " engram doctor repair --project PROJECT --check CODE (--plan|--dry-run|--apply)")
85+
fmt.Fprintln(os.Stdout, "checks: "+strings.Join(diagnostic.RegisteredCodes(), ", "))
86+
}
87+
88+
func cmdDoctorRepair(cfg store.Config) {
89+
project := ""
90+
check := ""
91+
mode := diagnostic.RepairMode("")
92+
modeCount := 0
93+
for i := 3; i < len(os.Args); i++ {
94+
switch os.Args[i] {
95+
case "--project":
96+
if i+1 >= len(os.Args) {
97+
failDoctorRepair("--project requires a value")
98+
return
99+
}
100+
project = os.Args[i+1]
101+
i++
102+
case "--check":
103+
if i+1 >= len(os.Args) {
104+
failDoctorRepair("--check requires a value")
105+
return
106+
}
107+
check = os.Args[i+1]
108+
i++
109+
case "--plan":
110+
mode = diagnostic.RepairModePlan
111+
modeCount++
112+
case "--dry-run":
113+
mode = diagnostic.RepairModeDryRun
114+
modeCount++
115+
case "--apply":
116+
mode = diagnostic.RepairModeApply
117+
modeCount++
118+
case "--help", "-h", "help":
119+
printDoctorUsage()
120+
return
121+
default:
122+
failDoctorRepair(fmt.Sprintf("unknown doctor repair argument %q", os.Args[i]))
123+
return
124+
}
125+
}
126+
127+
project, _ = store.NormalizeProject(project)
128+
project = strings.TrimSpace(project)
129+
check = strings.TrimSpace(check)
130+
if project == "" {
131+
failDoctorRepair("--project is required")
132+
return
133+
}
134+
if check == "" {
135+
failDoctorRepair("--check is required")
136+
return
137+
}
138+
if modeCount != 1 {
139+
failDoctorRepair("exactly one of --plan, --dry-run, or --apply is required")
140+
return
141+
}
142+
if !isSupportedDoctorRepairCheck(check) {
143+
failDoctorRepair("unsupported repair check " + check)
144+
return
145+
}
146+
147+
s, err := storeNew(cfg)
148+
if err != nil {
149+
fatal(err)
150+
return
151+
}
152+
defer s.Close()
153+
154+
ctx := context.Background()
155+
report, err := runDiagnostics(ctx, s, project, check)
156+
if err != nil {
157+
failDoctorRepair(err.Error())
158+
return
159+
}
160+
plan, err := diagnostic.BuildRepairPlan(ctx, diagnostic.Scope{Store: s, Project: project}, report, check, mode)
161+
if err != nil {
162+
failDoctorRepair(err.Error())
163+
return
164+
}
165+
actions := make([]store.SessionProjectReclassification, 0, len(plan.Actions))
166+
for _, action := range plan.Actions {
167+
actions = append(actions, store.SessionProjectReclassification{SessionID: action.SessionID, FromProject: action.FromProject, ToProject: action.ToProject})
168+
}
169+
if mode == diagnostic.RepairModeApply && len(actions) > 0 {
170+
counts, err := s.EstimateSessionProjectReclassification(actions)
171+
if err != nil {
172+
failDoctorRepair(err.Error())
173+
return
174+
}
175+
plan.Counts.SessionsPlanned = counts.Sessions
176+
plan.Counts.ObservationsPlanned = counts.Observations
177+
plan.Counts.PromptsPlanned = counts.Prompts
178+
result, err := s.ApplySessionProjectReclassification(actions)
179+
if err != nil {
180+
failDoctorRepair(err.Error())
181+
return
182+
}
183+
plan.Status = "applied"
184+
plan.BackupPath = result.BackupPath
185+
plan.Counts.SessionsApplied = result.Counts.Sessions
186+
plan.Counts.ObservationsApplied = result.Counts.Observations
187+
plan.Counts.PromptsApplied = result.Counts.Prompts
188+
} else {
189+
counts, err := s.EstimateSessionProjectReclassification(actions)
190+
if err != nil {
191+
failDoctorRepair(err.Error())
192+
return
193+
}
194+
plan.Counts.SessionsPlanned = counts.Sessions
195+
plan.Counts.ObservationsPlanned = counts.Observations
196+
plan.Counts.PromptsPlanned = counts.Prompts
197+
}
198+
writeDoctorRepairJSON(plan)
199+
}
200+
201+
func isSupportedDoctorRepairCheck(check string) bool {
202+
switch check {
203+
case diagnostic.CheckSessionProjectDirectoryMismatch, diagnostic.CheckManualSessionNameProjectMismatch:
204+
return true
205+
default:
206+
return false
207+
}
208+
}
209+
210+
func failDoctorRepair(message string) {
211+
fmt.Fprintln(os.Stderr, "engram doctor repair failed: "+message)
212+
printDoctorUsage()
213+
exitFunc(1)
214+
}
215+
216+
func writeDoctorRepairJSON(plan diagnostic.RepairPlan) {
217+
out, err := jsonMarshalIndent(plan, "", " ")
218+
if err != nil {
219+
fatal(err)
220+
return
221+
}
222+
fmt.Println(string(out))
223+
}
224+
225+
func writeDoctorJSON(report diagnostic.Report) {
226+
out, err := jsonMarshalIndent(report, "", " ")
227+
if err != nil {
228+
fatal(err)
229+
return
230+
}
231+
fmt.Println(string(out))
232+
}
233+
234+
func renderDoctorText(report diagnostic.Report) {
235+
fmt.Printf("Engram Doctor: %s\n", report.Status)
236+
if report.Project != "" {
237+
fmt.Printf("Project: %s\n", report.Project)
238+
}
239+
fmt.Printf("Checks: %d ok=%d warnings=%d blocked=%d errors=%d\n\n", report.Summary.Total, report.Summary.OK, report.Summary.Warnings, report.Summary.Blocked, report.Summary.Errors)
240+
for _, check := range report.Checks {
241+
fmt.Printf("[%s] %s — %s\n", check.Result, check.CheckID, check.Message)
242+
if check.Why != "" {
243+
fmt.Printf(" why: %s\n", check.Why)
244+
}
245+
if check.SafeNextStep != "" {
246+
fmt.Printf(" next: %s\n", check.SafeNextStep)
247+
}
248+
for _, finding := range check.Findings {
249+
fmt.Printf(" - %s: %s\n", finding.ReasonCode, finding.Message)
250+
if len(finding.Evidence) > 0 {
251+
fmt.Printf(" evidence: %s\n", string(finding.Evidence))
252+
}
253+
}
254+
}
255+
}

0 commit comments

Comments
 (0)