diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 8643a0c..2686c2c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -679,6 +679,16 @@ truth as the structured output. ## Stage 10 — Implement Required Output Initialization and Finalization +**Completed 2026-09-24.** A filesystem output session now checks active +destination collisions (including symlink and hardlink aliases), preflights the +transcript without truncation, initializes all enabled report files, and replaces +final files through owned temporary files. It attempts every report after a +finalization error and makes one failed-status pass while retaining any published +transcript. Temporary-directory tests cover collisions, unavailable parents, +disabled reporting, stdout mode, overwrite/permissions, initialization failure, +and partial finalization. `go test ./internal/core/diagnostics` and +`git diff --check` passed. CLI sequencing remains for Stage 12. + **Work:** Add the output-session filesystem adapter described in G. Resolve active paths, protect collisions before truncation, preflight transcript writes without destroying existing content, initialize all enabled reports, and publish final files diff --git a/internal/core/diagnostics/artifacts.go b/internal/core/diagnostics/artifacts.go index 6b30bd9..95b1e9c 100644 --- a/internal/core/diagnostics/artifacts.go +++ b/internal/core/diagnostics/artifacts.go @@ -1,11 +1,5 @@ package diagnostics -import ( - "path/filepath" - - "gitea.maximumdirect.net/eric/audita/internal/core/reporting" -) - const ( ArtifactSourceTranscript = "source-transcript.json" ArtifactParsedSourceTranscript = "source-transcript-parsed.json" @@ -19,29 +13,5 @@ const ( ArtifactCorrectionLedger = "correction-ledger.json" ArtifactInvocationMetadata = "invocation.json" ArtifactEffectiveConfig = "effective-config.redacted.yaml" - ArtifactReport = "report.json" ArtifactErrorLog = "error.log" ) - -func BuildDiagnosticsMetadata(runDirectoryPath string, runSucceeded bool) reporting.DiagnosticsMetadata { - metadata := reporting.DiagnosticsMetadata{ - DirectoryPath: runDirectoryPath, - SourceTranscriptPath: filepath.Join(runDirectoryPath, ArtifactSourceTranscript), - ParsedSourceTranscriptPath: filepath.Join(runDirectoryPath, ArtifactParsedSourceTranscript), - NormalizedTranscriptPath: filepath.Join(runDirectoryPath, ArtifactNormalizedTranscript), - NormalizationSummaryPath: filepath.Join(runDirectoryPath, ArtifactNormalizationSummary), - ChunkingSummaryPath: filepath.Join(runDirectoryPath, ArtifactChunkingSummary), - UtilizationSummaryPath: filepath.Join(runDirectoryPath, ArtifactUtilizationSummary), - LLMExecutionPath: filepath.Join(runDirectoryPath, ArtifactLLMExecution), - SelectedProfilesPath: filepath.Join(runDirectoryPath, ArtifactSelectedProfiles), - CorrectionLedgerPath: filepath.Join(runDirectoryPath, ArtifactCorrectionLedger), - InvocationMetadataPath: filepath.Join(runDirectoryPath, ArtifactInvocationMetadata), - RedactedEffectiveConfigPath: filepath.Join(runDirectoryPath, ArtifactEffectiveConfig), - } - - if !runSucceeded { - metadata.ErrorLogPath = filepath.Join(runDirectoryPath, ArtifactErrorLog) - } - - return metadata -} diff --git a/internal/core/diagnostics/artifacts_test.go b/internal/core/diagnostics/artifacts_test.go deleted file mode 100644 index 7540870..0000000 --- a/internal/core/diagnostics/artifacts_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package diagnostics - -import ( - "path/filepath" - "testing" -) - -func TestBuildDiagnosticsMetadataSuccessPathsMatchArtifactConstants(t *testing.T) { - runPath := filepath.Join("tmp", "run-123") - metadata := BuildDiagnosticsMetadata(runPath, true) - - if metadata.DirectoryPath != runPath { - t.Fatalf("unexpected diagnostics directory path: got=%q want=%q", metadata.DirectoryPath, runPath) - } - if metadata.SourceTranscriptPath != filepath.Join(runPath, ArtifactSourceTranscript) { - t.Fatalf("unexpected source transcript path: %q", metadata.SourceTranscriptPath) - } - if metadata.ParsedSourceTranscriptPath != filepath.Join(runPath, ArtifactParsedSourceTranscript) { - t.Fatalf("unexpected parsed source transcript path: %q", metadata.ParsedSourceTranscriptPath) - } - if metadata.NormalizedTranscriptPath != filepath.Join(runPath, ArtifactNormalizedTranscript) { - t.Fatalf("unexpected normalized transcript path: %q", metadata.NormalizedTranscriptPath) - } - if metadata.NormalizationSummaryPath != filepath.Join(runPath, ArtifactNormalizationSummary) { - t.Fatalf("unexpected normalization summary path: %q", metadata.NormalizationSummaryPath) - } - if metadata.ChunkingSummaryPath != filepath.Join(runPath, ArtifactChunkingSummary) { - t.Fatalf("unexpected chunking summary path: %q", metadata.ChunkingSummaryPath) - } - if metadata.UtilizationSummaryPath != filepath.Join(runPath, ArtifactUtilizationSummary) { - t.Fatalf("unexpected utilization summary path: %q", metadata.UtilizationSummaryPath) - } - if metadata.CorrectionLedgerPath != filepath.Join(runPath, ArtifactCorrectionLedger) { - t.Fatalf("unexpected correction ledger path: %q", metadata.CorrectionLedgerPath) - } - if metadata.InvocationMetadataPath != filepath.Join(runPath, ArtifactInvocationMetadata) { - t.Fatalf("unexpected invocation metadata path: %q", metadata.InvocationMetadataPath) - } - if metadata.RedactedEffectiveConfigPath != filepath.Join(runPath, ArtifactEffectiveConfig) { - t.Fatalf("unexpected redacted effective config path: %q", metadata.RedactedEffectiveConfigPath) - } - if metadata.ErrorLogPath != "" { - t.Fatalf("did not expect error log path on success: %q", metadata.ErrorLogPath) - } -} - -func TestBuildDiagnosticsMetadataFailureIncludesErrorLogPath(t *testing.T) { - runPath := filepath.Join("tmp", "run-123") - metadata := BuildDiagnosticsMetadata(runPath, false) - - want := filepath.Join(runPath, ArtifactErrorLog) - if metadata.ErrorLogPath != want { - t.Fatalf("unexpected error log path: got=%q want=%q", metadata.ErrorLogPath, want) - } -} diff --git a/internal/core/diagnostics/output_session.go b/internal/core/diagnostics/output_session.go new file mode 100644 index 0000000..a2c2101 --- /dev/null +++ b/internal/core/diagnostics/output_session.go @@ -0,0 +1,277 @@ +package diagnostics + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + ErrorsFile = "errors.yml" + RecoveriesFile = "recoveries.yml" + MarkdownFile = "report.md" +) + +// OutputSelection contains only active destinations. ProtectedPaths are inputs +// and loaded configuration/profile sources that must never be overwritten. +type OutputSelection struct { + TranscriptPath string + ReportDir string + ReportingEnabled bool + ProtectedPaths []string +} + +type ReportBundle struct { + Errors []byte + Recoveries []byte + Markdown []byte +} + +type FileFailure struct { + Path string + Kind string + Err error +} + +func (f FileFailure) Error() string { return fmt.Sprintf("%s %q: %v", f.Kind, f.Path, f.Err) } +func (f FileFailure) Unwrap() error { return f.Err } + +// OutputSession owns checked destinations and per-file publication. Filesystem +// failures remain typed so CLI can present safe, redacted diagnostics. +type OutputSession struct { + transcript string + reports [3]string + enabled bool + replace func(string, []byte) error +} + +func PrepareOutput(selection OutputSelection) (*OutputSession, error) { + session := &OutputSession{enabled: selection.ReportingEnabled, replace: atomicReplace} + active := make([]string, 0, 4) + if strings.TrimSpace(selection.TranscriptPath) != "" { + session.transcript = selection.TranscriptPath + active = append(active, session.transcript) + } + if session.enabled { + if strings.TrimSpace(selection.ReportDir) == "" { + return nil, fmt.Errorf("report directory is required") + } + info, err := os.Stat(selection.ReportDir) + if err != nil { + return nil, fmt.Errorf("report directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("report destination is not a directory") + } + session.reports = [3]string{ + filepath.Join(selection.ReportDir, ErrorsFile), + filepath.Join(selection.ReportDir, RecoveriesFile), + filepath.Join(selection.ReportDir, MarkdownFile), + } + active = append(active, session.reports[:]...) + } + protected := make([]string, 0, len(selection.ProtectedPaths)) + for _, path := range selection.ProtectedPaths { + if strings.TrimSpace(path) != "" { + protected = append(protected, path) + } + } + for i, path := range active { + if path != session.transcript { + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("report output %q is not a regular file", path) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("report output %q: %w", path, err) + } + } + for _, source := range protected { + collision, err := sameDestination(path, source) + if err != nil { + return nil, err + } + if collision { + return nil, fmt.Errorf("output destination %q conflicts with input %q", path, source) + } + } + for _, prior := range active[:i] { + collision, err := sameDestination(path, prior) + if err != nil { + return nil, err + } + if collision { + return nil, fmt.Errorf("output destinations %q and %q conflict", path, prior) + } + } + } + if session.transcript != "" { + if err := preflightTranscript(session.transcript); err != nil { + return nil, err + } + } + return session, nil +} + +func (s *OutputSession) ReportPaths() []string { + if s == nil || !s.enabled { + return nil + } + return []string{s.reports[0], s.reports[1], s.reports[2]} +} + +func (s *OutputSession) TranscriptPath() string { + if s == nil { + return "" + } + return s.transcript +} + +// Initialize writes all report destinations before input/model processing. On +// failure it makes one best-effort pass to mark already-created reports failed. +func (s *OutputSession) Initialize(initial, failed ReportBundle) error { + if s == nil || !s.enabled { + return nil + } + created := make([]int, 0, len(s.reports)) + for i, payload := range bundleParts(initial) { + if err := s.replace(s.reports[i], payload); err != nil { + for _, prior := range created { + _ = s.replace(s.reports[prior], bundleParts(failed)[prior]) + } + return FileFailure{Path: s.reports[i], Kind: "report initialization", Err: err} + } + created = append(created, i) + } + return nil +} + +// Finalize publishes the transcript first, then errors, recoveries, and Markdown. +// An empty transcript payload means processing failed and no transcript is written. +// Every enabled report is attempted even after an earlier write failure. +func (s *OutputSession) Finalize(transcript []byte, reports, failed ReportBundle) []FileFailure { + if s == nil { + return []FileFailure{{Kind: "output finalization", Err: fmt.Errorf("output session is required")}} + } + failures := make([]FileFailure, 0) + if transcript != nil && s.transcript != "" { + if err := s.replace(s.transcript, transcript); err != nil { + failures = append(failures, FileFailure{Path: s.transcript, Kind: "transcript write", Err: err}) + } + } + if s.enabled { + for i, payload := range bundleParts(reports) { + if err := s.replace(s.reports[i], payload); err != nil { + failures = append(failures, FileFailure{Path: s.reports[i], Kind: "report write", Err: err}) + } + } + if len(failures) > 0 { + for i, payload := range bundleParts(failed) { + _ = s.replace(s.reports[i], payload) + } + } + } + return failures +} + +func bundleParts(bundle ReportBundle) [3][]byte { + return [3][]byte{bundle.Errors, bundle.Recoveries, bundle.Markdown} +} + +func preflightTranscript(path string) error { + info, err := os.Stat(path) + if err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("transcript output %q is not a regular file", path) + } + file, openErr := os.OpenFile(path, os.O_WRONLY, 0) + if openErr != nil { + return fmt.Errorf("transcript output %q cannot be opened: %w", path, openErr) + } + if closeErr := file.Close(); closeErr != nil { + return fmt.Errorf("transcript output %q cannot be closed: %w", path, closeErr) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("transcript output %q: %w", path, err) + } + parent := filepath.Dir(path) + probe, err := os.CreateTemp(parent, ".audita-probe-*") + if err != nil { + return fmt.Errorf("transcript output parent %q: %w", parent, err) + } + name := probe.Name() + closeErr := probe.Close() + removeErr := os.Remove(name) + if closeErr != nil { + return fmt.Errorf("transcript output probe close: %w", closeErr) + } + if removeErr != nil { + return fmt.Errorf("transcript output probe removal: %w", removeErr) + } + return nil +} + +func sameDestination(a, b string) (bool, error) { + ca, err := canonicalDestination(a) + if err != nil { + return false, err + } + cb, err := canonicalDestination(b) + if err != nil { + return false, err + } + if ca == cb { + return true, nil + } + ai, ae := os.Stat(a) + bi, be := os.Stat(b) + return ae == nil && be == nil && os.SameFile(ai, bi), nil +} + +func canonicalDestination(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + abs = filepath.Clean(abs) + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved, nil + } + parent, err := filepath.EvalSymlinks(filepath.Dir(abs)) + if err == nil { + return filepath.Join(parent, filepath.Base(abs)), nil + } + return abs, nil +} + +func atomicReplace(path string, payload []byte) error { + publishPath, err := canonicalDestination(path) + if err != nil { + return err + } + parent := filepath.Dir(publishPath) + temp, err := os.CreateTemp(parent, ".audita-write-*") + if err != nil { + return err + } + name := temp.Name() + defer os.Remove(name) + mode := os.FileMode(0644) + if info, err := os.Stat(publishPath); err == nil { + mode = info.Mode().Perm() + } + if err := temp.Chmod(mode); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(payload); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(name, publishPath) +} diff --git a/internal/core/diagnostics/output_session_test.go b/internal/core/diagnostics/output_session_test.go new file mode 100644 index 0000000..949cc0f --- /dev/null +++ b/internal/core/diagnostics/output_session_test.go @@ -0,0 +1,201 @@ +package diagnostics + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareOutputRejectsDirectAndAliasCollisions(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "input.json") + if err := os.WriteFile(input, []byte("source"), 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias.json") + if err := os.Symlink(input, alias); err != nil { + t.Fatal(err) + } + hardlink := filepath.Join(dir, "hardlink.json") + if err := os.Link(input, hardlink); err != nil { + t.Fatal(err) + } + for _, output := range []string{input, alias, hardlink} { + if _, err := PrepareOutput(OutputSelection{TranscriptPath: output, ProtectedPaths: []string{input}}); err == nil { + t.Fatalf("collision accepted: %s", output) + } + } + if _, err := PrepareOutput(OutputSelection{TranscriptPath: filepath.Join(dir, ErrorsFile), ReportDir: dir, ReportingEnabled: true}); err == nil { + t.Fatal("report/transcript collision accepted") + } + if _, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true, ProtectedPaths: []string{filepath.Join(dir, ErrorsFile)}}); err == nil { + t.Fatal("report/source collision accepted") + } + if err := os.Mkdir(filepath.Join(dir, MarkdownFile), 0700); err != nil { + t.Fatal(err) + } + if _, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true}); err == nil { + t.Fatal("directory report target accepted") + } + if got, err := os.ReadFile(input); err != nil || string(got) != "source" { + t.Fatalf("source changed: %s %v", got, err) + } +} + +func TestPrepareOutputPreservesExistingTranscriptAndChecksParent(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "out.json") + if err := os.WriteFile(output, []byte("old content"), 0600); err != nil { + t.Fatal(err) + } + session, err := PrepareOutput(OutputSelection{TranscriptPath: output}) + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(output); err != nil || string(got) != "old content" { + t.Fatalf("preflight truncated output: %s %v", got, err) + } + if failures := session.Finalize([]byte("new content"), ReportBundle{}, ReportBundle{}); len(failures) != 0 { + t.Fatal(failures) + } + if got, err := os.ReadFile(output); err != nil || string(got) != "new content" { + t.Fatalf("publication: %s %v", got, err) + } + if info, _ := os.Stat(output); info.Mode().Perm() != 0600 { + t.Fatalf("permission changed: %v", info.Mode()) + } + if _, err := PrepareOutput(OutputSelection{TranscriptPath: filepath.Join(dir, "missing", "out.json")}); err == nil { + t.Fatal("absent parent accepted") + } + if _, err := PrepareOutput(OutputSelection{TranscriptPath: dir}); err == nil { + t.Fatal("directory target accepted") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".audita-") { + t.Fatalf("probe left behind: %s", entry.Name()) + } + } +} + +func TestDisabledReportingIgnoresUnusableDirectory(t *testing.T) { + dir := t.TempDir() + session, err := PrepareOutput(OutputSelection{TranscriptPath: filepath.Join(dir, "out.json"), ReportDir: filepath.Join(dir, "missing"), ReportingEnabled: false}) + if err != nil || len(session.ReportPaths()) != 0 { + t.Fatalf("disabled reports probed: %+v %v", session, err) + } + if err := session.Initialize(ReportBundle{}, ReportBundle{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "missing")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("disabled destination created: %v", err) + } +} + +func TestEnabledReportingRequiresExistingDirectory(t *testing.T) { + dir := t.TempDir() + if _, err := PrepareOutput(OutputSelection{ReportDir: filepath.Join(dir, "missing"), ReportingEnabled: true}); err == nil { + t.Fatal("missing report directory accepted") + } + if _, err := os.Stat(filepath.Join(dir, "missing")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("report parent created: %v", err) + } +} + +func TestStdoutModeInitializesAndFinalizesReports(t *testing.T) { + dir := t.TempDir() + session, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true}) + if err != nil { + t.Fatal(err) + } + initial := ReportBundle{Errors: []byte("in progress errors"), Recoveries: []byte("in progress recoveries"), Markdown: []byte("in progress report")} + finished := ReportBundle{Errors: []byte("completed errors"), Recoveries: []byte("completed recoveries"), Markdown: []byte("completed report")} + if err := session.Initialize(initial, ReportBundle{}); err != nil { + t.Fatal(err) + } + if session.TranscriptPath() != "" { + t.Fatal("stdout mode acquired transcript path") + } + for i, path := range session.ReportPaths() { + got, err := os.ReadFile(path) + if err != nil || string(got) != string(bundleParts(initial)[i]) { + t.Fatalf("initial %s: %s %v", path, got, err) + } + } + if failures := session.Finalize(nil, finished, ReportBundle{}); len(failures) != 0 { + t.Fatal(failures) + } + for i, path := range session.ReportPaths() { + got, err := os.ReadFile(path) + if err != nil || string(got) != string(bundleParts(finished)[i]) { + t.Fatalf("final %s: %s %v", path, got, err) + } + } +} + +func TestInitializeFailureMarksPriorReportFailedBeforeModelWork(t *testing.T) { + dir := t.TempDir() + session, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true}) + if err != nil { + t.Fatal(err) + } + actual := session.replace + session.replace = func(path string, payload []byte) error { + if filepath.Base(path) == RecoveriesFile && string(payload) == "initial" { + return os.ErrPermission + } + return actual(path, payload) + } + modelCalled := false + if err := session.Initialize(ReportBundle{Errors: []byte("initial"), Recoveries: []byte("initial"), Markdown: []byte("initial")}, ReportBundle{Errors: []byte("failed"), Recoveries: []byte("failed"), Markdown: []byte("failed")}); err == nil { + modelCalled = true + } + if modelCalled { + t.Fatal("model work continued after initialization failure") + } + got, err := os.ReadFile(filepath.Join(dir, ErrorsFile)) + if err != nil || string(got) != "failed" { + t.Fatalf("prior report not failed: %s %v", got, err) + } + if _, err := os.Stat(filepath.Join(dir, MarkdownFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("later report created: %v", err) + } +} + +func TestFinalizationCollectsFailuresAndRetainsPublishedTranscript(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "output.json") + session, err := PrepareOutput(OutputSelection{TranscriptPath: output, ReportDir: dir, ReportingEnabled: true}) + if err != nil { + t.Fatal(err) + } + if err := session.Initialize(ReportBundle{Errors: []byte("initial"), Recoveries: []byte("initial"), Markdown: []byte("initial")}, ReportBundle{}); err != nil { + t.Fatal(err) + } + actual := session.replace + session.replace = func(path string, payload []byte) error { + if filepath.Base(path) == ErrorsFile && string(payload) == "complete" { + return os.ErrPermission + } + return actual(path, payload) + } + complete := ReportBundle{Errors: []byte("complete"), Recoveries: []byte("complete"), Markdown: []byte("complete")} + failed := ReportBundle{Errors: []byte("failed"), Recoveries: []byte("failed"), Markdown: []byte("failed")} + failures := session.Finalize([]byte("transcript"), complete, failed) + if len(failures) != 1 || failures[0].Path != filepath.Join(dir, ErrorsFile) { + t.Fatalf("failures: %+v", failures) + } + if got, err := os.ReadFile(output); err != nil || string(got) != "transcript" { + t.Fatalf("transcript erased: %s %v", got, err) + } + for _, path := range session.ReportPaths() { + if got, err := os.ReadFile(path); err != nil || string(got) != "failed" { + t.Fatalf("failed-status pass %s: %s %v", path, got, err) + } + } +} diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go index e9022d8..85c3a8c 100644 --- a/internal/core/diagnostics/run_dir.go +++ b/internal/core/diagnostics/run_dir.go @@ -9,7 +9,6 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/chunking" "gitea.maximumdirect.net/eric/audita/internal/core/normalization" "gitea.maximumdirect.net/eric/audita/internal/core/redaction" - "gitea.maximumdirect.net/eric/audita/internal/core/reporting" "gitea.maximumdirect.net/eric/audita/internal/core/schema" ) @@ -174,20 +173,6 @@ func (r *RunDirectory) WriteNormalizationSummary(summary *normalization.Normaliz return nil } -// WriteReport writes the authoritative report artifact -func (r *RunDirectory) WriteReport(report reporting.ProcessReport) error { - reportPath := filepath.Join(r.path, ArtifactReport) - bytes, err := r.redactor.JSON(report) - if err != nil { - return fmt.Errorf("failed to marshal report: %w", err) - } - bytes = append(bytes, '\n') - if err := os.WriteFile(reportPath, bytes, 0o644); err != nil { - return fmt.Errorf("failed to write report: %w", err) - } - return nil -} - // WriteErrorLog writes an error log on failure func (r *RunDirectory) WriteErrorLog(errorMessage string) error { errorPath := filepath.Join(r.path, ArtifactErrorLog)