Make debug evidence opt-in and retain all candidates
This commit is contained in:
@@ -199,3 +199,30 @@ func TestFinalizationCollectsFailuresAndRetainsPublishedTranscript(t *testing.T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugRunDirectoryUsesSuppliedIDAndIsRetained(t *testing.T) {
|
||||
parent := filepath.Join(t.TempDir(), "debug")
|
||||
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected parent: %v", err)
|
||||
}
|
||||
run, err := NewRunDirectory(parent, "run-123", "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Path() != filepath.Join(parent, "run-123") {
|
||||
t.Fatalf("run ID changed: %s", run.Path())
|
||||
}
|
||||
if _, err := NewRunDirectory(parent, "run-123"); err == nil {
|
||||
t.Fatal("duplicate run ID accepted")
|
||||
}
|
||||
if err := run.WriteJSONArtifact(ArtifactCorrectionLedger, map[string]string{"proposal": "secret"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(filepath.Join(run.Path(), ArtifactCorrectionLedger))
|
||||
if err != nil || strings.Contains(string(contents), "secret") {
|
||||
t.Fatalf("debug artifact: %s %v", contents, err)
|
||||
}
|
||||
if _, err := os.Stat(run.Path()); err != nil {
|
||||
t.Fatalf("debug directory removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldRetainRunDirectoryMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input RetentionDecisionInput
|
||||
want bool
|
||||
}{
|
||||
{name: "always success keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: true}, want: true},
|
||||
{name: "always failure keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: false}, want: true},
|
||||
{name: "never success keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: true}, want: true},
|
||||
{name: "never failure keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: false}, want: true},
|
||||
{name: "auto success no skips removes", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: false}, want: false},
|
||||
{name: "auto success skips keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: true}, want: true},
|
||||
{name: "auto failure keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: false}, want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ShouldRetainRunDirectory(tc.input)
|
||||
if got != tc.want {
|
||||
t.Fatalf("unexpected retain decision: got=%v want=%v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionRemovesWhenDecisionSaysRemove(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
runPath := filepath.Join(workDir, "run-test")
|
||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir run path: %v", err)
|
||||
}
|
||||
|
||||
runDir := &RunDirectory{path: runPath, retention: "auto"}
|
||||
err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyRetention failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(runPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected run directory removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionReturnsRemovalError(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
runPath := filepath.Join(parent, "run-test")
|
||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir run path: %v", err)
|
||||
}
|
||||
|
||||
// Make parent non-writable so removing child fails.
|
||||
if err := os.Chmod(parent, 0o500); err != nil {
|
||||
t.Fatalf("chmod parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chmod(parent, 0o700)
|
||||
})
|
||||
|
||||
runDir := &RunDirectory{path: runPath, retention: "auto"}
|
||||
err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false})
|
||||
if err == nil {
|
||||
t.Fatalf("expected removal error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
||||
@@ -16,42 +18,15 @@ import (
|
||||
type RunDirectory struct {
|
||||
redactor redaction.Redactor
|
||||
path string
|
||||
retention string
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
type RetentionDecisionInput struct {
|
||||
RetentionMode string
|
||||
RunSucceeded bool
|
||||
HasSkippedCorrections bool
|
||||
}
|
||||
|
||||
func ShouldRetainRunDirectory(input RetentionDecisionInput) bool {
|
||||
// Failed runs are always retained.
|
||||
if !input.RunSucceeded {
|
||||
return true
|
||||
}
|
||||
|
||||
switch input.RetentionMode {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return true
|
||||
case "auto":
|
||||
return input.HasSkippedCorrections
|
||||
default:
|
||||
// Be conservative for unknown values.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// InvocationMetadata captures non-secret invocation details for diagnostics.
|
||||
type InvocationMetadata struct {
|
||||
Operation string `json:"operation"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
GlossaryPath string `json:"glossary_path"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
ReportJSONPath string `json:"report_json_path,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
ConfigSource string `json:"config_source,omitempty"`
|
||||
Overlay string `json:"overlay,omitempty"`
|
||||
@@ -63,9 +38,12 @@ type InvocationMetadata struct {
|
||||
}
|
||||
|
||||
// NewRunDirectory creates a new run directory under the configured work dir
|
||||
func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirectory, error) {
|
||||
if workDir == "" {
|
||||
workDir = ".audita-runs"
|
||||
func NewRunDirectory(workDir, runID string, secrets ...string) (*RunDirectory, error) {
|
||||
if strings.TrimSpace(workDir) == "" {
|
||||
return nil, fmt.Errorf("debug work directory is required")
|
||||
}
|
||||
if strings.TrimSpace(runID) == "" || filepath.Base(runID) != runID || runID == "." || runID == ".." {
|
||||
return nil, fmt.Errorf("valid run ID is required")
|
||||
}
|
||||
|
||||
// Create work directory if it doesn't exist
|
||||
@@ -73,8 +51,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
|
||||
return nil, fmt.Errorf("failed to create work directory %q: %w", workDir, err)
|
||||
}
|
||||
|
||||
// Create a unique run directory identifier.
|
||||
runID := fmt.Sprintf("run-%d", time.Now().UTC().UnixNano())
|
||||
runPath := filepath.Join(workDir, runID)
|
||||
|
||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
||||
@@ -84,7 +60,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
|
||||
return &RunDirectory{
|
||||
path: runPath,
|
||||
redactor: redaction.New(secrets),
|
||||
retention: retention,
|
||||
createdAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
@@ -126,9 +101,19 @@ func (r *RunDirectory) WriteEffectiveConfig(effectiveYAML []byte) error {
|
||||
|
||||
// WriteSourceTranscript writes the source transcript artifact
|
||||
func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript, raw []byte) error {
|
||||
// Write raw source for reference
|
||||
// Preserve the source snapshot without copying unredacted input bytes.
|
||||
var source any
|
||||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&source); err != nil {
|
||||
return fmt.Errorf("decode source snapshot: %w", err)
|
||||
}
|
||||
sourceBytes, err := r.redactor.JSON(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("redact source snapshot: %w", err)
|
||||
}
|
||||
sourcePath := filepath.Join(r.path, ArtifactSourceTranscript)
|
||||
if err := os.WriteFile(sourcePath, raw, 0o644); err != nil {
|
||||
if err := os.WriteFile(sourcePath, append(sourceBytes, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write source transcript: %w", err)
|
||||
}
|
||||
|
||||
@@ -149,7 +134,7 @@ func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript
|
||||
// WriteNormalizedTranscript writes the normalized transcript artifact
|
||||
func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error {
|
||||
normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript)
|
||||
bytes, err := schema.TranscriptToJSON(transcript)
|
||||
bytes, err := r.redactor.JSON(transcript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize normalized transcript: %w", err)
|
||||
}
|
||||
@@ -218,15 +203,3 @@ func (r *RunDirectory) WriteYAMLArtifact(name string, payload []byte) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error {
|
||||
decision := input
|
||||
if decision.RetentionMode == "" {
|
||||
decision.RetentionMode = r.retention
|
||||
}
|
||||
|
||||
if ShouldRetainRunDirectory(decision) {
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(r.path)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user