package scriptorium import ( "context" "fmt" "os" "sort" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" ) // SubprocessRunner invokes Scriptorium through its public CLI. type SubprocessRunner struct{} // NewSubprocessRunner constructs a Scriptorium subprocess runner. func NewSubprocessRunner() *SubprocessRunner { return &SubprocessRunner{} } // RunArtifact executes `scriptorium run` and validates output file creation. func (r *SubprocessRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) { if r == nil { return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil") } credentialPresent, err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.APIKeyEnv, req.InputPaths, req.Vars) if err != nil { return ArtifactResult{}, err } args := buildRunArgs(req) if req.GeneratedConfigPath != "" { if err := writeInvocationConfig(req.GeneratedConfigPath, invocationPayload{ Mode: CommandModeRun, Binary: req.Binary, Args: args, Timeout: req.Timeout, ConfigPath: strings.TrimSpace(req.ConfigPath), PromptID: strings.TrimSpace(req.PromptID), ProfileID: strings.TrimSpace(req.ProfileID), InputPaths: cloneStringMap(req.InputPaths), Vars: cloneStringMap(req.Vars), OutputPath: req.OutputPath, APIKeyEnv: strings.TrimSpace(req.APIKeyEnv), APIKeyEnvPresent: credentialPresent, WorkingDir: req.WorkingDir, RenderFormat: "", RenderPromptStore: false, }); err != nil { return ArtifactResult{}, fmt.Errorf("write scriptorium invocation config %q: %w", req.GeneratedConfigPath, err) } } runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{ Executable: req.Binary, Args: args, WorkingDir: req.WorkingDir, Timeout: req.Timeout, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) result := ArtifactResult{ OutputPath: req.OutputPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, CommandMode: CommandModeRun, PromptID: req.PromptID, ProfileID: req.ProfileID, Metadata: map[string]any{ "adapter": "scriptorium_subprocess", "config_path": strings.TrimSpace(req.ConfigPath), "inputs_count": len(req.InputPaths), "vars_count": len(req.Vars), "api_key_env": strings.TrimSpace(req.APIKeyEnv), "api_key_env_present": credentialPresent, }, } if runErr != nil { if runRes.ExitCode == 2 { result.ValidationFailed = true exists, size, statErr := fileExistsWithSize(req.OutputPath) if statErr == nil { result.Metadata["output_exists"] = exists result.Metadata["output_size_bytes"] = size } return result, fmt.Errorf("scriptorium run exited with validation failure (exit code 2): %w", runErr) } return result, fmt.Errorf("run scriptorium artifact (binary=%q): %w", req.Binary, runErr) } if err := validateNonEmptyOutput(req.OutputPath); err != nil { return result, fmt.Errorf("validate scriptorium run output %q: %w", req.OutputPath, err) } return result, nil } // RenderArtifact executes `scriptorium render` with JSON output. func (r *SubprocessRunner) RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) { if r == nil { return ArtifactResult{}, fmt.Errorf("scriptorium subprocess runner is nil") } credentialPresent, err := validateCommonRunRequest(req.Binary, req.ConfigPath, req.PromptID, req.OutputPath, req.Timeout, req.APIKeyEnv, req.InputPaths, req.Vars) if err != nil { return ArtifactResult{}, err } args := buildRenderArgs(req) if req.GeneratedConfigPath != "" { if err := writeInvocationConfig(req.GeneratedConfigPath, invocationPayload{ Mode: CommandModeRender, Binary: req.Binary, Args: args, Timeout: req.Timeout, ConfigPath: strings.TrimSpace(req.ConfigPath), PromptID: strings.TrimSpace(req.PromptID), ProfileID: strings.TrimSpace(req.ProfileID), InputPaths: cloneStringMap(req.InputPaths), Vars: cloneStringMap(req.Vars), OutputPath: req.OutputPath, APIKeyEnv: strings.TrimSpace(req.APIKeyEnv), APIKeyEnvPresent: credentialPresent, WorkingDir: req.WorkingDir, RenderFormat: "json", RenderPromptStore: false, }); err != nil { return ArtifactResult{}, fmt.Errorf("write scriptorium invocation config %q: %w", req.GeneratedConfigPath, err) } } runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{ Executable: req.Binary, Args: args, WorkingDir: req.WorkingDir, Timeout: req.Timeout, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) result := ArtifactResult{ OutputPath: req.OutputPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, CommandMode: CommandModeRender, PromptID: req.PromptID, ProfileID: req.ProfileID, Metadata: map[string]any{ "adapter": "scriptorium_subprocess", "config_path": strings.TrimSpace(req.ConfigPath), "inputs_count": len(req.InputPaths), "vars_count": len(req.Vars), "api_key_env": strings.TrimSpace(req.APIKeyEnv), "api_key_env_present": credentialPresent, "format": "json", }, } if runErr != nil { return result, fmt.Errorf("run scriptorium render (binary=%q): %w", req.Binary, runErr) } if err := validateNonEmptyOutput(req.OutputPath); err != nil { return result, fmt.Errorf("validate scriptorium render output %q: %w", req.OutputPath, err) } return result, nil } func validateCommonRunRequest( binary string, configPath string, promptID string, outputPath string, timeout time.Duration, apiKeyEnv string, inputPaths map[string]string, vars map[string]string, ) (bool, error) { if strings.TrimSpace(binary) == "" { return false, fmt.Errorf("scriptorium binary is required") } if strings.TrimSpace(configPath) == "" && configPath != "" { return false, fmt.Errorf("scriptorium config path must be non-empty when provided") } if strings.TrimSpace(promptID) == "" { return false, fmt.Errorf("scriptorium prompt id is required") } if strings.TrimSpace(outputPath) == "" { return false, fmt.Errorf("scriptorium output path is required") } if timeout <= 0 { return false, fmt.Errorf("scriptorium timeout must be > 0") } for name, path := range inputPaths { if strings.TrimSpace(name) == "" { return false, fmt.Errorf("scriptorium input names must be non-empty") } if strings.TrimSpace(path) == "" { return false, fmt.Errorf("scriptorium input %q path is required", name) } } for name := range vars { if strings.TrimSpace(name) == "" { return false, fmt.Errorf("scriptorium variable names must be non-empty") } } trimmedEnv := strings.TrimSpace(apiKeyEnv) if trimmedEnv == "" { return false, nil } value, ok := os.LookupEnv(trimmedEnv) if !ok || strings.TrimSpace(value) == "" { return false, fmt.Errorf("scriptorium required credential environment variable %s is not set", trimmedEnv) } return true, nil } func buildRunArgs(req RunArtifactRequest) []string { args := []string{"run", "--prompt", strings.TrimSpace(req.PromptID)} if cfgPath := strings.TrimSpace(req.ConfigPath); cfgPath != "" { args = append(args, "--config", cfgPath) } if profileID := strings.TrimSpace(req.ProfileID); profileID != "" { args = append(args, "--profile", profileID) } for _, kv := range sortedKeyValues(req.InputPaths) { args = append(args, "--input", kv) } for _, kv := range sortedKeyValues(req.Vars) { args = append(args, "--var", kv) } if envName := strings.TrimSpace(req.APIKeyEnv); envName != "" { args = append(args, "--api-key-env", envName) } args = append(args, "--timeout", req.Timeout.String()) args = append(args, "--out", req.OutputPath) return args } func buildRenderArgs(req RenderArtifactRequest) []string { args := []string{"render", "--prompt", strings.TrimSpace(req.PromptID)} if cfgPath := strings.TrimSpace(req.ConfigPath); cfgPath != "" { args = append(args, "--config", cfgPath) } if profileID := strings.TrimSpace(req.ProfileID); profileID != "" { args = append(args, "--profile", profileID) } for _, kv := range sortedKeyValues(req.InputPaths) { args = append(args, "--input", kv) } for _, kv := range sortedKeyValues(req.Vars) { args = append(args, "--var", kv) } if envName := strings.TrimSpace(req.APIKeyEnv); envName != "" { args = append(args, "--api-key-env", envName) } args = append(args, "--timeout", req.Timeout.String()) args = append(args, "--format", "json") args = append(args, "--out", req.OutputPath) return args } func sortedKeyValues(values map[string]string) []string { if len(values) == 0 { return nil } keys := make([]string, 0, len(values)) for k := range values { keys = append(keys, k) } sort.Strings(keys) out := make([]string, 0, len(keys)) for _, k := range keys { out = append(out, k+"="+values[k]) } return out } type invocationPayload struct { Mode string Binary string Args []string Timeout time.Duration ConfigPath string PromptID string ProfileID string InputPaths map[string]string Vars map[string]string OutputPath string APIKeyEnv string APIKeyEnvPresent bool WorkingDir string RenderFormat string RenderPromptStore bool } func writeInvocationConfig(path string, payload invocationPayload) error { data := map[string]any{ "schema": "scriptorium.generated.v1", "mode": payload.Mode, "binary": payload.Binary, "args": payload.Args, "timeout": payload.Timeout.String(), "config_path": payload.ConfigPath, "prompt_id": payload.PromptID, "profile_id": payload.ProfileID, "input_paths": payload.InputPaths, "vars": payload.Vars, "output_path": payload.OutputPath, "api_key_env": payload.APIKeyEnv, "api_key_env_present": payload.APIKeyEnvPresent, "working_dir": payload.WorkingDir, "render_format": payload.RenderFormat, "render_prompt_logged": payload.RenderPromptStore, } return subprocess.WriteYAMLAtomic(path, data, 0o644) } func validateNonEmptyOutput(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("stat file: %w", err) } if info.IsDir() { return fmt.Errorf("path is a directory") } if info.Size() <= 0 { return fmt.Errorf("file is empty") } return nil } func fileExistsWithSize(path string) (bool, int64, error) { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return false, 0, nil } return false, 0, err } if info.IsDir() { return false, 0, nil } return true, info.Size(), nil } func cloneStringMap(src map[string]string) map[string]string { if len(src) == 0 { return nil } out := make(map[string]string, len(src)) for k, v := range src { out[k] = v } return out }