Generate Daily reports through scriptorium

This commit is contained in:
2026-05-29 17:47:42 +00:00
parent 7ac73f758b
commit b17a3591e0
14 changed files with 543 additions and 113 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
} }

View File

@@ -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")
} }
} }

View File

@@ -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)
} }

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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)
} }