Generate Daily reports through scriptorium
This commit is contained in:
@@ -3,15 +3,14 @@
|
|||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data.
|
reports from normalized forecast data.
|
||||||
|
|
||||||
The application can currently prepare a Daily prompt input data package, persist
|
The application can currently generate a Daily Markdown report through
|
||||||
inspectable workspace metadata, and run `scriptorium render` as a preflight
|
`scriptorium`, with inspectable briefing, prompt input, preflight, report, and
|
||||||
check. Full rendered reports are tracked in the roadmap and are not implemented
|
metadata artifacts under the configured workspace.
|
||||||
yet.
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
30
docs/cli.md
30
docs/cli.md
@@ -1,21 +1,23 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter generate daily` currently writes managed Daily preparation
|
`weatherreporter generate daily` currently writes a Daily Markdown report
|
||||||
artifacts under the configured workspace and runs `scriptorium render` as a
|
through `scriptorium`, after writing managed preparation artifacts and running
|
||||||
preflight check. Other report generation and scheduled runs still resolve
|
`scriptorium render` as a preflight check. Other report generation and scheduled
|
||||||
configuration, report definitions, and valid periods, then return a
|
runs still resolve configuration, report definitions, and valid periods, then
|
||||||
not-implemented error.
|
return a not-implemented error.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The command parses flags, loads configuration, fetches weather data, builds a
|
The command parses flags, loads configuration, fetches weather data, builds a
|
||||||
Daily briefing, writes workspace artifacts, and invokes
|
Daily briefing, writes workspace artifacts, invokes
|
||||||
`scriptorium render --input data_package=<managed_path> --format json`. When
|
`scriptorium render --input data_package=<managed_path> --format json`, then
|
||||||
`--out` is supplied, it also writes a copy of the data package to that path.
|
invokes `scriptorium run --input data_package=<managed_path> --out <managed_report>`.
|
||||||
|
When `--out` is supplied, it also writes a copy of the Markdown report to that
|
||||||
|
path.
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
|
|
||||||
@@ -30,10 +32,10 @@ weatherreporter run evening
|
|||||||
```
|
```
|
||||||
|
|
||||||
`generate daily` writes a briefing snapshot, prompt input data package, render
|
`generate daily` writes a briefing snapshot, prompt input data package, render
|
||||||
preflight output, and metadata file under the configured workspace. Other
|
preflight output, Markdown report, and metadata file under the configured
|
||||||
`generate` commands resolve one report request and stop before report
|
workspace. Other `generate` commands resolve one report request and stop before
|
||||||
generation. `run` commands resolve a scheduled batch request and stop before
|
report generation. `run` commands resolve a scheduled batch request and stop
|
||||||
execution.
|
before execution.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ execution.
|
|||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units.
|
- `--units VALUE`: override configured Weather API units.
|
||||||
- `--tz NAME`: override configured Weather API timezone.
|
- `--tz NAME`: override configured Weather API timezone.
|
||||||
- `--out PATH`: optional extra data package copy for `generate daily`; reserved for later generated report output on other `generate` commands.
|
- `--out PATH`: optional Markdown report copy for `generate daily`; reserved for later generated report output on other `generate` commands.
|
||||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||||
- `--start TIME`: required start time for `generate storm`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This document describes the implemented `scriptorium` subprocess adapter.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
|
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
|
||||||
wiring without LLM generation.
|
wiring and `scriptorium run` to generate report artifacts.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
|
|
||||||
@@ -13,12 +13,13 @@ Input:
|
|||||||
|
|
||||||
- prompt ID
|
- prompt ID
|
||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
|
- report output path for `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
|
|
||||||
- captured stdout
|
- captured stdout, with truncation tracking
|
||||||
- captured stderr
|
- captured stderr, with truncation tracking
|
||||||
- exit code
|
- exit code
|
||||||
- full argv used for inspection
|
- full argv used for inspection
|
||||||
|
|
||||||
@@ -37,15 +38,22 @@ The render invocation shape is:
|
|||||||
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The run invocation shape is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <artifact_path>
|
||||||
|
```
|
||||||
|
|
||||||
Configured `--config` and `--profile` values are added when present. Arguments
|
Configured `--config` and `--profile` values are added when present. Arguments
|
||||||
are passed directly as argv, not through a shell. Stdout and stderr are captured
|
are passed directly as argv, not through a shell. Stdout and stderr are captured
|
||||||
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
|
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
Nonzero render exits return both the captured result and an error containing
|
Nonzero render and run exits return both the captured result and an error
|
||||||
the exit code and stderr. Command execution respects context cancellation and
|
containing the exit code and stderr. Run exit code `2` is treated as an error
|
||||||
the configured timeout.
|
but may still produce a report artifact. Command execution respects context
|
||||||
|
cancellation and the configured timeout.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -59,5 +67,5 @@ Inspect:
|
|||||||
|
|
||||||
- `scriptorium` details stay inside the adapter package.
|
- `scriptorium` details stay inside the adapter package.
|
||||||
- The input name for prompt packages is always `data_package`.
|
- The input name for prompt packages is always `data_package`.
|
||||||
- Render preflight is orchestration behavior; final report generation is not
|
- Render preflight remains orchestration behavior; this adapter only exposes the
|
||||||
implemented in this adapter yet.
|
subprocess operations.
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ Inputs:
|
|||||||
- briefing package
|
- briefing package
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- `scriptorium render` result
|
- `scriptorium render` result
|
||||||
|
- rendered report path preparation
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- briefing snapshot JSON
|
- briefing snapshot JSON
|
||||||
- prompt input data package JSON
|
- prompt input data package JSON
|
||||||
- render preflight JSON
|
- render preflight JSON
|
||||||
|
- Markdown report path
|
||||||
- metadata JSON
|
- metadata JSON
|
||||||
- prior Daily snapshot metadata when available
|
- prior Daily snapshot metadata when available
|
||||||
|
|
||||||
@@ -47,7 +49,8 @@ Workspace subdirectories must be relative paths that stay under
|
|||||||
Managed artifact names use RunID, which is generated from report generation time
|
Managed artifact names use RunID, which is generated from report generation time
|
||||||
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
||||||
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
||||||
date and returns the latest earlier run.
|
date and returns the latest earlier run. The store prepares the managed Markdown
|
||||||
|
report path before `scriptorium run` writes it.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
## Normal Workflow
|
## Normal Workflow
|
||||||
|
|
||||||
The implemented preparation workflow is:
|
The implemented Daily generation workflow is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
```
|
```
|
||||||
|
|
||||||
The command fetches weather data, builds the Daily briefing, builds the prompt
|
The command fetches weather data, builds the Daily briefing, builds the prompt
|
||||||
input data package, runs `scriptorium render`, and writes inspectable artifacts
|
input data package, runs `scriptorium render`, runs `scriptorium run`, and
|
||||||
under the configured workspace.
|
writes inspectable artifacts under the configured workspace.
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The report path is reserved in metadata for the eventual rendered report. The
|
The Markdown report is written to the managed report path. When `--out` is
|
||||||
current workflow does not write the Markdown report.
|
provided, the managed report is also copied to that path.
|
||||||
|
|
||||||
## Run Identifiers
|
## Run Identifiers
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ date do not overwrite each other.
|
|||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
Each Daily preparation writes metadata that links:
|
Each Daily generation writes metadata that links:
|
||||||
|
|
||||||
- RunID
|
- RunID
|
||||||
- report ID and prompt ID
|
- report ID and prompt ID
|
||||||
@@ -61,7 +61,7 @@ Each Daily preparation writes metadata that links:
|
|||||||
- briefing snapshot path
|
- briefing snapshot path
|
||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- preflight output path
|
- preflight output path
|
||||||
- reserved rendered report path
|
- rendered report path
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
@@ -69,5 +69,9 @@ If render preflight exits nonzero after producing a result, the captured stdout,
|
|||||||
stderr, exit code, and command are still written to the preflight artifact, and
|
stderr, exit code, and command are still written to the preflight artifact, and
|
||||||
metadata is still written for inspection.
|
metadata is still written for inspection.
|
||||||
|
|
||||||
|
If `scriptorium run` exits nonzero after writing a report, the generated report
|
||||||
|
and metadata remain available for inspection. Exit code `2` is still returned as
|
||||||
|
an error because it indicates validation failed, even if report output exists.
|
||||||
|
|
||||||
The application does not currently implement resume, cleanup, archive, or
|
The application does not currently implement resume, cleanup, archive, or
|
||||||
remote storage behavior.
|
remote storage behavior.
|
||||||
|
|||||||
@@ -2,24 +2,28 @@
|
|||||||
package scriptorium
|
package scriptorium
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxCapturedOutputBytes = 1024 * 1024
|
||||||
|
|
||||||
type CommandRunner interface {
|
type CommandRunner interface {
|
||||||
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
|
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandResult struct {
|
type CommandResult struct {
|
||||||
Stdout []byte
|
Stdout []byte
|
||||||
Stderr []byte
|
Stderr []byte
|
||||||
ExitCode int
|
StdoutTruncated bool
|
||||||
|
StderrTruncated bool
|
||||||
|
ExitCode int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecRunner struct{}
|
type ExecRunner struct{}
|
||||||
@@ -33,22 +37,28 @@ func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout t
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(runCtx, name, args...)
|
cmd := exec.CommandContext(runCtx, name, args...)
|
||||||
var stdout bytes.Buffer
|
stdout := &limitedBuffer{limit: maxCapturedOutputBytes}
|
||||||
var stderr bytes.Buffer
|
stderr := &limitedBuffer{limit: maxCapturedOutputBytes}
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = stderr
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
result := CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), ExitCode: 0}
|
result := CommandResult{
|
||||||
if err == nil {
|
Stdout: stdout.Bytes(),
|
||||||
return result, nil
|
Stderr: stderr.Bytes(),
|
||||||
|
StdoutTruncated: stdout.Truncated(),
|
||||||
|
StderrTruncated: stderr.Truncated(),
|
||||||
|
ExitCode: 0,
|
||||||
}
|
}
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if err == nil {
|
||||||
result.ExitCode = exitErr.ExitCode()
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if runCtx.Err() != nil {
|
if runCtx.Err() != nil {
|
||||||
return result, runCtx.Err()
|
return result, runCtx.Err()
|
||||||
}
|
}
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
result.ExitCode = exitErr.ExitCode()
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +76,29 @@ type RenderRequest struct {
|
|||||||
DataPackagePath string
|
DataPackagePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RunRequest 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"`
|
||||||
Stderr string `json:"stderr"`
|
Stderr string `json:"stderr"`
|
||||||
ExitCode int `json:"exitCode"`
|
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) {
|
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
||||||
@@ -94,10 +122,12 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
|||||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||||
}
|
}
|
||||||
result := &RenderResult{
|
result := &RenderResult{
|
||||||
Command: append([]string{binary}, args...),
|
Command: append([]string{binary}, args...),
|
||||||
Stdout: string(commandResult.Stdout),
|
Stdout: string(commandResult.Stdout),
|
||||||
Stderr: string(commandResult.Stderr),
|
Stderr: string(commandResult.Stderr),
|
||||||
ExitCode: commandResult.ExitCode,
|
StdoutTruncated: commandResult.StdoutTruncated,
|
||||||
|
StderrTruncated: commandResult.StderrTruncated,
|
||||||
|
ExitCode: commandResult.ExitCode,
|
||||||
}
|
}
|
||||||
if commandResult.ExitCode != 0 {
|
if commandResult.ExitCode != 0 {
|
||||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||||
@@ -105,6 +135,44 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
|||||||
return result, nil
|
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 {
|
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||||
args := []string{"render"}
|
args := []string{"render"}
|
||||||
if r.ConfigPath != "" {
|
if r.ConfigPath != "" {
|
||||||
@@ -122,6 +190,23 @@ func (r Runner) renderArgs(req RenderRequest) []string {
|
|||||||
return args
|
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 {
|
func SaveRenderResult(path string, result *RenderResult) error {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return fmt.Errorf("render result is required")
|
return fmt.Errorf("render result is required")
|
||||||
@@ -152,3 +237,38 @@ func SaveRenderResult(path string, result *RenderResult) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
|||||||
@@ -73,6 +73,80 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunConstructsCommand(t *testing.T) {
|
||||||
|
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
|
||||||
|
runner := Runner{
|
||||||
|
Binary: "/usr/local/bin/scriptorium",
|
||||||
|
ConfigPath: "/etc/scriptorium.yml",
|
||||||
|
Profile: "weather",
|
||||||
|
Timeout: 45 * time.Second,
|
||||||
|
Commands: commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
DataPackagePath: "/tmp/data_package.json",
|
||||||
|
OutputPath: "/tmp/daily.md",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantArgs := []string{
|
||||||
|
"run",
|
||||||
|
"--config", "/etc/scriptorium.yml",
|
||||||
|
"--profile", "weather",
|
||||||
|
"--prompt", "weather.daily_report",
|
||||||
|
"--input", "data_package=/tmp/data_package.json",
|
||||||
|
"--out", "/tmp/daily.md",
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if commands.timeout != 45*time.Second {
|
||||||
|
t.Fatalf("timeout = %s, want 45s", 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.OutputPath != "/tmp/daily.md" {
|
||||||
|
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReturnsResultForValidationExit(t *testing.T) {
|
||||||
|
runner := Runner{
|
||||||
|
Commands: &fakeCommands{
|
||||||
|
result: CommandResult{
|
||||||
|
Stdout: []byte("# Daily Report\n"),
|
||||||
|
Stderr: []byte("validation failed"),
|
||||||
|
ExitCode: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
DataPackagePath: "/tmp/data_package.json",
|
||||||
|
OutputPath: "/tmp/daily.md",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want nonzero exit error")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Run() result = nil, want captured result")
|
||||||
|
}
|
||||||
|
if result.ExitCode != 2 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "validation failed") {
|
||||||
|
t.Fatalf("error = %q, want stderr context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeCommands struct {
|
type fakeCommands struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
@@ -59,12 +61,12 @@ type DailyBriefingRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyPreparationRequest struct {
|
type DailyReportRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
DataPackagePath string
|
OutputPath string
|
||||||
Renderer Renderer
|
Renderer Renderer
|
||||||
Store state.Store
|
Store state.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyBriefingResult struct {
|
type DailyBriefingResult struct {
|
||||||
@@ -72,20 +74,24 @@ type DailyBriefingResult struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyPreparationResult struct {
|
type DailyReportResult struct {
|
||||||
Briefing briefing.Package
|
Briefing briefing.Package
|
||||||
BriefingPath string
|
BriefingPath string
|
||||||
DataPackage promptinput.Package
|
DataPackage promptinput.Package
|
||||||
DataPackagePath string
|
DataPackagePath string
|
||||||
PreflightPath string
|
PreflightPath string
|
||||||
|
ReportPath string
|
||||||
|
OutputPath string
|
||||||
Metadata state.Metadata
|
Metadata state.Metadata
|
||||||
MetadataPath string
|
MetadataPath string
|
||||||
PriorSnapshot *state.PriorSnapshot
|
PriorSnapshot *state.PriorSnapshot
|
||||||
RenderResult *scriptorium.RenderResult
|
RenderResult *scriptorium.RenderResult
|
||||||
|
RunResult *scriptorium.RunResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type Renderer interface {
|
type Renderer interface {
|
||||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||||
|
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||||
@@ -94,10 +100,10 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resolved.Definition.ID == report.DailyToday {
|
if resolved.Definition.ID == report.DailyToday {
|
||||||
_, err := PrepareDailyReport(ctx, DailyPreparationRequest{
|
_, err := GenerateDailyReport(ctx, DailyReportRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
DataPackagePath: req.OutputPath,
|
OutputPath: req.OutputPath,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -226,7 +232,7 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
|
|||||||
return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*DailyPreparationResult, error) {
|
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
|
||||||
store := req.Store
|
store := req.Store
|
||||||
if store == nil {
|
if store == nil {
|
||||||
defaultStore, err := defaultStore(req.Config)
|
defaultStore, err := defaultStore(req.Config)
|
||||||
@@ -269,11 +275,6 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if req.DataPackagePath != "" && req.DataPackagePath != dataPackagePath {
|
|
||||||
if err := promptinput.Save(req.DataPackagePath, dataPackage); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer := req.Renderer
|
renderer := req.Renderer
|
||||||
if renderer == nil {
|
if renderer == nil {
|
||||||
@@ -313,16 +314,46 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
|
|||||||
return nil, renderErr
|
return nil, renderErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DailyPreparationResult{
|
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
|
||||||
|
PromptID: req.Resolved.Definition.PromptID,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
OutputPath: reportPath,
|
||||||
|
})
|
||||||
|
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
||||||
|
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputPath := reportPath
|
||||||
|
if req.OutputPath != "" {
|
||||||
|
outputPath = req.OutputPath
|
||||||
|
}
|
||||||
|
metadata.RenderedReportPath = reportPath
|
||||||
|
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
||||||
|
if metadataErr != nil {
|
||||||
|
return nil, metadataErr
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
return nil, runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DailyReportResult{
|
||||||
Briefing: briefingPackage,
|
Briefing: briefingPackage,
|
||||||
BriefingPath: briefingPath,
|
BriefingPath: briefingPath,
|
||||||
DataPackage: dataPackage,
|
DataPackage: dataPackage,
|
||||||
DataPackagePath: dataPackagePath,
|
DataPackagePath: dataPackagePath,
|
||||||
PreflightPath: preflightPath,
|
PreflightPath: preflightPath,
|
||||||
|
ReportPath: reportPath,
|
||||||
|
OutputPath: outputPath,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
MetadataPath: metadataPath,
|
MetadataPath: metadataPath,
|
||||||
PriorSnapshot: priorSnapshot,
|
PriorSnapshot: priorSnapshot,
|
||||||
RenderResult: renderResult,
|
RenderResult: renderResult,
|
||||||
|
RunResult: runResult,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,3 +385,30 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie
|
|||||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||||
return state.NewFilesystemStore(cfg.Workspace)
|
return state.NewFilesystemStore(cfg.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyFileAtomic(source string, target string) error {
|
||||||
|
data, err := os.ReadFile(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read rendered report %q: %w", source, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temporary report output file: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("write temporary report output file: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temporary report output file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, target); err != nil {
|
||||||
|
return fmt.Errorf("save report output %q: %w", target, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -148,42 +148,58 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
result: &scriptorium.RenderResult{
|
renderResult: &scriptorium.RenderResult{
|
||||||
Command: []string{"scriptorium", "render"},
|
Command: []string{"scriptorium", "render"},
|
||||||
Stdout: `{"prepared":true}`,
|
Stdout: `{"prepared":true}`,
|
||||||
ExitCode: 0,
|
ExitCode: 0,
|
||||||
},
|
},
|
||||||
|
runResult: &scriptorium.RunResult{
|
||||||
|
Command: []string{"scriptorium", "run"},
|
||||||
|
Stderr: "wrote report",
|
||||||
|
ExitCode: 0,
|
||||||
|
OutputPath: "",
|
||||||
|
},
|
||||||
|
runBody: "# Daily Report\n\nRain this morning.\n",
|
||||||
}
|
}
|
||||||
dataPackagePath := filepath.Join(t.TempDir(), "daily.data_package.json")
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
|
||||||
result, err := PrepareDailyReport(context.Background(), DailyPreparationRequest{
|
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
DataPackagePath: dataPackagePath,
|
OutputPath: outputPath,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PrepareDailyReport() error = %v", err)
|
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if renderer.calls != 1 {
|
if renderer.renderCalls != 1 {
|
||||||
t.Fatalf("renderer calls = %d, want 1", renderer.calls)
|
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
|
||||||
}
|
}
|
||||||
if renderer.request.PromptID != "weather.daily_report" {
|
if renderer.runCalls != 1 {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID)
|
t.Fatalf("run calls = %d, want 1", renderer.runCalls)
|
||||||
}
|
}
|
||||||
if renderer.request.DataPackagePath != result.DataPackagePath {
|
if renderer.renderRequest.PromptID != "weather.daily_report" {
|
||||||
t.Fatalf("DataPackagePath = %q, want managed path %q", renderer.request.DataPackagePath, result.DataPackagePath)
|
t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID)
|
||||||
}
|
}
|
||||||
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.MetadataPath} {
|
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
|
||||||
|
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
|
||||||
|
}
|
||||||
|
if renderer.runRequest.DataPackagePath != result.DataPackagePath {
|
||||||
|
t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath)
|
||||||
|
}
|
||||||
|
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||||
|
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||||
|
}
|
||||||
|
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} {
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
t.Fatalf("expected artifact %q: %v", path, err)
|
t.Fatalf("expected artifact %q: %v", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(dataPackagePath); err != nil {
|
if _, err := os.Stat(outputPath); err != nil {
|
||||||
t.Fatalf("expected requested data package copy %q: %v", dataPackagePath, err)
|
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(dataPackagePath)
|
data, err := os.ReadFile(result.DataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read data package: %v", err)
|
t.Fatalf("read data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,9 +219,19 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
|
|||||||
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
||||||
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
||||||
}
|
}
|
||||||
|
if result.Metadata.RenderedReportPath != result.ReportPath {
|
||||||
|
t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath)
|
||||||
|
}
|
||||||
|
report, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report output: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -220,7 +246,7 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
renderer := &recordingRenderer{
|
renderer := &recordingRenderer{
|
||||||
result: &scriptorium.RenderResult{
|
renderResult: &scriptorium.RenderResult{
|
||||||
Command: []string{"scriptorium", "render"},
|
Command: []string{"scriptorium", "render"},
|
||||||
Stderr: "render failed",
|
Stderr: "render failed",
|
||||||
ExitCode: 1,
|
ExitCode: 1,
|
||||||
@@ -228,13 +254,13 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
err: errors.New("scriptorium render exited with code 1: render failed"),
|
err: errors.New("scriptorium render exited with code 1: render failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = PrepareDailyReport(context.Background(), DailyPreparationRequest{
|
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("PrepareDailyReport() error = nil, want render error")
|
t.Fatal("GenerateDailyReport() error = nil, want render error")
|
||||||
}
|
}
|
||||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -255,6 +281,60 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
if _, err := os.Stat(paths.Metadata); err != nil {
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||||
t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err)
|
t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err)
|
||||||
}
|
}
|
||||||
|
if renderer.runCalls != 0 {
|
||||||
|
t.Fatalf("run calls = %d, want none after failed preflight", renderer.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||||
|
server := dailyBundleServer(t)
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportDaily,
|
||||||
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
renderer := &recordingRenderer{
|
||||||
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
|
runResult: &scriptorium.RunResult{
|
||||||
|
Stderr: "validation failed",
|
||||||
|
ExitCode: 2,
|
||||||
|
},
|
||||||
|
runErr: errors.New("scriptorium run exited with code 2: validation failed"),
|
||||||
|
runBody: "# Daily Report\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: resolved,
|
||||||
|
Renderer: renderer,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("GenerateDailyReport() error = nil, want run error")
|
||||||
|
}
|
||||||
|
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
||||||
|
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
||||||
|
}
|
||||||
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
paths, err := store.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||||
|
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.RenderedReport); err != nil {
|
||||||
|
t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
@@ -356,14 +436,33 @@ func mustParse(value string) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type recordingRenderer struct {
|
type recordingRenderer struct {
|
||||||
calls int
|
renderCalls int
|
||||||
request scriptorium.RenderRequest
|
runCalls int
|
||||||
result *scriptorium.RenderResult
|
renderRequest scriptorium.RenderRequest
|
||||||
err error
|
runRequest scriptorium.RunRequest
|
||||||
|
renderResult *scriptorium.RenderResult
|
||||||
|
runResult *scriptorium.RunResult
|
||||||
|
err error
|
||||||
|
runErr error
|
||||||
|
runBody string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
||||||
r.calls++
|
r.renderCalls++
|
||||||
r.request = req
|
r.renderRequest = req
|
||||||
return r.result, r.err
|
return r.renderResult, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
|
||||||
|
r.runCalls++
|
||||||
|
r.runRequest = req
|
||||||
|
if r.runBody != "" {
|
||||||
|
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.runResult != nil {
|
||||||
|
r.runResult.OutputPath = req.OutputPath
|
||||||
|
}
|
||||||
|
return r.runResult, r.runErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Options:
|
|||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write an extra data package copy for generate daily.
|
--out PATH Write an extra Markdown report copy for generate daily.
|
||||||
`
|
`
|
||||||
|
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
@@ -210,7 +210,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
|||||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||||
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
||||||
if includeOutput {
|
if includeOutput {
|
||||||
fs.StringVar(&opts.Output, "out", "", "extra data package copy path")
|
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) {
|
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
@@ -81,7 +81,7 @@ func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) {
|
|||||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
outPath := filepath.Join(tempDir, "daily.data_package.json")
|
outPath := filepath.Join(tempDir, "daily.md")
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
@@ -95,9 +95,23 @@ func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(outPath)
|
report, err := os.ReadFile(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read data package: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
|
}
|
||||||
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob data package: %v", err)
|
||||||
|
}
|
||||||
|
if len(dataPackageMatches) != 1 {
|
||||||
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(dataPackageMatches[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
||||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||||
@@ -116,6 +130,13 @@ func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) {
|
|||||||
if !strings.Contains(string(preflight), `ok`) {
|
if !strings.Contains(string(preflight), `ok`) {
|
||||||
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
|
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
|
||||||
}
|
}
|
||||||
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob managed report: %v", err)
|
||||||
|
}
|
||||||
|
if len(reportMatches) != 1 {
|
||||||
|
t.Fatalf("managed reports = %#v, want one", reportMatches)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveGenerateCommands(t *testing.T) {
|
func TestResolveGenerateCommands(t *testing.T) {
|
||||||
@@ -269,7 +290,27 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
func writeFakeScriptorium(t *testing.T, dir string) string {
|
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, "scriptorium")
|
path := filepath.Join(dir, "scriptorium")
|
||||||
body := "#!/bin/sh\nprintf '{\"ok\":true,\"argv\":\"%s\"}' \"$*\"\n"
|
body := `#!/bin/sh
|
||||||
|
if [ "$1" = "render" ]; then
|
||||||
|
printf '{"ok":true,"argv":"%s"}' "$*"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = "run" ]; then
|
||||||
|
out=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
if [ "$1" = "--out" ]; then
|
||||||
|
shift
|
||||||
|
out="$1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||||
|
printf 'wrote report\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'unexpected command\n' >&2
|
||||||
|
exit 1
|
||||||
|
`
|
||||||
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
||||||
t.Fatalf("write fake scriptorium: %v", err)
|
t.Fatalf("write fake scriptorium: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resol
|
|||||||
return paths.Preflight, nil
|
return paths.Preflight, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
|
||||||
|
paths, err := s.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.RenderedReport), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create rendered report directory %q: %w", filepath.Dir(paths.RenderedReport), err)
|
||||||
|
}
|
||||||
|
return paths.RenderedReport, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) {
|
func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) {
|
||||||
if metadata.RunID == "" {
|
if metadata.RunID == "" {
|
||||||
return "", fmt.Errorf("metadata run id is required")
|
return "", fmt.Errorf("metadata run id is required")
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SavePreflight() error = %v", err)
|
t.Fatalf("SavePreflight() error = %v", err)
|
||||||
}
|
}
|
||||||
|
renderedReportPath, err := store.PrepareRenderedReport(context.Background(), resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PrepareRenderedReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write rendered report: %v", err)
|
||||||
|
}
|
||||||
paths, err := store.Paths(resolved)
|
paths, err := store.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
@@ -69,14 +76,14 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
Metadata: paths.Metadata,
|
Metadata: paths.Metadata,
|
||||||
DataPackage: dataPackagePath,
|
DataPackage: dataPackagePath,
|
||||||
Preflight: preflightPath,
|
Preflight: preflightPath,
|
||||||
RenderedReport: paths.RenderedReport,
|
RenderedReport: renderedReportPath,
|
||||||
})
|
})
|
||||||
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range []string{briefingPath, dataPackagePath, preflightPath, metadataPath} {
|
for _, path := range []string{briefingPath, dataPackagePath, preflightPath, renderedReportPath, metadataPath} {
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
t.Fatalf("expected artifact %q: %v", path, err)
|
t.Fatalf("expected artifact %q: %v", path, err)
|
||||||
}
|
}
|
||||||
@@ -95,6 +102,9 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if decoded.BriefingPath != briefingPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
if decoded.BriefingPath != briefingPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
|
||||||
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
|
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
|
||||||
}
|
}
|
||||||
|
if decoded.RenderedReportPath != renderedReportPath {
|
||||||
|
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindPriorDailySnapshot(t *testing.T) {
|
func TestFindPriorDailySnapshot(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Store interface {
|
|||||||
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
||||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||||
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
|
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
|
||||||
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (string, error)
|
SaveMetadata(context.Context, Metadata) (string, error)
|
||||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user