Add minimal process reports
This commit is contained in:
@@ -6,9 +6,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||
coreio "gitea.maximumdirect.net/eric/audita/internal/core/io"
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/reporting"
|
||||
)
|
||||
|
||||
type processInvocation struct {
|
||||
@@ -66,6 +68,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
func runProcess(args []string, stdout, stderr io.Writer) int {
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", err)
|
||||
@@ -182,13 +186,47 @@ func runProcess(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
if err := processRunner(inv, stdout); err != nil {
|
||||
completedAt := time.Now().UTC()
|
||||
if strings.TrimSpace(inv.ReportJSONPath) != "" {
|
||||
report := buildProcessReport("failed", inv, startedAt, completedAt, err.Error())
|
||||
if reportErr := reporting.WriteProcessReport(inv.ReportJSONPath, report); reportErr != nil {
|
||||
fmt.Fprintf(stderr, "audita process: %v\n", reportErr)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stderr, "audita process: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if strings.TrimSpace(inv.ReportJSONPath) != "" {
|
||||
completedAt := time.Now().UTC()
|
||||
report := buildProcessReport("success", inv, startedAt, completedAt, "")
|
||||
if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil {
|
||||
fmt.Fprintf(stderr, "audita process: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildProcessReport(status string, inv processInvocation, startedAt, completedAt time.Time, errorMessage string) reporting.ProcessReport {
|
||||
report := reporting.ProcessReport{
|
||||
Phase: "phase1-minimal",
|
||||
Status: status,
|
||||
Operation: "process",
|
||||
TranscriptPath: inv.TranscriptPath,
|
||||
GlossaryPath: inv.GlossaryPath,
|
||||
OutputPath: inv.OutputPath,
|
||||
Modules: append([]string(nil), inv.Config.Modules...),
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: &completedAt,
|
||||
}
|
||||
if errorMessage != "" {
|
||||
report.ErrorMessage = errorMessage
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
type processFlags struct {
|
||||
glossaryPath *string
|
||||
outputPath *string
|
||||
|
||||
@@ -204,6 +204,143 @@ func TestRunProcessOutputToStdout(t *testing.T) {
|
||||
assertJSONSemanticallyEqual(t, inputBytes, stdout.Bytes())
|
||||
}
|
||||
|
||||
func TestRunProcessReportJSONSuccess(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
transcriptPath := fixturePath("tiny_transcript.json")
|
||||
glossaryPath := fixturePath("tiny_glossary.yaml")
|
||||
outputPath := filepath.Join(t.TempDir(), "corrected.json")
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
|
||||
exitCode := Run([]string{
|
||||
"process",
|
||||
transcriptPath,
|
||||
"--glossary",
|
||||
glossaryPath,
|
||||
"--output",
|
||||
outputPath,
|
||||
"--report-json",
|
||||
reportPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String())
|
||||
}
|
||||
|
||||
report := readProcessReport(t, reportPath)
|
||||
if report.Status != "success" {
|
||||
t.Fatalf("expected success report status, got %q", report.Status)
|
||||
}
|
||||
if report.Operation != "process" {
|
||||
t.Fatalf("expected operation process, got %q", report.Operation)
|
||||
}
|
||||
if report.TranscriptPath != transcriptPath {
|
||||
t.Fatalf("unexpected transcript path in report: %q", report.TranscriptPath)
|
||||
}
|
||||
if report.GlossaryPath != glossaryPath {
|
||||
t.Fatalf("unexpected glossary path in report: %q", report.GlossaryPath)
|
||||
}
|
||||
if report.OutputPath != outputPath {
|
||||
t.Fatalf("unexpected output path in report: %q", report.OutputPath)
|
||||
}
|
||||
if len(report.Modules) == 0 {
|
||||
t.Fatalf("expected configured module sequence in report")
|
||||
}
|
||||
if report.StartedAt == "" || report.CompletedAt == "" {
|
||||
t.Fatalf("expected started_at and completed_at timestamps in report")
|
||||
}
|
||||
if report.ErrorMessage != "" {
|
||||
t.Fatalf("did not expect error message in success report, got %q", report.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessReportJSONNoStdoutPollution(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
transcriptPath := fixturePath("tiny_transcript.json")
|
||||
glossaryPath := fixturePath("tiny_glossary.yaml")
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
|
||||
exitCode := Run([]string{
|
||||
"process",
|
||||
transcriptPath,
|
||||
"--glossary",
|
||||
glossaryPath,
|
||||
"--report-json",
|
||||
reportPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
assertJSONSemanticallyEqual(t, readFile(t, transcriptPath), stdout.Bytes())
|
||||
|
||||
reportBytes := readFile(t, reportPath)
|
||||
if bytes.Contains(stdout.Bytes(), reportBytes) {
|
||||
t.Fatalf("stdout was polluted with report JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessReportJSONRedactsSecrets(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
secretPrimary := "top-secret-primary-key"
|
||||
secretValidation := "top-secret-validation-key"
|
||||
t.Setenv("AUDITA_LLM_API_KEY", secretPrimary)
|
||||
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secretValidation)
|
||||
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
exitCode := Run([]string{
|
||||
"process",
|
||||
fixturePath("tiny_transcript.json"),
|
||||
"--glossary",
|
||||
fixturePath("tiny_glossary.yaml"),
|
||||
"--report-json",
|
||||
reportPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
reportBytes := readFile(t, reportPath)
|
||||
if strings.Contains(string(reportBytes), secretPrimary) || strings.Contains(string(reportBytes), secretValidation) {
|
||||
t.Fatalf("report JSON leaked API key material: %q", string(reportBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
exitCode := Run([]string{
|
||||
"process",
|
||||
fixturePath("malformed_transcript.json"),
|
||||
"--glossary",
|
||||
fixturePath("tiny_glossary.yaml"),
|
||||
"--report-json",
|
||||
reportPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode == 0 {
|
||||
t.Fatalf("expected nonzero exit code for malformed transcript")
|
||||
}
|
||||
|
||||
report := readProcessReport(t, reportPath)
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("expected failed report status, got %q", report.Status)
|
||||
}
|
||||
if report.ErrorMessage == "" {
|
||||
t.Fatalf("expected error message in failed report")
|
||||
}
|
||||
if !strings.Contains(report.ErrorMessage, "not valid JSON") {
|
||||
t.Fatalf("expected malformed JSON failure in report error, got %q", report.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessCLIOverridesEnvironment(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -281,3 +418,25 @@ func assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) {
|
||||
t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual))
|
||||
}
|
||||
}
|
||||
|
||||
type minimalProcessReport struct {
|
||||
Status string `json:"status"`
|
||||
Operation string `json:"operation"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
GlossaryPath string `json:"glossary_path"`
|
||||
OutputPath string `json:"output_path"`
|
||||
Modules []string `json:"modules"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
func readProcessReport(t *testing.T, path string) minimalProcessReport {
|
||||
t.Helper()
|
||||
raw := readFile(t, path)
|
||||
var report minimalProcessReport
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw))
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
34
internal/core/reporting/report.go
Normal file
34
internal/core/reporting/report.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package reporting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProcessReport struct {
|
||||
Phase string `json:"phase"`
|
||||
Status string `json:"status"`
|
||||
Operation string `json:"operation"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
GlossaryPath string `json:"glossary_path"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
Modules []string `json:"modules"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
func WriteProcessReport(path string, report ProcessReport) error {
|
||||
payload, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal process report JSON: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
if err := os.WriteFile(path, payload, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write report JSON file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user