Preflight and publish required outputs through an output session

This commit is contained in:
2026-09-24 17:37:04 +00:00
parent 989b28e8c5
commit a80eed4745
6 changed files with 488 additions and 100 deletions

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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)