From 28c7ab3287853e55d1c754272f53f049a67a4aa0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 4 May 2026 07:56:35 -0500 Subject: [PATCH] Implement Audita subprocess adapter --- internal/adapters/audita/fake.go | 57 ++- internal/adapters/audita/runner.go | 31 +- internal/adapters/audita/subprocess.go | 335 +++++++++++++ internal/adapters/audita/subprocess_test.go | 513 ++++++++++++++++++++ 4 files changed, 929 insertions(+), 7 deletions(-) create mode 100644 internal/adapters/audita/subprocess.go create mode 100644 internal/adapters/audita/subprocess_test.go diff --git a/internal/adapters/audita/fake.go b/internal/adapters/audita/fake.go index de28e13..b159dda 100644 --- a/internal/adapters/audita/fake.go +++ b/internal/adapters/audita/fake.go @@ -2,7 +2,10 @@ package audita import ( "context" + "encoding/json" "fmt" + "os" + "path/filepath" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" ) @@ -18,7 +21,15 @@ func (n *NoopRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, if err := materializePlaceholders(req); err != nil { return PolishResult{}, err } - return PolishResult{ProcessedTranscriptPath: req.OutputProcessedPath, Metadata: map[string]any{"placeholder": true}}, nil + return PolishResult{ + ProcessedTranscriptPath: req.OutputProcessedPath, + ReportPath: req.ReportPath, + WorkDir: req.WorkDir, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + Metadata: map[string]any{"placeholder": true}, + }, nil } // FakeRunner captures polish requests and returns deterministic responses. @@ -44,6 +55,21 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, if res.ProcessedTranscriptPath == "" { res.ProcessedTranscriptPath = req.OutputProcessedPath } + if res.ReportPath == "" { + res.ReportPath = req.ReportPath + } + if res.WorkDir == "" { + res.WorkDir = req.WorkDir + } + if res.StdoutLogPath == "" { + res.StdoutLogPath = req.StdoutLogPath + } + if res.StderrLogPath == "" { + res.StderrLogPath = req.StderrLogPath + } + if res.GeneratedConfigPath == "" { + res.GeneratedConfigPath = req.GeneratedConfigPath + } if res.Metadata == nil { res.Metadata = map[string]any{"fake": true} } @@ -72,5 +98,34 @@ func materializePlaceholders(req PolishRequest) error { return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err) } } + if err := writeJSONIfRequested(req.OutputProcessedPath, map[string]any{ + "schema": "audita.processed.v1", + "segments": []any{}, + }); err != nil { + return err + } + if err := writeJSONIfRequested(req.ReportPath, map[string]any{ + "schema": "audita.report.v1", + "steps": []any{}, + }); err != nil { + return err + } + return nil +} + +func writeJSONIfRequested(path string, payload any) error { + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create parent directory %q: %w", filepath.Dir(path), err) + } + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal placeholder json for %q: %w", path, err) + } + if err := subprocess.WriteFileAtomic(path, data, 0o644); err != nil { + return fmt.Errorf("write placeholder json %q: %w", path, err) + } return nil } diff --git a/internal/adapters/audita/runner.go b/internal/adapters/audita/runner.go index 7f73233..1fa61d7 100644 --- a/internal/adapters/audita/runner.go +++ b/internal/adapters/audita/runner.go @@ -1,7 +1,10 @@ // Package audita declares the adapter contract for transcript polishing. package audita -import "context" +import ( + "context" + "time" +) // TODO: implement a real Audita subprocess/service adapter. @@ -12,15 +15,31 @@ type Runner interface { // PolishRequest describes an audita invocation. type PolishRequest struct { - GeneratedConfigPath string - MergedTranscriptPath string - OutputProcessedPath string - StdoutLogPath string - StderrLogPath string + GeneratedConfigPath string + MergedTranscriptPath string + OutputProcessedPath string + GlossaryPath string + ReportPath string + WorkDir string + Modules []string + BaseURL string + Model string + ValidationModel string + ValidationLLMConcurrency *int + StdoutLogPath string + StderrLogPath string } // PolishResult describes a polish output. type PolishResult struct { ProcessedTranscriptPath string + ReportPath string + WorkDir string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + ExitCode int + Duration time.Duration + InvokedBinary string Metadata map[string]any } diff --git a/internal/adapters/audita/subprocess.go b/internal/adapters/audita/subprocess.go new file mode 100644 index 0000000..ca615b0 --- /dev/null +++ b/internal/adapters/audita/subprocess.go @@ -0,0 +1,335 @@ +package audita + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" +) + +// SubprocessRunnerConfig defines deterministic settings for Audita CLI execution. +type SubprocessRunnerConfig struct { + Binary string + Timeout time.Duration + LLMAPIKeyEnv string + Modules []string + BaseURL string + Model string + LLMConcurrency *int + ValidationModel string + ValidationLLMConcurrency *int + Report bool +} + +// SubprocessRunner invokes Audita via subprocess. +type SubprocessRunner struct { + binary string + timeout time.Duration + llmAPIKeyEnv string + modules []string + baseURL string + model string + llmConcurrency *int + validationModel string + validationLLMConcurrency *int + report bool +} + +// NewSubprocessRunnerFromConfigValues parses config-derived values once. +func NewSubprocessRunnerFromConfigValues( + binary string, + timeout string, + llmAPIKeyEnv string, + modules []string, + baseURL string, + model string, + llmConcurrency *int, + validationModel string, + validationLLMConcurrency *int, + report bool, +) (*SubprocessRunner, error) { + if strings.TrimSpace(timeout) == "" { + return nil, fmt.Errorf("audita timeout is required") + } + parsedTimeout, err := time.ParseDuration(timeout) + if err != nil { + return nil, fmt.Errorf("parse audita timeout %q: %w", timeout, err) + } + return NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: binary, + Timeout: parsedTimeout, + LLMAPIKeyEnv: llmAPIKeyEnv, + Modules: modules, + BaseURL: baseURL, + Model: model, + LLMConcurrency: llmConcurrency, + ValidationModel: validationModel, + ValidationLLMConcurrency: validationLLMConcurrency, + Report: report, + }) +} + +// NewSubprocessRunner constructs a validated Audita subprocess runner. +func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) { + if strings.TrimSpace(cfg.Binary) == "" { + return nil, fmt.Errorf("audita binary is required") + } + if cfg.Timeout <= 0 { + return nil, fmt.Errorf("audita timeout must be > 0") + } + if strings.TrimSpace(cfg.LLMAPIKeyEnv) == "" { + return nil, fmt.Errorf("audita llm api key env var name is required") + } + if len(cfg.Modules) == 0 { + return nil, fmt.Errorf("audita modules must include at least one module") + } + for i, module := range cfg.Modules { + if strings.TrimSpace(module) == "" { + return nil, fmt.Errorf("audita module at index %d is empty", i) + } + } + if strings.TrimSpace(cfg.BaseURL) == "" { + return nil, fmt.Errorf("audita base url is required") + } + u, err := url.Parse(cfg.BaseURL) + if err != nil || u.Scheme == "" || u.Host == "" { + if err != nil { + return nil, fmt.Errorf("audita base url %q is invalid: %w", cfg.BaseURL, err) + } + return nil, fmt.Errorf("audita base url %q is invalid", cfg.BaseURL) + } + if strings.TrimSpace(cfg.Model) == "" { + return nil, fmt.Errorf("audita model is required") + } + if cfg.LLMConcurrency != nil && *cfg.LLMConcurrency <= 0 { + return nil, fmt.Errorf("audita llm concurrency must be > 0 when provided") + } + if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 { + return nil, fmt.Errorf("audita validation llm concurrency must be > 0 when provided") + } + + modules := make([]string, len(cfg.Modules)) + for i, m := range cfg.Modules { + modules[i] = strings.TrimSpace(m) + } + + return &SubprocessRunner{ + binary: strings.TrimSpace(cfg.Binary), + timeout: cfg.Timeout, + llmAPIKeyEnv: strings.TrimSpace(cfg.LLMAPIKeyEnv), + modules: modules, + baseURL: strings.TrimSpace(cfg.BaseURL), + model: strings.TrimSpace(cfg.Model), + llmConcurrency: cfg.LLMConcurrency, + validationModel: strings.TrimSpace(cfg.ValidationModel), + validationLLMConcurrency: cfg.ValidationLLMConcurrency, + report: cfg.Report, + }, nil +} + +// Run executes Audita process with deterministic flags and validates output artifacts. +func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) { + if r == nil { + return PolishResult{}, fmt.Errorf("audita subprocess runner is nil") + } + if strings.TrimSpace(req.MergedTranscriptPath) == "" { + return PolishResult{}, fmt.Errorf("audita merged transcript path is required") + } + if strings.TrimSpace(req.GlossaryPath) == "" { + return PolishResult{}, fmt.Errorf("audita glossary path is required") + } + if strings.TrimSpace(req.OutputProcessedPath) == "" { + return PolishResult{}, fmt.Errorf("audita output processed path is required") + } + if strings.TrimSpace(req.WorkDir) == "" { + return PolishResult{}, fmt.Errorf("audita work dir is required") + } + if r.report && strings.TrimSpace(req.ReportPath) == "" { + return PolishResult{}, fmt.Errorf("audita report is enabled but report path is missing") + } + + reqModules := req.Modules + if len(reqModules) == 0 { + reqModules = append([]string(nil), r.modules...) + } + args := r.buildArgs(req, reqModules) + + credential, credentialPresent := os.LookupEnv(r.llmAPIKeyEnv) + if !credentialPresent || strings.TrimSpace(credential) == "" { + return PolishResult{}, fmt.Errorf("audita credential env var %q is required but not set", r.llmAPIKeyEnv) + } + + env := map[string]string{ + "AUDITA_LLM_API_KEY": credential, + } + primaryConcurrencyViaEnv := false + if r.llmConcurrency != nil { + env["AUDITA_LLM_CONCURRENCY"] = strconv.Itoa(*r.llmConcurrency) + primaryConcurrencyViaEnv = true + } + + if req.GeneratedConfigPath != "" { + if err := r.writeInvocationConfig(req, args, reqModules, credentialPresent, primaryConcurrencyViaEnv); err != nil { + return PolishResult{}, fmt.Errorf("write audita invocation config %q: %w", req.GeneratedConfigPath, err) + } + } + + runRes, err := subprocess.Run(ctx, subprocess.RunRequest{ + Executable: r.binary, + Args: args, + Timeout: r.timeout, + EnvOverrides: env, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + }) + if err != nil { + return r.failureResult(req, reqModules, runRes, credentialPresent, primaryConcurrencyViaEnv), fmt.Errorf("run audita process (binary=%q): %w", r.binary, err) + } + + if err := validateProcessedOutput(req.OutputProcessedPath); err != nil { + return r.failureResult(req, reqModules, runRes, credentialPresent, primaryConcurrencyViaEnv), fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err) + } + if r.report { + if err := validateJSONFile(req.ReportPath); err != nil { + return r.failureResult(req, reqModules, runRes, credentialPresent, primaryConcurrencyViaEnv), fmt.Errorf("validate audita report output %q: %w", req.ReportPath, err) + } + } + + return PolishResult{ + ProcessedTranscriptPath: req.OutputProcessedPath, + ReportPath: req.ReportPath, + WorkDir: req.WorkDir, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: r.binary, + Metadata: map[string]any{ + "adapter": "audita_subprocess", + "modules": reqModules, + "base_url": r.baseURL, + "model": r.model, + "validation_model": r.validationModel, + "validation_llm_concurrency": r.validationLLMConcurrency, + "credential_env_var": r.llmAPIKeyEnv, + "credential_present": credentialPresent, + "primary_llm_concurrency_via_env": primaryConcurrencyViaEnv, + "primary_llm_concurrency_env_name": "AUDITA_LLM_CONCURRENCY", + }, + }, nil +} + +func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, runRes subprocess.RunResult, credentialPresent bool, primaryConcurrencyViaEnv bool) PolishResult { + return PolishResult{ + ProcessedTranscriptPath: req.OutputProcessedPath, + ReportPath: req.ReportPath, + WorkDir: req.WorkDir, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + ExitCode: runRes.ExitCode, + Duration: runRes.Duration, + InvokedBinary: r.binary, + Metadata: map[string]any{ + "adapter": "audita_subprocess", + "modules": modules, + "base_url": r.baseURL, + "model": r.model, + "validation_model": r.validationModel, + "validation_llm_concurrency": r.validationLLMConcurrency, + "credential_env_var": r.llmAPIKeyEnv, + "credential_present": credentialPresent, + "primary_llm_concurrency_via_env": primaryConcurrencyViaEnv, + "primary_llm_concurrency_env_name": "AUDITA_LLM_CONCURRENCY", + }, + } +} + +func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string { + args := []string{ + "process", + req.MergedTranscriptPath, + "--glossary", req.GlossaryPath, + "--output", req.OutputProcessedPath, + "--modules", strings.Join(modules, ","), + "--base-url", r.baseURL, + "--model", r.model, + "--work-dir", req.WorkDir, + } + if r.report { + args = append(args, "--report-json", req.ReportPath) + } + if r.validationModel != "" { + args = append(args, "--validation-model", r.validationModel) + } + if r.validationLLMConcurrency != nil { + args = append(args, "--validation-llm-concurrency", strconv.Itoa(*r.validationLLMConcurrency)) + } + return args +} + +func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []string, modules []string, credentialPresent bool, primaryConcurrencyViaEnv bool) error { + payload := map[string]any{ + "schema": "audita.generated.v1", + "binary": r.binary, + "args": args, + "timeout": r.timeout.String(), + "modules": modules, + "base_url": r.baseURL, + "model": r.model, + "validation_model": r.validationModel, + "validation_llm_concurrency": r.validationLLMConcurrency, + "report_enabled": r.report, + "merged_transcript_path": req.MergedTranscriptPath, + "glossary_path": req.GlossaryPath, + "output_path": req.OutputProcessedPath, + "report_path": req.ReportPath, + "work_dir": req.WorkDir, + "credential_env_var": r.llmAPIKeyEnv, + "credential_present": credentialPresent, + "primary_llm_concurrency_via_env": primaryConcurrencyViaEnv, + } + if r.llmConcurrency != nil { + payload["llm_concurrency"] = *r.llmConcurrency + } + return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) +} + +func validateProcessedOutput(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return fmt.Errorf("parse json: %w", err) + } + segments, ok := payload["segments"] + if !ok { + return fmt.Errorf("top-level segments is required") + } + if _, ok := segments.([]any); !ok { + return fmt.Errorf("top-level segments must be an array") + } + return nil +} + +func validateJSONFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + return fmt.Errorf("parse json: %w", err) + } + return nil +} diff --git a/internal/adapters/audita/subprocess_test.go b/internal/adapters/audita/subprocess_test.go new file mode 100644 index 0000000..b90b95c --- /dev/null +++ b/internal/adapters/audita/subprocess_test.go @@ -0,0 +1,513 @@ +package audita + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + + t.Setenv("GO_WANT_AUDITA_HELPER", "1") + t.Setenv("AUDITA_HELPER_MODE", "success") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath) + + wrapper := writeAuditaHelperWrapper(t) + llmConcurrency := 1 + validationLLMConcurrency := 2 + runner, err := NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: wrapper, + Timeout: mustParseAuditaDuration(t, "2s"), + LLMAPIKeyEnv: "OPENAI_KEY_SOURCE", + Modules: []string{"glossary", "homophones", "glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + ValidationModel: "openrouter/google/gemma-4-31b-it", + ValidationLLMConcurrency: &validationLLMConcurrency, + Report: true, + }) + if err != nil { + t.Fatalf("NewSubprocessRunner() error = %v", err) + } + + dir := t.TempDir() + req := PolishRequest{ + GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), + MergedTranscriptPath: filepath.Join(dir, "merged.json"), + GlossaryPath: filepath.Join(dir, "glossary.yml"), + OutputProcessedPath: filepath.Join(dir, "processed.json"), + ReportPath: filepath.Join(dir, "audita.report.json"), + WorkDir: filepath.Join(dir, "artifacts", "audita-work"), + StdoutLogPath: filepath.Join(dir, "audita.stdout.log"), + StderrLogPath: filepath.Join(dir, "audita.stderr.log"), + } + writeAuditaTestFile(t, req.MergedTranscriptPath, `{"segments":[]}`) + writeAuditaTestFile(t, req.GlossaryPath, "terms: []\n") + + res, err := runner.Run(context.Background(), req) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if res.ProcessedTranscriptPath != req.OutputProcessedPath { + t.Fatalf("ProcessedTranscriptPath = %q, want %q", res.ProcessedTranscriptPath, req.OutputProcessedPath) + } + if res.ReportPath != req.ReportPath { + t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath) + } + if res.WorkDir != req.WorkDir { + t.Fatalf("WorkDir = %q, want %q", res.WorkDir, req.WorkDir) + } + if res.InvokedBinary != wrapper { + t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if res.Duration <= 0 { + t.Fatalf("Duration = %s, want > 0", res.Duration) + } + assertJSONFileAudita(t, req.OutputProcessedPath) + assertJSONFileAudita(t, req.ReportPath) + if _, err := os.Stat(req.StdoutLogPath); err != nil { + t.Fatalf("stdout log missing: %v", err) + } + if _, err := os.Stat(req.StderrLogPath); err != nil { + t.Fatalf("stderr log missing: %v", err) + } + + rec := readAuditaHelperRecord(t, recordPath) + wantArgs := []string{ + "process", req.MergedTranscriptPath, + "--glossary", req.GlossaryPath, + "--output", req.OutputProcessedPath, + "--modules", "glossary,homophones,glossary", + "--base-url", "https://openrouter.ai/api/v1", + "--model", "openrouter/google/gemma-4-31b-it", + "--work-dir", req.WorkDir, + "--report-json", req.ReportPath, + "--validation-model", "openrouter/google/gemma-4-31b-it", + "--validation-llm-concurrency", "2", + } + if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { + t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) + } + if rec.Env["AUDITA_LLM_API_KEY"] != "super-secret" { + t.Fatalf("AUDITA_LLM_API_KEY = %q, want propagated secret", rec.Env["AUDITA_LLM_API_KEY"]) + } + if rec.Env["AUDITA_LLM_CONCURRENCY"] != "1" { + t.Fatalf("AUDITA_LLM_CONCURRENCY = %q, want 1", rec.Env["AUDITA_LLM_CONCURRENCY"]) + } + + cfgData, err := os.ReadFile(req.GeneratedConfigPath) + if err != nil { + t.Fatalf("read generated config: %v", err) + } + if strings.Contains(string(cfgData), "super-secret") { + t.Fatalf("generated config must not contain credential value") + } +} + +func TestSubprocessRunnerMissingCredentialFails(t *testing.T) { + llmConcurrency := 1 + runner, err := NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: "audita", + Timeout: mustParseAuditaDuration(t, "1s"), + LLMAPIKeyEnv: "MISSING_AUDITA_KEY", + Modules: []string{"glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + }) + if err != nil { + t.Fatalf("NewSubprocessRunner() error = %v", err) + } + + req := PolishRequest{ + MergedTranscriptPath: "/tmp/merged.json", + GlossaryPath: "/tmp/glossary.yml", + OutputProcessedPath: "/tmp/processed.json", + WorkDir: "/tmp/audita-work", + } + _, err = runner.Run(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "MISSING_AUDITA_KEY") { + t.Fatalf("error = %q, want env var name context", err.Error()) + } +} + +func TestSubprocessRunnerSubprocessFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_AUDITA_HELPER", "1") + t.Setenv("AUDITA_HELPER_MODE", "fail") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + llmConcurrency := 1 + runner := mustAuditaRunner(t, SubprocessRunnerConfig{ + Binary: writeAuditaHelperWrapper(t), + Timeout: mustParseAuditaDuration(t, "2s"), + LLMAPIKeyEnv: "OPENAI_KEY_SOURCE", + Modules: []string{"glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + Report: true, + }) + req := auditaReqForTest(t, true) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "run audita process") { + t.Fatalf("error = %q, want subprocess context", err.Error()) + } + if !strings.Contains(err.Error(), "exit code") { + t.Fatalf("error = %q, want exit code context", err.Error()) + } +} + +func TestSubprocessRunnerMissingOutputFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_AUDITA_HELPER", "1") + t.Setenv("AUDITA_HELPER_MODE", "missing_output") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + llmConcurrency := 1 + runner := mustAuditaRunner(t, SubprocessRunnerConfig{ + Binary: writeAuditaHelperWrapper(t), + Timeout: mustParseAuditaDuration(t, "2s"), + LLMAPIKeyEnv: "OPENAI_KEY_SOURCE", + Modules: []string{"glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + Report: false, + }) + req := auditaReqForTest(t, false) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "validate audita processed output") { + t.Fatalf("error = %q, want output validation context", err.Error()) + } +} + +func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_AUDITA_HELPER", "1") + t.Setenv("AUDITA_HELPER_MODE", "invalid_output") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + llmConcurrency := 1 + runner := mustAuditaRunner(t, SubprocessRunnerConfig{ + Binary: writeAuditaHelperWrapper(t), + Timeout: mustParseAuditaDuration(t, "2s"), + LLMAPIKeyEnv: "OPENAI_KEY_SOURCE", + Modules: []string{"glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + Report: false, + }) + req := auditaReqForTest(t, false) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "parse json") { + t.Fatalf("error = %q, want parse json context", err.Error()) + } +} + +func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_AUDITA_HELPER", "1") + t.Setenv("AUDITA_HELPER_MODE", "segments_missing") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + llmConcurrency := 1 + runner := mustAuditaRunner(t, SubprocessRunnerConfig{ + Binary: writeAuditaHelperWrapper(t), + Timeout: mustParseAuditaDuration(t, "2s"), + LLMAPIKeyEnv: "OPENAI_KEY_SOURCE", + Modules: []string{"glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + Report: false, + }) + req := auditaReqForTest(t, false) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "segments") { + t.Fatalf("error = %q, want segments validation context", err.Error()) + } +} + +func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_AUDITA_HELPER", "1") + t.Setenv("AUDITA_HELPER_MODE", "invalid_report") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + llmConcurrency := 1 + runner := mustAuditaRunner(t, SubprocessRunnerConfig{ + Binary: writeAuditaHelperWrapper(t), + Timeout: mustParseAuditaDuration(t, "2s"), + LLMAPIKeyEnv: "OPENAI_KEY_SOURCE", + Modules: []string{"glossary"}, + BaseURL: "https://openrouter.ai/api/v1", + Model: "openrouter/google/gemma-4-31b-it", + LLMConcurrency: &llmConcurrency, + Report: true, + }) + req := auditaReqForTest(t, true) + _, err := runner.Run(context.Background(), req) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "validate audita report output") { + t.Fatalf("error = %q, want report validation context", err.Error()) + } +} + +func TestSubprocessRunnerConstructorValidation(t *testing.T) { + _, err := NewSubprocessRunnerFromConfigValues("", "3h", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", nil, "", nil, true) + if err == nil { + t.Fatal("expected binary validation error") + } + _, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", nil, "", nil, true) + if err == nil { + t.Fatal("expected timeout parse error") + } + _, err = NewSubprocessRunner(SubprocessRunnerConfig{ + Binary: "audita", + Timeout: mustParseAuditaDuration(t, "1s"), + LLMAPIKeyEnv: "AUDITA_LLM_API_KEY", + Modules: []string{"glossary"}, + BaseURL: "://", + Model: "openrouter/google/gemma-4-31b-it", + }) + if err == nil { + t.Fatal("expected base url validation error") + } +} + +type auditaHelperRecord struct { + Args []string `json:"args"` + Env map[string]string `json:"env"` +} + +func TestAuditaSubprocessHelper(t *testing.T) { + if os.Getenv("GO_WANT_AUDITA_HELPER") != "1" { + return + } + + args := os.Args + start := -1 + for i := range args { + if args[i] == "--" { + start = i + 1 + break + } + } + if start < 0 || start >= len(args) { + _, _ = os.Stderr.WriteString("missing -- args separator\n") + os.Exit(2) + } + procArgs := args[start:] + outPath := auditaFlagValue(procArgs, "--output") + reportPath := auditaFlagValue(procArgs, "--report-json") + recordPath := os.Getenv("AUDITA_HELPER_RECORD_PATH") + if strings.TrimSpace(recordPath) != "" { + rec := auditaHelperRecord{ + Args: procArgs, + Env: map[string]string{ + "AUDITA_LLM_API_KEY": os.Getenv("AUDITA_LLM_API_KEY"), + "AUDITA_LLM_CONCURRENCY": os.Getenv("AUDITA_LLM_CONCURRENCY"), + }, + } + data, _ := json.Marshal(rec) + _ = os.MkdirAll(filepath.Dir(recordPath), 0o755) + _ = os.WriteFile(recordPath, data, 0o644) + } + + mode := os.Getenv("AUDITA_HELPER_MODE") + switch mode { + case "success": + writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1","segments":[]}`) + if reportPath != "" { + writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) + } + _, _ = os.Stdout.WriteString("audita helper success stdout\n") + _, _ = os.Stderr.WriteString("audita helper success stderr\n") + os.Exit(0) + case "fail": + _, _ = os.Stderr.WriteString("audita helper failure\n") + os.Exit(8) + case "missing_output": + if reportPath != "" { + writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) + } + os.Exit(0) + case "invalid_output": + writeAuditaHelperFile(outPath, `not-json`) + if reportPath != "" { + writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) + } + os.Exit(0) + case "segments_missing": + writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1"}`) + if reportPath != "" { + writeAuditaHelperFile(reportPath, `{"schema":"audita.report.v1","steps":[]}`) + } + os.Exit(0) + case "invalid_report": + writeAuditaHelperFile(outPath, `{"schema":"audita.processed.v1","segments":[]}`) + if reportPath != "" { + writeAuditaHelperFile(reportPath, `not-json`) + } + os.Exit(0) + default: + _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) + os.Exit(2) + } +} + +func mustAuditaRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunner { + t.Helper() + r, err := NewSubprocessRunner(cfg) + if err != nil { + t.Fatalf("NewSubprocessRunner() error = %v", err) + } + return r +} + +func auditaReqForTest(t *testing.T, withReport bool) PolishRequest { + t.Helper() + dir := t.TempDir() + merged := filepath.Join(dir, "merged.json") + glossary := filepath.Join(dir, "glossary.yml") + writeAuditaTestFile(t, merged, `{"segments":[]}`) + writeAuditaTestFile(t, glossary, "terms: []\n") + req := PolishRequest{ + GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), + MergedTranscriptPath: merged, + GlossaryPath: glossary, + OutputProcessedPath: filepath.Join(dir, "processed.json"), + WorkDir: filepath.Join(dir, "artifacts", "audita-work"), + StdoutLogPath: filepath.Join(dir, "audita.stdout.log"), + StderrLogPath: filepath.Join(dir, "audita.stderr.log"), + } + if withReport { + req.ReportPath = filepath.Join(dir, "audita.report.json") + } + return req +} + +func writeAuditaHelperWrapper(t *testing.T) string { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable() error = %v", err) + } + path := filepath.Join(t.TempDir(), "audita-helper-wrapper.sh") + content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestAuditaSubprocessHelper -- \"$@\"\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + return path +} + +func writeAuditaTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", path, err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", path, err) + } +} + +func writeAuditaHelperFile(path, contents string) { + if strings.TrimSpace(path) == "" { + return + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(contents), 0o644) +} + +func auditaFlagValue(args []string, name string) string { + for i := 0; i < len(args)-1; i++ { + if args[i] == name { + return args[i+1] + } + } + return "" +} + +func readAuditaHelperRecord(t *testing.T, path string) auditaHelperRecord { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + var rec auditaHelperRecord + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("json unmarshal helper record: %v", err) + } + return rec +} + +func assertJSONFileAudita(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("json unmarshal %q: %v", path, err) + } +} + +func mustParseAuditaDuration(t *testing.T, value string) time.Duration { + t.Helper() + d, err := time.ParseDuration(value) + if err != nil { + t.Fatalf("time.ParseDuration(%q) error = %v", value, err) + } + return d +}