diff --git a/internal/cli/run.go b/internal/cli/run.go index fee41a9..ac63999 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -307,7 +307,9 @@ func runProcess(args []string, stdout, stderr io.Writer) int { if runDir != nil { _ = runDir.WriteReport(report) - _ = runDir.ApplyRetention(false) + _ = runDir.ApplyRetention(diagnostics.RetentionDecisionInput{ + RunSucceeded: false, + }) } fmt.Fprintf(stderr, "audita process: %v\n", runErr) @@ -320,7 +322,9 @@ func runProcess(args []string, stdout, stderr io.Writer) int { if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil { if runDir != nil { _ = runDir.WriteErrorLog(fmt.Sprintf("report_write: %v", err)) - _ = runDir.ApplyRetention(false) + _ = runDir.ApplyRetention(diagnostics.RetentionDecisionInput{ + RunSucceeded: false, + }) } fmt.Fprintf(stderr, "audita process: %v\n", err) return 1 @@ -329,7 +333,10 @@ func runProcess(args []string, stdout, stderr io.Writer) int { if runDir != nil { _ = runDir.WriteReport(report) - if err := runDir.ApplyRetention(true); err != nil { + if err := runDir.ApplyRetention(diagnostics.RetentionDecisionInput{ + RunSucceeded: true, + HasSkippedCorrections: false, // Hook for future module-level skipped-correction reporting. + }); err != nil { fmt.Fprintf(stderr, "audita process: failed to apply work-dir retention: %v\n", err) return 1 } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 05a2fce..f5994cc 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -478,6 +478,98 @@ func TestRunProcessFailedRunRetainedWithNeverRetention(t *testing.T) { } } +func TestRunProcessSuccessfulRunAutoRetentionRemovesRunDir(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + workDir := t.TempDir() + reportPath := filepath.Join(t.TempDir(), "report.json") + + exitCode := Run([]string{ + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--work-dir", + workDir, + "--work-dir-retention", + "auto", + "--report-json", + reportPath, + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + + entries, err := os.ReadDir(workDir) + if err != nil { + t.Fatalf("failed to read work dir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected auto retention to remove successful clean run dir, found %d entries", len(entries)) + } + + // Explicit report path must still exist even if run dir is removed. + if _, err := os.Stat(reportPath); err != nil { + t.Fatalf("expected --report-json output to survive run-dir removal: %v", err) + } +} + +func TestRunProcessSuccessfulRunAlwaysRetentionKeepsRunDir(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + workDir := t.TempDir() + + exitCode := Run([]string{ + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--work-dir", + workDir, + "--work-dir-retention", + "always", + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + + runPath := onlyRunDir(t, workDir) + if _, err := os.Stat(runPath); err != nil { + t.Fatalf("expected run dir retained under always: %v", err) + } +} + +func TestRunProcessFailedRunRetainedWithAutoRetention(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + workDir := t.TempDir() + + exitCode := Run([]string{ + "process", + schemaFixturePath("transcript_empty_speaker.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--work-dir", + workDir, + "--work-dir-retention", + "auto", + }, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) + } + + runPath := onlyRunDir(t, workDir) + if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil { + t.Fatalf("expected error.log in retained failed run under auto: %v", err) + } +} + func TestRunProcessReportJSONIncludesChunkingSummary(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/core/diagnostics/retention_test.go b/internal/core/diagnostics/retention_test.go new file mode 100644 index 0000000..548d55e --- /dev/null +++ b/internal/core/diagnostics/retention_test.go @@ -0,0 +1,72 @@ +package diagnostics + +import ( + "os" + "path/filepath" + "testing" +) + +func TestShouldRetainRunDirectoryMatrix(t *testing.T) { + tests := []struct { + name string + input RetentionDecisionInput + want bool + }{ + {name: "always success keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: true}, want: true}, + {name: "always failure keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: false}, want: true}, + {name: "never success keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: true}, want: true}, + {name: "never failure keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: false}, want: true}, + {name: "auto success no skips removes", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: false}, want: false}, + {name: "auto success skips keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: true}, want: true}, + {name: "auto failure keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: false}, want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ShouldRetainRunDirectory(tc.input) + if got != tc.want { + t.Fatalf("unexpected retain decision: got=%v want=%v", got, tc.want) + } + }) + } +} + +func TestApplyRetentionRemovesWhenDecisionSaysRemove(t *testing.T) { + workDir := t.TempDir() + runPath := filepath.Join(workDir, "run-test") + if err := os.Mkdir(runPath, 0o755); err != nil { + t.Fatalf("mkdir run path: %v", err) + } + + runDir := &RunDirectory{path: runPath, retention: "auto"} + err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false}) + if err != nil { + t.Fatalf("ApplyRetention failed: %v", err) + } + + if _, err := os.Stat(runPath); !os.IsNotExist(err) { + t.Fatalf("expected run directory removed, stat err=%v", err) + } +} + +func TestApplyRetentionReturnsRemovalError(t *testing.T) { + parent := t.TempDir() + runPath := filepath.Join(parent, "run-test") + if err := os.Mkdir(runPath, 0o755); err != nil { + t.Fatalf("mkdir run path: %v", err) + } + + // Make parent non-writable so removing child fails. + if err := os.Chmod(parent, 0o500); err != nil { + t.Fatalf("chmod parent: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(parent, 0o700) + }) + + runDir := &RunDirectory{path: runPath, retention: "auto"} + err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false}) + if err == nil { + t.Fatalf("expected removal error, got nil") + } +} diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go index 4bead05..c9ac1a1 100644 --- a/internal/core/diagnostics/run_dir.go +++ b/internal/core/diagnostics/run_dir.go @@ -21,6 +21,31 @@ type RunDirectory struct { createdAt time.Time } +type RetentionDecisionInput struct { + RetentionMode string + RunSucceeded bool + HasSkippedCorrections bool +} + +func ShouldRetainRunDirectory(input RetentionDecisionInput) bool { + // Failed runs are always retained. + if !input.RunSucceeded { + return true + } + + switch input.RetentionMode { + case "always": + return true + case "never": + return true + case "auto": + return input.HasSkippedCorrections + default: + // Be conservative for unknown values. + return true + } +} + // InvocationMetadata captures non-secret invocation details for diagnostics. type InvocationMetadata struct { Operation string `json:"operation"` @@ -187,20 +212,14 @@ func (r *RunDirectory) WriteChunkingSummary(summary *chunking.DetailedSummary) e return nil } -// TODO: In later phases, implement full "auto" semantics based on skip/report outcomes. -func (r *RunDirectory) ApplyRetention(runSucceeded bool) error { - if !runSucceeded { - // Failed runs are always retained. - return nil +func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error { + decision := input + if decision.RetentionMode == "" { + decision.RetentionMode = r.retention } - switch r.retention { - case "always": - return nil - case "never", "auto": - return os.RemoveAll(r.path) - default: - // Retain by default for unknown modes; config validation should prevent this. + if ShouldRetainRunDirectory(decision) { return nil } + return os.RemoveAll(r.path) }