Preflight and publish required outputs through an output session
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
277
internal/core/diagnostics/output_session.go
Normal file
277
internal/core/diagnostics/output_session.go
Normal 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)
|
||||
}
|
||||
201
internal/core/diagnostics/output_session_test.go
Normal file
201
internal/core/diagnostics/output_session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user