383 lines
12 KiB
Go
383 lines
12 KiB
Go
package scriptorium
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
)
|
|
|
|
// MaxOutputFileBytes bounds one Scriptorium artifact result.
|
|
const MaxOutputFileBytes int64 = 64 * 1024 * 1024
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
envOverrides, sensitiveNames := credentialEnvironment(req.APIKeyEnv)
|
|
runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{
|
|
Executable: req.Binary,
|
|
Args: args,
|
|
WorkingDir: req.WorkingDir,
|
|
Timeout: req.Timeout,
|
|
EnvOverrides: envOverrides,
|
|
SensitiveEnvNames: sensitiveNames,
|
|
DiagnosticOwner: "scriptorium",
|
|
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)
|
|
}
|
|
}
|
|
|
|
envOverrides, sensitiveNames := credentialEnvironment(req.APIKeyEnv)
|
|
runRes, runErr := subprocess.Run(ctx, subprocess.RunRequest{
|
|
Executable: req.Binary,
|
|
Args: args,
|
|
WorkingDir: req.WorkingDir,
|
|
Timeout: req.Timeout,
|
|
EnvOverrides: envOverrides,
|
|
SensitiveEnvNames: sensitiveNames,
|
|
DiagnosticOwner: "scriptorium",
|
|
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 credentialEnvironment(apiKeyEnv string) (map[string]string, []string) {
|
|
name := strings.TrimSpace(apiKeyEnv)
|
|
if name == "" {
|
|
return nil, nil
|
|
}
|
|
value, _ := os.LookupEnv(name)
|
|
return map[string]string{name: value}, []string{name}
|
|
}
|
|
|
|
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, fileops.WorkspaceFileMode)
|
|
}
|
|
|
|
func validateNonEmptyOutput(path string) error {
|
|
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err)
|
|
}
|
|
if len(data) == 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
|
|
}
|