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 TranscriptDescription string ConfigPath string OutputSchema string WorkDirRetention string TotalLLMConcurrency *int ProposalLLMConcurrency *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 transcriptDescription string configPath string outputSchema string workDirRetention string totalLLMConcurrency *int proposalLLMConcurrency *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, transcriptDescription string, configPath string, outputSchema string, workDirRetention string, totalLLMConcurrency *int, proposalLLMConcurrency *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, TranscriptDescription: transcriptDescription, ConfigPath: configPath, OutputSchema: outputSchema, WorkDirRetention: workDirRetention, TotalLLMConcurrency: totalLLMConcurrency, ProposalLLMConcurrency: proposalLLMConcurrency, 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") } 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) != "" { 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 cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { return nil, fmt.Errorf("audita total llm concurrency must be > 0 when provided") } if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 { return nil, fmt.Errorf("audita proposal 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") } switch strings.TrimSpace(cfg.OutputSchema) { case "", "bare-segments", "audita-v1": default: return nil, fmt.Errorf("audita output schema must be one of: bare-segments, audita-v1") } switch strings.TrimSpace(cfg.WorkDirRetention) { case "", "always", "auto", "never": default: return nil, fmt.Errorf("audita work dir retention must be one of: always, auto, never") } 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), transcriptDescription: strings.TrimSpace(cfg.TranscriptDescription), configPath: strings.TrimSpace(cfg.ConfigPath), outputSchema: strings.TrimSpace(cfg.OutputSchema), workDirRetention: strings.TrimSpace(cfg.WorkDirRetention), totalLLMConcurrency: cfg.TotalLLMConcurrency, proposalLLMConcurrency: cfg.ProposalLLMConcurrency, 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 reqModules == nil { reqModules = append([]string(nil), r.modules...) } args := r.buildArgs(req, reqModules) credentialEnvVar := strings.TrimSpace(r.llmAPIKeyEnv) credentialPresent := false env := map[string]string{} if credentialEnvVar != "" { credential, ok := os.LookupEnv(credentialEnvVar) if !ok || strings.TrimSpace(credential) == "" { return PolishResult{}, fmt.Errorf("audita: required credential environment variable %s is not set", credentialEnvVar) } env["AUDITA_LLM_API_KEY"] = credential credentialPresent = true } if req.GeneratedConfigPath != "" { if err := r.writeInvocationConfig(req, args, reqModules, credentialPresent); 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 { wrappedMessage := fmt.Sprintf( "run audita process (binary=%q, stdout_log=%q, stderr_log=%q)", r.binary, req.StdoutLogPath, req.StderrLogPath, ) wrappedMessage = addSubprocessStreamHint(wrappedMessage, err) return r.failureResult(req, reqModules, runRes, credentialPresent), fmt.Errorf( "%s: %w", wrappedMessage, err, ) } if err := validateProcessedOutput(req.OutputProcessedPath); err != nil { return r.failureResult(req, reqModules, runRes, credentialPresent), 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), 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, "transcript_description": r.transcriptDescription, "config_path": r.configPath, "output_schema": r.outputSchema, "work_dir_retention": r.workDirRetention, "validation_model": r.validationModel, "total_llm_concurrency": r.totalLLMConcurrency, "proposal_llm_concurrency": r.proposalLLMConcurrency, "validation_llm_concurrency": r.validationLLMConcurrency, "credential_env_var": r.llmAPIKeyEnv, "credential_present": credentialPresent, }, }, nil } func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, runRes subprocess.RunResult, credentialPresent 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, "transcript_description": r.transcriptDescription, "config_path": r.configPath, "output_schema": r.outputSchema, "work_dir_retention": r.workDirRetention, "validation_model": r.validationModel, "total_llm_concurrency": r.totalLLMConcurrency, "proposal_llm_concurrency": r.proposalLLMConcurrency, "validation_llm_concurrency": r.validationLLMConcurrency, "credential_env_var": r.llmAPIKeyEnv, "credential_present": credentialPresent, }, } } func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string { args := []string{ "process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath, "--work-dir", req.WorkDir, } if r.baseURL != "" { args = append(args, "--base-url", r.baseURL) } if r.model != "" { args = append(args, "--model", r.model) } if len(modules) > 0 { args = append(args, "--modules", strings.Join(modules, ",")) } if r.report { args = append(args, "--report-json", req.ReportPath) } if r.transcriptDescription != "" { args = append(args, "--transcript-description", r.transcriptDescription) } if r.configPath != "" { args = append(args, "--config", r.configPath) } if r.outputSchema != "" { args = append(args, "--output-schema", r.outputSchema) } if r.workDirRetention != "" { args = append(args, "--work-dir-retention", r.workDirRetention) } if r.totalLLMConcurrency != nil { args = append(args, "--total-llm-concurrency", strconv.Itoa(*r.totalLLMConcurrency)) } if r.proposalLLMConcurrency != nil { args = append(args, "--proposal-llm-concurrency", strconv.Itoa(*r.proposalLLMConcurrency)) } 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) 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, "transcript_description": r.transcriptDescription, "config_path": r.configPath, "output_schema": r.outputSchema, "work_dir_retention": r.workDirRetention, "validation_model": r.validationModel, "total_llm_concurrency": r.totalLLMConcurrency, "proposal_llm_concurrency": r.proposalLLMConcurrency, "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, } 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 addSubprocessStreamHint(message string, runErr error) string { if runErr == nil { return message } lower := strings.ToLower(runErr.Error()) if strings.Contains(lower, "bad file descriptor") || strings.Contains(lower, "exit code 120") { return message + "; hint=audita child process may have started with invalid stderr/stdout descriptors" } return message } 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 }