From f94590f70ebcb02e40f4104ad4fec172a2dcf5d7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 6 May 2026 21:18:28 +0000 Subject: [PATCH] Implement Scriptorium subprocess adapter --- internal/adapters/scriptorium/fake.go | 203 +++++++++ internal/adapters/scriptorium/fake_test.go | 77 ++++ internal/adapters/scriptorium/runner.go | 69 ++++ internal/adapters/scriptorium/subprocess.go | 364 +++++++++++++++++ .../adapters/scriptorium/subprocess_test.go | 386 ++++++++++++++++++ 5 files changed, 1099 insertions(+) create mode 100644 internal/adapters/scriptorium/fake.go create mode 100644 internal/adapters/scriptorium/fake_test.go create mode 100644 internal/adapters/scriptorium/runner.go create mode 100644 internal/adapters/scriptorium/subprocess.go create mode 100644 internal/adapters/scriptorium/subprocess_test.go diff --git a/internal/adapters/scriptorium/fake.go b/internal/adapters/scriptorium/fake.go new file mode 100644 index 0000000..51a0190 --- /dev/null +++ b/internal/adapters/scriptorium/fake.go @@ -0,0 +1,203 @@ +package scriptorium + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" +) + +// NoopRunner is a deterministic no-op scriptorium adapter. +type NoopRunner struct{} + +// RunArtifact returns requested output path with placeholder metadata. +func (n *NoopRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) { + if err := ctx.Err(); err != nil { + return ArtifactResult{}, err + } + if err := materializeRunPlaceholders(req); err != nil { + return ArtifactResult{}, err + } + return ArtifactResult{ + OutputPath: req.OutputPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + CommandMode: CommandModeRun, + PromptID: req.PromptID, + ProfileID: req.ProfileID, + Metadata: map[string]any{"placeholder": true}, + }, nil +} + +// RenderArtifact returns requested output path with placeholder metadata. +func (n *NoopRunner) RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) { + if err := ctx.Err(); err != nil { + return ArtifactResult{}, err + } + if err := materializeRenderPlaceholders(req); err != nil { + return ArtifactResult{}, err + } + return ArtifactResult{ + OutputPath: req.OutputPath, + StdoutLogPath: req.StdoutLogPath, + StderrLogPath: req.StderrLogPath, + GeneratedConfigPath: req.GeneratedConfigPath, + CommandMode: CommandModeRender, + PromptID: req.PromptID, + ProfileID: req.ProfileID, + Metadata: map[string]any{"placeholder": true}, + }, nil +} + +// FakeRunner captures requests and returns deterministic responses. +type FakeRunner struct { + RunRequests []RunArtifactRequest + RenderRequests []RenderArtifactRequest + RunErr error + RenderErr error + RunResult ArtifactResult + RenderResult ArtifactResult +} + +// RunArtifact records request and returns configured response. +func (f *FakeRunner) RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) { + if err := ctx.Err(); err != nil { + return ArtifactResult{}, err + } + f.RunRequests = append(f.RunRequests, req) + if f.RunErr != nil { + return ArtifactResult{}, f.RunErr + } + if err := materializeRunPlaceholders(req); err != nil { + return ArtifactResult{}, err + } + res := f.RunResult + if res.OutputPath == "" { + res.OutputPath = req.OutputPath + } + if res.StdoutLogPath == "" { + res.StdoutLogPath = req.StdoutLogPath + } + if res.StderrLogPath == "" { + res.StderrLogPath = req.StderrLogPath + } + if res.GeneratedConfigPath == "" { + res.GeneratedConfigPath = req.GeneratedConfigPath + } + if res.CommandMode == "" { + res.CommandMode = CommandModeRun + } + if res.PromptID == "" { + res.PromptID = req.PromptID + } + if res.ProfileID == "" { + res.ProfileID = req.ProfileID + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} + +// RenderArtifact records request and returns configured response. +func (f *FakeRunner) RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) { + if err := ctx.Err(); err != nil { + return ArtifactResult{}, err + } + f.RenderRequests = append(f.RenderRequests, req) + if f.RenderErr != nil { + return ArtifactResult{}, f.RenderErr + } + if err := materializeRenderPlaceholders(req); err != nil { + return ArtifactResult{}, err + } + res := f.RenderResult + if res.OutputPath == "" { + res.OutputPath = req.OutputPath + } + if res.StdoutLogPath == "" { + res.StdoutLogPath = req.StdoutLogPath + } + if res.StderrLogPath == "" { + res.StderrLogPath = req.StderrLogPath + } + if res.GeneratedConfigPath == "" { + res.GeneratedConfigPath = req.GeneratedConfigPath + } + if res.CommandMode == "" { + res.CommandMode = CommandModeRender + } + if res.PromptID == "" { + res.PromptID = req.PromptID + } + if res.ProfileID == "" { + res.ProfileID = req.ProfileID + } + if res.Metadata == nil { + res.Metadata = map[string]any{"fake": true} + } + return res, nil +} + +func materializeRunPlaceholders(req RunArtifactRequest) error { + if req.OutputPath != "" { + if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), 0o644); err != nil { + return fmt.Errorf("write run output %q: %w", req.OutputPath, err) + } + } + if req.GeneratedConfigPath != "" { + payload := map[string]any{ + "schema": "scriptorium.generated.v1", + "placeholder": true, + "mode": CommandModeRun, + "prompt_id": req.PromptID, + "output_path": req.OutputPath, + } + if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil { + return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err) + } + } + if req.StdoutLogPath != "" { + if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err) + } + } + if req.StderrLogPath != "" { + if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err) + } + } + return nil +} + +func materializeRenderPlaceholders(req RenderArtifactRequest) error { + if req.OutputPath != "" { + if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), 0o644); err != nil { + return fmt.Errorf("write render output %q: %w", req.OutputPath, err) + } + } + if req.GeneratedConfigPath != "" { + payload := map[string]any{ + "schema": "scriptorium.generated.v1", + "placeholder": true, + "mode": CommandModeRender, + "prompt_id": req.PromptID, + "output_path": req.OutputPath, + } + if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil { + return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err) + } + } + if req.StdoutLogPath != "" { + if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err) + } + } + if req.StderrLogPath != "" { + if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), 0o644); err != nil { + return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err) + } + } + return nil +} diff --git a/internal/adapters/scriptorium/fake_test.go b/internal/adapters/scriptorium/fake_test.go new file mode 100644 index 0000000..28fe071 --- /dev/null +++ b/internal/adapters/scriptorium/fake_test.go @@ -0,0 +1,77 @@ +package scriptorium + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestFakeRunnerCapturesRunAndRenderRequests(t *testing.T) { + fake := &FakeRunner{} + dir := t.TempDir() + + runReq := RunArtifactRequest{ + Binary: "scriptorium", + PromptID: "dnd.session_recap", + OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"), + GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.run.generated.yml"), + StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"), + Timeout: 2 * time.Second, + } + runRes, err := fake.RunArtifact(context.Background(), runReq) + if err != nil { + t.Fatalf("RunArtifact() error = %v", err) + } + if len(fake.RunRequests) != 1 || fake.RunRequests[0].PromptID != runReq.PromptID { + t.Fatalf("run requests = %#v, want captured request", fake.RunRequests) + } + if runRes.OutputPath != runReq.OutputPath { + t.Fatalf("run output path = %q, want %q", runRes.OutputPath, runReq.OutputPath) + } + + renderReq := RenderArtifactRequest{ + Binary: "scriptorium", + PromptID: "dnd.session_recap", + OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"), + GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.render.generated.yml"), + StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"), + Timeout: 2 * time.Second, + } + renderRes, err := fake.RenderArtifact(context.Background(), renderReq) + if err != nil { + t.Fatalf("RenderArtifact() error = %v", err) + } + if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].PromptID != renderReq.PromptID { + t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests) + } + if renderRes.OutputPath != renderReq.OutputPath { + t.Fatalf("render output path = %q, want %q", renderRes.OutputPath, renderReq.OutputPath) + } + + cfgData, err := os.ReadFile(runReq.GeneratedConfigPath) + if err != nil { + t.Fatalf("read generated config: %v", err) + } + if !strings.Contains(string(cfgData), "placeholder: true") { + t.Fatalf("generated config = %q, want placeholder marker", string(cfgData)) + } +} + +func TestFakeRunnerError(t *testing.T) { + fake := &FakeRunner{ + RunErr: errors.New("run boom"), + RenderErr: errors.New("render boom"), + } + if _, err := fake.RunArtifact(context.Background(), RunArtifactRequest{}); err == nil { + t.Fatal("expected run error, got nil") + } + if _, err := fake.RenderArtifact(context.Background(), RenderArtifactRequest{}); err == nil { + t.Fatal("expected render error, got nil") + } +} diff --git a/internal/adapters/scriptorium/runner.go b/internal/adapters/scriptorium/runner.go new file mode 100644 index 0000000..b13a6cd --- /dev/null +++ b/internal/adapters/scriptorium/runner.go @@ -0,0 +1,69 @@ +// Package scriptorium declares the adapter contract for Scriptorium CLI invocations. +package scriptorium + +import ( + "context" + "time" +) + +const ( + // CommandModeRun is the Scriptorium CLI mode for production generation. + CommandModeRun = "run" + // CommandModeRender is the Scriptorium CLI mode for debug/test rendering. + CommandModeRender = "render" +) + +// Runner is the adapter boundary for Scriptorium artifact invocations. +type Runner interface { + RunArtifact(ctx context.Context, req RunArtifactRequest) (ArtifactResult, error) + RenderArtifact(ctx context.Context, req RenderArtifactRequest) (ArtifactResult, error) +} + +// RunArtifactRequest describes a production artifact-generation invocation. +type RunArtifactRequest struct { + Binary string + ConfigPath string + PromptID string + ProfileID string + InputPaths map[string]string + Vars map[string]string + OutputPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + Timeout time.Duration + APIKeyEnv string + WorkingDir string +} + +// RenderArtifactRequest describes a render-debug invocation. +type RenderArtifactRequest struct { + Binary string + ConfigPath string + PromptID string + ProfileID string + InputPaths map[string]string + Vars map[string]string + OutputPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + Timeout time.Duration + APIKeyEnv string + WorkingDir string +} + +// ArtifactResult describes Scriptorium invocation output and provenance. +type ArtifactResult struct { + OutputPath string + StdoutLogPath string + StderrLogPath string + GeneratedConfigPath string + ExitCode int + Duration time.Duration + CommandMode string + PromptID string + ProfileID string + ValidationFailed bool + Metadata map[string]any +} diff --git a/internal/adapters/scriptorium/subprocess.go b/internal/adapters/scriptorium/subprocess.go new file mode 100644 index 0000000..a6ab461 --- /dev/null +++ b/internal/adapters/scriptorium/subprocess.go @@ -0,0 +1,364 @@ +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 +} diff --git a/internal/adapters/scriptorium/subprocess_test.go b/internal/adapters/scriptorium/subprocess_test.go new file mode 100644 index 0000000..4bc80d2 --- /dev/null +++ b/internal/adapters/scriptorium/subprocess_test.go @@ -0,0 +1,386 @@ +package scriptorium + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + + t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") + t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success") + t.Setenv("OPENAI_KEY_SOURCE", "super-secret") + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", recordPath) + + runner := NewSubprocessRunner() + wrapper := writeScriptoriumHelperWrapper(t) + dir := t.TempDir() + req := RunArtifactRequest{ + Binary: wrapper, + ConfigPath: "/etc/scriptorium/config.yml", + PromptID: "dnd.session_recap", + ProfileID: "local-quality", + InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json"), "other": filepath.Join(dir, "other.md")}, + Vars: map[string]string{"session_id": "2026-05-03", "campaign_name": "Icewind Dale"}, + OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"), + StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"), + GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.run.generated.yml"), + Timeout: mustParseScriptoriumDuration(t, "2s"), + APIKeyEnv: "OPENAI_KEY_SOURCE", + } + writeScriptoriumFile(t, req.InputPaths["transcript"], `{"segments":[]}`) + writeScriptoriumFile(t, req.InputPaths["other"], "notes\n") + + res, err := runner.RunArtifact(context.Background(), req) + if err != nil { + t.Fatalf("RunArtifact() error = %v", err) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if res.CommandMode != CommandModeRun { + t.Fatalf("CommandMode = %q, want %q", res.CommandMode, CommandModeRun) + } + if res.OutputPath != req.OutputPath { + t.Fatalf("OutputPath = %q, want %q", res.OutputPath, req.OutputPath) + } + if res.Duration <= 0 { + t.Fatalf("Duration = %s, want > 0", res.Duration) + } + + assertFileNonEmpty(t, req.OutputPath) + assertFileContains(t, req.StdoutLogPath, "scriptorium helper stdout") + assertFileContains(t, req.StderrLogPath, "scriptorium helper stderr") + + rec := readScriptoriumHelperRecord(t, recordPath) + wantArgs := []string{ + "run", + "--prompt", "dnd.session_recap", + "--config", "/etc/scriptorium/config.yml", + "--profile", "local-quality", + "--input", "other=" + req.InputPaths["other"], + "--input", "transcript=" + req.InputPaths["transcript"], + "--var", "campaign_name=Icewind Dale", + "--var", "session_id=2026-05-03", + "--api-key-env", "OPENAI_KEY_SOURCE", + "--timeout", "2s", + "--out", req.OutputPath, + } + if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { + t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) + } + if rec.Env["OPENAI_KEY_SOURCE"] != "super-secret" { + t.Fatalf("OPENAI_KEY_SOURCE env = %q, want inherited value", rec.Env["OPENAI_KEY_SOURCE"]) + } + + 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 include credential value") + } +} + +func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") + t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1") + t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := NewSubprocessRunner() + req := runReqForTest(t, writeScriptoriumHelperWrapper(t)) + res, err := runner.RunArtifact(context.Background(), req) + if err == nil { + t.Fatal("RunArtifact() error = nil, want non-nil") + } + if res.ExitCode != 1 { + t.Fatalf("ExitCode = %d, want 1", res.ExitCode) + } + if res.ValidationFailed { + t.Fatalf("ValidationFailed = true, want false") + } + assertFileContains(t, req.StderrLogPath, "scriptorium helper failure") +} + +func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") + t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit2_with_output") + t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := NewSubprocessRunner() + req := runReqForTest(t, writeScriptoriumHelperWrapper(t)) + res, err := runner.RunArtifact(context.Background(), req) + if err == nil { + t.Fatal("RunArtifact() error = nil, want non-nil") + } + if res.ExitCode != 2 { + t.Fatalf("ExitCode = %d, want 2", res.ExitCode) + } + if !res.ValidationFailed { + t.Fatalf("ValidationFailed = false, want true") + } + if res.OutputPath != req.OutputPath { + t.Fatalf("OutputPath = %q, want %q", res.OutputPath, req.OutputPath) + } + assertFileNonEmpty(t, req.OutputPath) +} + +func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") + t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success_missing_output") + t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json")) + + runner := NewSubprocessRunner() + req := runReqForTest(t, writeScriptoriumHelperWrapper(t)) + res, err := runner.RunArtifact(context.Background(), req) + if err == nil { + t.Fatal("RunArtifact() error = nil, want non-nil") + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + if !strings.Contains(err.Error(), "validate scriptorium run output") { + t.Fatalf("error = %q, want output validation context", err.Error()) + } +} + +func TestSubprocessRunnerRenderSuccess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("helper wrapper script uses /bin/sh") + } + t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1") + t.Setenv("SCRIPTORIUM_HELPER_MODE", "render_success") + recordPath := filepath.Join(t.TempDir(), "record.json") + t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", recordPath) + + runner := NewSubprocessRunner() + wrapper := writeScriptoriumHelperWrapper(t) + dir := t.TempDir() + req := RenderArtifactRequest{ + Binary: wrapper, + PromptID: "dnd.session_recap", + InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json")}, + OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"), + StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"), + GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.render.generated.yml"), + Timeout: mustParseScriptoriumDuration(t, "2s"), + } + writeScriptoriumFile(t, req.InputPaths["transcript"], `{"segments":[]}`) + + res, err := runner.RenderArtifact(context.Background(), req) + if err != nil { + t.Fatalf("RenderArtifact() error = %v", err) + } + if res.CommandMode != CommandModeRender { + t.Fatalf("CommandMode = %q, want %q", res.CommandMode, CommandModeRender) + } + if res.ExitCode != 0 { + t.Fatalf("ExitCode = %d, want 0", res.ExitCode) + } + assertFileNonEmpty(t, req.OutputPath) + + rec := readScriptoriumHelperRecord(t, recordPath) + wantArgs := []string{ + "render", + "--prompt", "dnd.session_recap", + "--input", "transcript=" + req.InputPaths["transcript"], + "--timeout", "2s", + "--format", "json", + "--out", req.OutputPath, + } + if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { + t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs) + } +} + +func TestScriptoriumSubprocessHelper(t *testing.T) { + if os.Getenv("GO_WANT_SCRIPTORIUM_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) + } + cliArgs := args[start:] + + outputPath := flagValue(cliArgs, "--out") + recordPath := os.Getenv("SCRIPTORIUM_HELPER_RECORD_PATH") + if strings.TrimSpace(recordPath) != "" { + rec := scriptoriumHelperRecord{ + Args: cliArgs, + Env: map[string]string{ + "OPENAI_KEY_SOURCE": os.Getenv("OPENAI_KEY_SOURCE"), + }, + } + data, _ := json.Marshal(rec) + _ = os.MkdirAll(filepath.Dir(recordPath), 0o755) + _ = os.WriteFile(recordPath, data, 0o644) + } + + mode := os.Getenv("SCRIPTORIUM_HELPER_MODE") + switch mode { + case "run_success": + writeScriptoriumHelperFile(outputPath, "generated artifact\n") + _, _ = os.Stdout.WriteString("scriptorium helper stdout\n") + _, _ = os.Stderr.WriteString("scriptorium helper stderr\n") + os.Exit(0) + case "run_success_missing_output": + _, _ = os.Stdout.WriteString("scriptorium helper stdout missing output\n") + _, _ = os.Stderr.WriteString("scriptorium helper stderr missing output\n") + os.Exit(0) + case "fail_exit1": + _, _ = os.Stderr.WriteString("scriptorium helper failure\n") + os.Exit(1) + case "fail_exit2_with_output": + writeScriptoriumHelperFile(outputPath, "validation failed artifact\n") + _, _ = os.Stderr.WriteString("scriptorium helper validation failure\n") + os.Exit(2) + case "render_success": + writeScriptoriumHelperFile(outputPath, "{\"rendered\":true}\n") + _, _ = os.Stdout.WriteString("scriptorium helper render stdout\n") + _, _ = os.Stderr.WriteString("scriptorium helper render stderr\n") + os.Exit(0) + default: + _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) + os.Exit(2) + } +} + +type scriptoriumHelperRecord struct { + Args []string `json:"args"` + Env map[string]string `json:"env"` +} + +func runReqForTest(t *testing.T, binary string) RunArtifactRequest { + t.Helper() + dir := t.TempDir() + transcriptPath := filepath.Join(dir, "processed.json") + writeScriptoriumFile(t, transcriptPath, `{"segments":[]}`) + return RunArtifactRequest{ + Binary: binary, + PromptID: "dnd.session_recap", + InputPaths: map[string]string{"transcript": transcriptPath}, + Vars: map[string]string{"output_kind": "session_recap"}, + OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"), + StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"), + StderrLogPath: filepath.Join(dir, "logs", "scriptorium.run.stderr.log"), + GeneratedConfigPath: filepath.Join(dir, "config", "scriptorium.generated.yml"), + Timeout: mustParseScriptoriumDuration(t, "2s"), + } +} + +func writeScriptoriumHelperWrapper(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(), "scriptorium-helper-wrapper.sh") + content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestScriptoriumSubprocessHelper -- \"$@\"\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + return path +} + +func mustParseScriptoriumDuration(t *testing.T, v string) time.Duration { + t.Helper() + d, err := time.ParseDuration(v) + if err != nil { + t.Fatalf("time.ParseDuration(%q) error = %v", v, err) + } + return d +} + +func flagValue(args []string, name string) string { + for i := 0; i < len(args)-1; i++ { + if args[i] == name { + return args[i+1] + } + } + return "" +} + +func writeScriptoriumFile(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 writeScriptoriumHelperFile(path, contents string) { + if strings.TrimSpace(path) == "" { + return + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(contents), 0o644) +} + +func assertFileNonEmpty(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%q): %v", path, err) + } + if info.Size() <= 0 { + t.Fatalf("file %q is empty", path) + } +} + +func assertFileContains(t *testing.T, path, wantSubstring string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + if !strings.Contains(string(data), wantSubstring) { + t.Fatalf("file %q contents = %q, want substring %q", path, string(data), wantSubstring) + } +} + +func readScriptoriumHelperRecord(t *testing.T, path string) scriptoriumHelperRecord { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + var rec scriptoriumHelperRecord + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("json unmarshal helper record: %v", err) + } + return rec +}