package diagnostics import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "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/schema" ) // RunDirectory represents a per-run diagnostics directory type RunDirectory struct { redactor redaction.Redactor path string createdAt time.Time } // 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"` ConfigPath string `json:"config_path,omitempty"` ConfigSource string `json:"config_source,omitempty"` Overlay string `json:"overlay,omitempty"` ConfigVersion *int `json:"config_version,omitempty"` TranscriptDescription string `json:"transcript_description,omitempty"` Modules []string `json:"modules"` RunID string `json:"run_id"` StartedAt time.Time `json:"started_at"` } // NewRunDirectory creates a new run directory under the configured work dir 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 if err := os.MkdirAll(workDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create work directory %q: %w", workDir, err) } runPath := filepath.Join(workDir, runID) if err := os.Mkdir(runPath, 0o755); err != nil { return nil, fmt.Errorf("failed to create run directory %q: %w", runPath, err) } return &RunDirectory{ path: runPath, redactor: redaction.New(secrets), createdAt: time.Now().UTC(), }, nil } // Path returns the run directory path func (r *RunDirectory) Path() string { return r.path } func (r *RunDirectory) runID() string { return filepath.Base(r.path) } // WriteInvocationMetadata writes invocation metadata for this run. func (r *RunDirectory) WriteInvocationMetadata(metadata InvocationMetadata) error { if metadata.RunID == "" { metadata.RunID = r.runID() } if metadata.StartedAt.IsZero() { metadata.StartedAt = r.createdAt } path := filepath.Join(r.path, ArtifactInvocationMetadata) bytes, err := r.redactor.JSON(metadata) if err != nil { return fmt.Errorf("failed to marshal invocation metadata: %w", err) } bytes = append(bytes, '\n') if err := os.WriteFile(path, bytes, 0o644); err != nil { return fmt.Errorf("failed to write invocation metadata: %w", err) } return nil } // WriteEffectiveConfig writes redacted effective config metadata for this run. func (r *RunDirectory) WriteEffectiveConfig(effectiveYAML []byte) error { return r.WriteYAMLArtifact(ArtifactEffectiveConfig, effectiveYAML) } // WriteSourceTranscript writes the source transcript artifact func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript, raw []byte) error { // 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, append(sourceBytes, '\n'), 0o644); err != nil { return fmt.Errorf("failed to write source transcript: %w", err) } // Write parsed source for debugging parsedPath := filepath.Join(r.path, ArtifactParsedSourceTranscript) parsedBytes, err := r.redactor.JSON(transcript) if err != nil { return fmt.Errorf("failed to marshal parsed source transcript: %w", err) } parsedBytes = append(parsedBytes, '\n') if err := os.WriteFile(parsedPath, parsedBytes, 0o644); err != nil { return fmt.Errorf("failed to write parsed source transcript: %w", err) } return nil } // WriteNormalizedTranscript writes the normalized transcript artifact func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error { normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript) bytes, err := r.redactor.JSON(transcript) if err != nil { return fmt.Errorf("failed to serialize normalized transcript: %w", err) } if err := os.WriteFile(normalizedPath, bytes, 0o644); err != nil { return fmt.Errorf("failed to write normalized transcript: %w", err) } return nil } // WriteNormalizationSummary writes the normalization summary artifact func (r *RunDirectory) WriteNormalizationSummary(summary *normalization.NormalizationSummary) error { summaryPath := filepath.Join(r.path, ArtifactNormalizationSummary) bytes, err := r.redactor.JSON(summary) if err != nil { return fmt.Errorf("failed to marshal normalization summary: %w", err) } bytes = append(bytes, '\n') if err := os.WriteFile(summaryPath, bytes, 0o644); err != nil { return fmt.Errorf("failed to write normalization summary: %w", err) } return nil } // WriteErrorLog writes an error log on failure func (r *RunDirectory) WriteErrorLog(errorMessage string) error { errorPath := filepath.Join(r.path, ArtifactErrorLog) return os.WriteFile(errorPath, []byte(r.redactor.String(errorMessage)+"\n"), 0o644) } // WriteChunkingSummary writes the chunking summary artifact func (r *RunDirectory) WriteChunkingSummary(summary *chunking.DetailedSummary) error { summaryPath := filepath.Join(r.path, ArtifactChunkingSummary) bytes, err := r.redactor.JSON(summary) if err != nil { return fmt.Errorf("failed to marshal chunking summary: %w", err) } bytes = append(bytes, '\n') if err := os.WriteFile(summaryPath, bytes, 0o644); err != nil { return fmt.Errorf("failed to write chunking summary: %w", err) } return nil } func (r *RunDirectory) WriteJSONArtifact(name string, payload any) error { artifactPath := filepath.Join(r.path, name) bytes, err := r.redactor.JSON(payload) if err != nil { return fmt.Errorf("failed to marshal %s: %w", name, err) } bytes = append(bytes, '\n') if err := os.WriteFile(artifactPath, bytes, 0o644); err != nil { return fmt.Errorf("failed to write %s: %w", name, err) } return nil } // WriteYAMLArtifact writes a sanitized YAML diagnostic artifact. func (r *RunDirectory) WriteYAMLArtifact(name string, payload []byte) error { var err error payload, err = r.redactor.YAML(payload) if err != nil { return err } if err := os.WriteFile(filepath.Join(r.path, name), payload, 0o644); err != nil { return fmt.Errorf("failed to write %s: %w", name, err) } return nil }