diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index b1038cf..b23e031 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -44,6 +44,7 @@ var ( // seams so tests can substitute them without touching the environment. doctorCopilotVersionFn = defaultCopilotVersion doctorSessionCountFn = defaultSessionCount + doctorWorkspacesFn = defaultDoctorWorkspaces ) type versionOutput struct { @@ -584,6 +585,16 @@ type doctorReport struct { CopilotCLI doctorEntry `json:"copilot_cli"` CopilotVersion string `json:"copilot_version"` SessionCount int `json:"session_count"` + Workspaces workspaceReport `json:"workspaces"` +} + +// workspaceReport summarizes whether stored session working directories still +// exist on disk. +type workspaceReport struct { + Total int `json:"total"` + Missing int `json:"missing"` + Samples []string `json:"samples,omitempty"` + Error string `json:"error,omitempty"` } // collectDoctorReport gathers the environment diagnostics once so they can be @@ -620,6 +631,7 @@ func collectDoctorReport() doctorReport { } r.SessionCount = doctorSessionCountFn() + r.Workspaces = doctorWorkspacesFn() return r } @@ -664,6 +676,35 @@ func defaultSessionCount() int { return n } +func defaultDoctorWorkspaces() workspaceReport { + store, err := data.Open() + if err != nil { + return workspaceReport{Error: err.Error()} + } + defer store.Close() //nolint:errcheck // read-only, best-effort close + + folders, err := store.ListFolders(context.Background()) + if err != nil { + return workspaceReport{Error: err.Error()} + } + + r := workspaceReport{Total: len(folders)} + for _, folder := range folders { + if strings.TrimSpace(folder) == "" { + continue + } + if _, err := os.Stat(folder); err != nil { + if errors.Is(err, os.ErrNotExist) { + r.Missing++ + if len(r.Samples) < 5 { + r.Samples = append(r.Samples, folder) + } + } + } + } + return r +} + // pathStatus stats a path and reports whether it is found, missing, or the // wrong type (a file where a directory is expected, or vice versa). func pathStatus(path string, wantDir bool) string { @@ -700,6 +741,7 @@ func runDoctor(w io.Writer) { fmt.Fprintf(w, "Copilot CLI version: not detected\n") } fmt.Fprintf(w, "Stored sessions: %d\n", r.SessionCount) + writeWorkspaceLine(w, r.Workspaces) } // runDoctorJSON writes the diagnostics as a single JSON object followed by a @@ -740,6 +782,17 @@ func writeDoctorLine(w io.Writer, label string, e doctorEntry, wantDir bool) { } } +func writeWorkspaceLine(w io.Writer, r workspaceReport) { + if r.Error != "" { + fmt.Fprintf(w, "Missing workspaces: unknown (%s)\n", r.Error) + return + } + fmt.Fprintf(w, "Missing workspaces: %d of %d folders\n", r.Missing, r.Total) + for _, sample := range r.Samples { + fmt.Fprintf(w, " - %s\n", sample) + } +} + // setupLogRedirect opens the log file (if configured via DISPATCH_LOG) and // redirects stderr to it. When no log file is configured, stderr is sent to // os.DevNull to keep Bubble Tea's alt-screen clean. Returns the writer for diff --git a/cmd/dispatch/cli_test.go b/cmd/dispatch/cli_test.go index fb1c391..f7d8a59 100644 --- a/cmd/dispatch/cli_test.go +++ b/cmd/dispatch/cli_test.go @@ -196,10 +196,15 @@ func TestRunDoctor_PrintsDiagnostics(t *testing.T) { t.Setenv("DISPATCH_DB", db) t.Setenv("DISPATCH_SESSION_STATE", stateDir) - origCount, origVer := doctorSessionCountFn, doctorCopilotVersionFn + origCount, origVer, origWorkspaces := doctorSessionCountFn, doctorCopilotVersionFn, doctorWorkspacesFn doctorSessionCountFn = func() int { return 7 } doctorCopilotVersionFn = func(string) string { return "1.2.3" } - t.Cleanup(func() { doctorSessionCountFn, doctorCopilotVersionFn = origCount, origVer }) + doctorWorkspacesFn = func() workspaceReport { + return workspaceReport{Total: 3, Missing: 1, Samples: []string{filepath.Join(stateDir, "missing")}} + } + t.Cleanup(func() { + doctorSessionCountFn, doctorCopilotVersionFn, doctorWorkspacesFn = origCount, origVer, origWorkspaces + }) var buf bytes.Buffer runDoctor(&buf) @@ -213,6 +218,7 @@ func TestRunDoctor_PrintsDiagnostics(t *testing.T) { "Session state: found", "Copilot CLI:", "Stored sessions: 7", + "Missing workspaces: 1 of 3 folders", } { if !strings.Contains(out, want) { t.Errorf("doctor output missing %q:\n%s", want, out) @@ -229,9 +235,10 @@ func TestRunDoctorJSON_Shape(t *testing.T) { t.Setenv("DISPATCH_DB", db) t.Setenv("DISPATCH_SESSION_STATE", stateDir) - origCount := doctorSessionCountFn + origCount, origWorkspaces := doctorSessionCountFn, doctorWorkspacesFn doctorSessionCountFn = func() int { return 3 } - t.Cleanup(func() { doctorSessionCountFn = origCount }) + doctorWorkspacesFn = func() workspaceReport { return workspaceReport{Total: 2, Missing: 1, Samples: []string{"missing-dir"}} } + t.Cleanup(func() { doctorSessionCountFn, doctorWorkspacesFn = origCount, origWorkspaces }) var buf bytes.Buffer if err := runDoctorJSON(&buf); err != nil { @@ -257,11 +264,22 @@ func TestRunDoctorJSON_Shape(t *testing.T) { if r.SessionCount != 3 { t.Errorf("session_count = %d, want 3", r.SessionCount) } + if r.Workspaces.Missing != 1 || len(r.Workspaces.Samples) != 1 { + t.Errorf("workspaces = %+v, want one missing sample", r.Workspaces) + } if !strings.HasSuffix(buf.String(), "}\n") { t.Errorf("JSON output should end with a single newline, got:\n%q", buf.String()) } } +func TestWriteWorkspaceLine_Error(t *testing.T) { + var buf bytes.Buffer + writeWorkspaceLine(&buf, workspaceReport{Error: "store unavailable"}) + if !strings.Contains(buf.String(), "Missing workspaces: unknown") { + t.Fatalf("workspace line = %q, want unknown status", buf.String()) + } +} + func TestDefaultCopilotVersion_EmptyBinary(t *testing.T) { if got := defaultCopilotVersion(""); got != "" { t.Errorf("defaultCopilotVersion(\"\") = %q, want empty", got)