Add structured Scriptorium run support

This commit is contained in:
2026-06-14 04:55:07 +00:00
parent 316ab8f3fc
commit 2a4ce64d6f
4 changed files with 249 additions and 16 deletions

View File

@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
## Purpose ## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input `weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate Markdown reports. This page documents the CLI surface the adapter and generate report artifacts. This page documents the CLI surface the adapter
uses, not the full Scriptorium product. uses, not the full Scriptorium product.
## Commands Used ## Commands Used
@@ -29,11 +29,25 @@ scriptorium run \
--out <artifact_path> --out <artifact_path>
``` ```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as `weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by `--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for `internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes. inspection and Recent Changes.
For generated-text reports, Scriptorium selects the structured output schema
from the prompt configuration associated with the prompt ID. `weatherreporter`
does not pass `--format`, schema path, or JSON Schema flags for structured
generation.
## Configured Arguments ## Configured Arguments
The adapter can prepend configured flags before prompt-specific arguments: The adapter can prepend configured flags before prompt-specific arguments:
@@ -68,11 +82,16 @@ Render results include:
- exit code - exit code
- truncation flags when applicable - truncation flags when applicable
Run results include the same fields plus the requested output path. Run results include the same fields plus the requested output path. Structured
generated-text run results use the same captured fields and output-path
recording, with the output path pointing at the raw generated-text JSON
artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the `weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to preflight save point. Markdown report artifacts are written by Scriptorium to
the `--out` path. the `--out` path. Generated-text raw JSON artifacts are also written by
Scriptorium to the `--out` path; later weatherreporter workflow steps validate
and render those bytes.
## Failure Behavior ## Failure Behavior
@@ -80,7 +99,7 @@ The adapter validates required request fields before starting Scriptorium:
- prompt ID - prompt ID
- data package path - data package path
- output path for `run` - output path for `run` and structured generated-text `run`
Nonzero exits return both the captured result and an error containing the exit Nonzero exits return both the captured result and an error containing the exit
code and stderr. A `run` exit code such as `2` is still treated as an error by code and stderr. A `run` exit code such as `2` is still treated as an error by

View File

@@ -6,9 +6,9 @@ This document describes the subprocess adapter in
## Purpose ## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run` The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation. It isolates subprocess execution, argv for Markdown report generation or structured generated-text output. It isolates
construction, timeout handling, output capture, and exit-code interpretation subprocess execution, argv construction, timeout handling, output capture, and
from app and domain packages. exit-code interpretation from app and domain packages.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,6 +17,7 @@ Inputs:
- prompt ID - prompt ID
- YAML prompt input data package path - YAML prompt input data package path
- report output path for `run` - report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments - configured binary, config path, profile, timeout, and extra arguments
- context for cancellation - context for cancellation
@@ -27,6 +28,7 @@ Outputs:
- truncation flags for captured output - truncation flags for captured output
- exit code - exit code
- report output path for `run` - report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries ## Boundaries
@@ -34,9 +36,9 @@ Outputs:
subprocess execution. It does not choose report types, build prompt input, subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata. fetch weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render and run operations. The adapter exposes request and result structs for render, Markdown run, and
State persistence uses a state-owned preflight artifact shape; app structured generated-text run operations. State persistence uses state-owned
orchestration converts render results before saving. artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used ## Config Fields Used
@@ -60,6 +62,12 @@ Report generation argv starts with:
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path> scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
``` ```
Structured generated-text argv uses the same `scriptorium run` form, with the
`--out` value set to the raw generated-text JSON artifact path. The adapter
does not add `--format`, schema path, or JSON Schema flags for structured
generation; Scriptorium selects the structured output schema from prompt
configuration.
Configured `--config` and `--profile` flags are inserted after the subcommand Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments. built-in arguments.
@@ -67,8 +75,8 @@ built-in arguments.
## Execution Behavior ## Execution Behavior
The adapter runs commands without shell interpolation. The same private The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request execution path is used by render, Markdown run, and structured run after
validation and argv construction. command-specific request validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at context with that timeout. Stdout and stderr are captured separately, capped at
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
- Missing run output path returns an error before subprocess execution. - Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with - Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method. operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error - Nonzero render, Markdown run, and structured run exits return the captured
containing the exit code and stderr. result plus an error containing the exit code and stderr.
## Tests ## Tests
@@ -97,5 +105,6 @@ Inspect:
- No shell interpolation is used. - No shell interpolation is used.
- The Scriptorium input name is `data_package`. - The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`. - The file at the data package path is YAML produced by `internal/promptinput`.
- Render and run preserve command-specific result structs. - Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries. - Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -79,6 +79,12 @@ type RunRequest struct {
OutputPath string OutputPath string
} }
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult struct { type RenderResult struct {
Command []string `json:"command"` Command []string `json:"command"`
Stdout string `json:"stdout"` Stdout string `json:"stdout"`
@@ -98,6 +104,16 @@ type RunResult struct {
OutputPath string `json:"outputPath"` OutputPath string `json:"outputPath"`
} }
type StructuredRunResult 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) { func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" { if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required") return nil, fmt.Errorf("prompt id is required")
@@ -152,6 +168,35 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
return result, nil return result, nil
} }
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, 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")
}
execution, err := r.execute(ctx, r.structuredRunArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
}
result := &StructuredRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct { type execution struct {
binary string binary string
args []string args []string
@@ -212,6 +257,14 @@ func (r Runner) runArgs(req RunRequest) []string {
return args return args
} }
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
return r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
})
}
type limitedBuffer struct { type limitedBuffer struct {
data []byte data []byte
limit int limit int

View File

@@ -147,17 +147,169 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
} }
} }
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte(`{"summary":"ok"}`),
Stderr: []byte("wrote generated text"),
StdoutTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 30 * time.Second,
Commands: commands,
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err != nil {
t.Fatalf("StructuredRun() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/hourly.data_package.yaml",
"--out", "/tmp/hourly.generated_text.raw.json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
if containsArg(commands.args, disallowed) {
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
}
}
if commands.timeout != 30*time.Second {
t.Fatalf("timeout = %s, want 30s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result)
}
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
}
}
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte(`{"summary":"partial"}`),
Stderr: []byte("structured output failed"),
ExitCode: 3,
},
},
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("StructuredRun() result = nil, want captured result")
}
if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
}
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("result = %#v, want captured result fields", result)
}
if !strings.Contains(err.Error(), "structured output failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
tests := []struct {
name string
req StructuredRunRequest
want string
}{
{
name: "prompt id",
req: StructuredRunRequest{
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "prompt id is required",
},
{
name: "data package path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "data package path is required",
},
{
name: "output path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{}
runner := Runner{Commands: commands}
result, err := runner.StructuredRun(context.Background(), test.req)
if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error")
}
if result != nil {
t.Fatalf("StructuredRun() result = %#v, want nil", result)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
}
})
}
}
type fakeCommands struct { type fakeCommands struct {
name string name string
args []string args []string
timeout time.Duration timeout time.Duration
result CommandResult result CommandResult
err error err error
calls int
} }
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.calls++
f.name = name f.name = name
f.args = append([]string{}, args...) f.args = append([]string{}, args...)
f.timeout = timeout f.timeout = timeout
return f.result, f.err return f.result, f.err
} }
func containsArg(args []string, want string) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}