Preserve transcript destinations that change type

This commit is contained in:
2026-11-10 20:31:03 +00:00
parent 8a2a19ccd3
commit 50940083c8
4 changed files with 78 additions and 9 deletions

View File

@@ -27,8 +27,10 @@ consumed application and PromptKit sources, and effective host message files. An
intentional in-place transcript replacement is allowed. A regular transcript
output is prepared in a sibling temporary file, leaving the final destination
untouched until successful publication. Pipes and devices are opened only for
delivery; a pipe can wait for its reader. Parent directories for required outputs
must already exist.
delivery, and a pipe can wait for its reader. If a transcript destination changes
between a regular file and a pipe or device during processing, Audita can report a
delivery failure while preserving the new destination. Parent directories for
required outputs must already exist.
Enabled reports are initialized as `in_progress` before processing. The
transcript is published before final reports; the files do not form a single

View File

@@ -384,6 +384,8 @@ keep their native behavior.
## R11 — P2: Preserve Pipes That Appear After Output Preparation
**Status:** Completed. Delivery checks the destination type before opening a prepared stream or replacing a file. Tests cover both type changes, preservation of the replacement destination, and cleanup of staged output.
**Category**
The new Stage 7 preparation changes the existing special-file delivery behavior.

View File

@@ -327,6 +327,13 @@ func (s *OutputSession) writeTranscript(payload []byte) error {
return err
}
if s.direct {
info, err := os.Stat(s.transcript)
if err != nil {
return err
}
if info.IsDir() || info.Mode().IsRegular() {
return fmt.Errorf("transcript output is no longer a stream destination")
}
file, err := os.OpenFile(s.transcript, os.O_WRONLY, 0)
if err != nil {
return err
@@ -349,6 +356,13 @@ func (s *OutputSession) writeTranscript(payload []byte) error {
if err := errors.Join(writeErr, closeErr); err != nil {
return err
}
info, err := os.Stat(s.publishPath)
if err == nil && !info.Mode().IsRegular() {
return fmt.Errorf("transcript output changed type after preparation")
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(s.tempPath, s.publishPath); err != nil {
return err
}

View File

@@ -418,14 +418,8 @@ func TestTranscriptWriteFailureCleansTemporaryResource(t *testing.T) {
}
func TestNamedPipeIsNotOpenedDuringPreparation(t *testing.T) {
tool, err := exec.LookPath("mkfifo")
if err != nil {
t.Skip("mkfifo is unavailable")
}
pipe := filepath.Join(t.TempDir(), "output-pipe")
if output, err := exec.Command(tool, pipe).CombinedOutput(); err != nil {
t.Fatalf("mkfifo: %v %s", err, output)
}
makeNamedPipe(t, pipe)
session, err := PrepareOutput(OutputSelection{TranscriptPath: pipe})
if err != nil {
t.Fatal(err)
@@ -437,3 +431,60 @@ func TestNamedPipeIsNotOpenedDuringPreparation(t *testing.T) {
t.Fatal(err)
}
}
func TestLateNamedPipeIsPreserved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "output")
session, err := PrepareOutput(OutputSelection{TranscriptPath: path})
if err != nil {
t.Fatal(err)
}
defer session.Close()
makeNamedPipe(t, path)
err = session.Finish(nil, []byte("transcript"), ReportBundle{}, nil)
if err == nil || !strings.Contains(err.Error(), "changed type") {
t.Fatalf("expected clear delivery failure, got %v", err)
}
info, err := os.Stat(path)
if err != nil || info.Mode()&os.ModeNamedPipe == 0 {
t.Fatalf("late pipe was lost: %v %v", info, err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("unpublished output resources remain: %v %v", entries, err)
}
}
func TestStreamChangedToRegularFileIsPreserved(t *testing.T) {
path := filepath.Join(t.TempDir(), "output")
makeNamedPipe(t, path)
session, err := PrepareOutput(OutputSelection{TranscriptPath: path})
if err != nil {
t.Fatal(err)
}
defer session.Close()
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("existing content"), 0600); err != nil {
t.Fatal(err)
}
err = session.Finish(nil, []byte("new"), ReportBundle{}, nil)
if err == nil || !strings.Contains(err.Error(), "no longer a stream") {
t.Fatalf("expected clear delivery failure, got %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "existing content" {
t.Fatalf("replacement file changed: %q %v", data, err)
}
}
func makeNamedPipe(t *testing.T, path string) {
t.Helper()
tool, err := exec.LookPath("mkfifo")
if err != nil {
t.Skip("mkfifo is unavailable")
}
if output, err := exec.Command(tool, path).CombinedOutput(); err != nil {
t.Fatalf("mkfifo: %v %s", err, output)
}
}