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

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

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)