Implement Scriptorium subprocess adapter
This commit is contained in:
364
internal/adapters/scriptorium/subprocess.go
Normal file
364
internal/adapters/scriptorium/subprocess.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user