Add minimal process reports
This commit is contained in:
@@ -6,9 +6,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
||||||
coreio "gitea.maximumdirect.net/eric/audita/internal/core/io"
|
coreio "gitea.maximumdirect.net/eric/audita/internal/core/io"
|
||||||
|
"gitea.maximumdirect.net/eric/audita/internal/core/reporting"
|
||||||
)
|
)
|
||||||
|
|
||||||
type processInvocation struct {
|
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 {
|
func runProcess(args []string, stdout, stderr io.Writer) int {
|
||||||
|
startedAt := time.Now().UTC()
|
||||||
|
|
||||||
cfg, err := config.LoadFromEnv()
|
cfg, err := config.LoadFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", err)
|
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 {
|
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)
|
fmt.Fprintf(stderr, "audita process: %v\n", err)
|
||||||
return 1
|
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
|
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 {
|
type processFlags struct {
|
||||||
glossaryPath *string
|
glossaryPath *string
|
||||||
outputPath *string
|
outputPath *string
|
||||||
|
|||||||
@@ -204,6 +204,143 @@ func TestRunProcessOutputToStdout(t *testing.T) {
|
|||||||
assertJSONSemanticallyEqual(t, inputBytes, stdout.Bytes())
|
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) {
|
func TestRunProcessCLIOverridesEnvironment(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr 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))
|
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