275 lines
7.0 KiB
Go
275 lines
7.0 KiB
Go
// Package scriptorium adapts the external scriptorium CLI.
|
|
package scriptorium
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
const maxCapturedOutputBytes = 1024 * 1024
|
|
|
|
type CommandRunner interface {
|
|
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
|
|
}
|
|
|
|
type CommandResult struct {
|
|
Stdout []byte
|
|
Stderr []byte
|
|
StdoutTruncated bool
|
|
StderrTruncated bool
|
|
ExitCode int
|
|
}
|
|
|
|
type ExecRunner struct{}
|
|
|
|
func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
|
runCtx := ctx
|
|
cancel := func() {}
|
|
if timeout > 0 {
|
|
runCtx, cancel = context.WithTimeout(ctx, timeout)
|
|
}
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(runCtx, name, args...)
|
|
stdout := &limitedBuffer{limit: maxCapturedOutputBytes}
|
|
stderr := &limitedBuffer{limit: maxCapturedOutputBytes}
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
err := cmd.Run()
|
|
result := CommandResult{
|
|
Stdout: stdout.Bytes(),
|
|
Stderr: stderr.Bytes(),
|
|
StdoutTruncated: stdout.Truncated(),
|
|
StderrTruncated: stderr.Truncated(),
|
|
ExitCode: 0,
|
|
}
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
if runCtx.Err() != nil {
|
|
return result, runCtx.Err()
|
|
}
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
return result, nil
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
type Runner struct {
|
|
Binary string
|
|
ConfigPath string
|
|
Profile string
|
|
Timeout time.Duration
|
|
ExtraArgs []string
|
|
Commands CommandRunner
|
|
}
|
|
|
|
type RenderRequest struct {
|
|
PromptID string
|
|
DataPackagePath string
|
|
}
|
|
|
|
type RunRequest struct {
|
|
PromptID string
|
|
DataPackagePath string
|
|
OutputPath string
|
|
}
|
|
|
|
type RenderResult struct {
|
|
Command []string `json:"command"`
|
|
Stdout string `json:"stdout"`
|
|
Stderr string `json:"stderr"`
|
|
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
|
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
|
ExitCode int `json:"exitCode"`
|
|
}
|
|
|
|
type RunResult struct {
|
|
Command []string `json:"command"`
|
|
Stdout string `json:"stdout"`
|
|
Stderr string `json:"stderr"`
|
|
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
|
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
|
ExitCode int `json:"exitCode"`
|
|
OutputPath string `json:"outputPath"`
|
|
}
|
|
|
|
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
|
if req.PromptID == "" {
|
|
return nil, fmt.Errorf("prompt id is required")
|
|
}
|
|
if req.DataPackagePath == "" {
|
|
return nil, fmt.Errorf("data package path is required")
|
|
}
|
|
binary := r.Binary
|
|
if binary == "" {
|
|
binary = "scriptorium"
|
|
}
|
|
commands := r.Commands
|
|
if commands == nil {
|
|
commands = ExecRunner{}
|
|
}
|
|
args := r.renderArgs(req)
|
|
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
|
}
|
|
result := &RenderResult{
|
|
Command: append([]string{binary}, args...),
|
|
Stdout: string(commandResult.Stdout),
|
|
Stderr: string(commandResult.Stderr),
|
|
StdoutTruncated: commandResult.StdoutTruncated,
|
|
StderrTruncated: commandResult.StderrTruncated,
|
|
ExitCode: commandResult.ExitCode,
|
|
}
|
|
if commandResult.ExitCode != 0 {
|
|
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|
if req.PromptID == "" {
|
|
return nil, fmt.Errorf("prompt id is required")
|
|
}
|
|
if req.DataPackagePath == "" {
|
|
return nil, fmt.Errorf("data package path is required")
|
|
}
|
|
if req.OutputPath == "" {
|
|
return nil, fmt.Errorf("output path is required")
|
|
}
|
|
binary := r.Binary
|
|
if binary == "" {
|
|
binary = "scriptorium"
|
|
}
|
|
commands := r.Commands
|
|
if commands == nil {
|
|
commands = ExecRunner{}
|
|
}
|
|
args := r.runArgs(req)
|
|
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("run scriptorium: %w", err)
|
|
}
|
|
result := &RunResult{
|
|
Command: append([]string{binary}, args...),
|
|
Stdout: string(commandResult.Stdout),
|
|
Stderr: string(commandResult.Stderr),
|
|
StdoutTruncated: commandResult.StdoutTruncated,
|
|
StderrTruncated: commandResult.StderrTruncated,
|
|
ExitCode: commandResult.ExitCode,
|
|
OutputPath: req.OutputPath,
|
|
}
|
|
if commandResult.ExitCode != 0 {
|
|
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r Runner) renderArgs(req RenderRequest) []string {
|
|
args := []string{"render"}
|
|
if r.ConfigPath != "" {
|
|
args = append(args, "--config", r.ConfigPath)
|
|
}
|
|
if r.Profile != "" {
|
|
args = append(args, "--profile", r.Profile)
|
|
}
|
|
args = append(args,
|
|
"--prompt", req.PromptID,
|
|
"--input", "data_package="+req.DataPackagePath,
|
|
"--format", "json",
|
|
)
|
|
args = append(args, r.ExtraArgs...)
|
|
return args
|
|
}
|
|
|
|
func (r Runner) runArgs(req RunRequest) []string {
|
|
args := []string{"run"}
|
|
if r.ConfigPath != "" {
|
|
args = append(args, "--config", r.ConfigPath)
|
|
}
|
|
if r.Profile != "" {
|
|
args = append(args, "--profile", r.Profile)
|
|
}
|
|
args = append(args,
|
|
"--prompt", req.PromptID,
|
|
"--input", "data_package="+req.DataPackagePath,
|
|
"--out", req.OutputPath,
|
|
)
|
|
args = append(args, r.ExtraArgs...)
|
|
return args
|
|
}
|
|
|
|
func SaveRenderResult(path string, result *RenderResult) error {
|
|
if result == nil {
|
|
return fmt.Errorf("render result is required")
|
|
}
|
|
data, err := json.MarshalIndent(result, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal render result: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create temporary preflight file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("write temporary preflight file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close temporary preflight file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return fmt.Errorf("save preflight %q: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type limitedBuffer struct {
|
|
data []byte
|
|
limit int
|
|
truncated bool
|
|
}
|
|
|
|
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
|
if b.limit <= 0 {
|
|
b.truncated = true
|
|
return len(p), nil
|
|
}
|
|
remaining := b.limit - len(b.data)
|
|
if remaining <= 0 {
|
|
b.truncated = true
|
|
return len(p), nil
|
|
}
|
|
if len(p) > remaining {
|
|
b.data = append(b.data, p[:remaining]...)
|
|
b.truncated = true
|
|
return len(p), nil
|
|
}
|
|
b.data = append(b.data, p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (b *limitedBuffer) Bytes() []byte {
|
|
return append([]byte{}, b.data...)
|
|
}
|
|
|
|
func (b *limitedBuffer) Truncated() bool {
|
|
return b.truncated
|
|
}
|
|
|
|
var _ io.Writer = (*limitedBuffer)(nil)
|