From b17a3591e08a79c580be99bf0ade995888f1407f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 17:47:42 +0000 Subject: [PATCH] Generate Daily reports through scriptorium --- README.md | 9 +- docs/cli.md | 30 ++-- docs/internal/scriptorium-adapter.md | 24 ++- docs/internal/state.md | 5 +- docs/operations.md | 18 ++- internal/adapters/scriptorium/runner.go | 162 ++++++++++++++++--- internal/adapters/scriptorium/runner_test.go | 74 +++++++++ internal/app/app.go | 94 ++++++++--- internal/app/app_test.go | 159 ++++++++++++++---- internal/cli/root.go | 4 +- internal/cli/root_test.go | 51 +++++- internal/state/filesystem.go | 11 ++ internal/state/filesystem_test.go | 14 +- internal/state/store.go | 1 + 14 files changed, 543 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index b41883b..e5fc3c5 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,14 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application can currently prepare a Daily prompt input data package, persist -inspectable workspace metadata, and run `scriptorium render` as a preflight -check. Full rendered reports are tracked in the roadmap and are not implemented -yet. +The application can currently generate a Daily Markdown report through +`scriptorium`, with inspectable briefing, prompt input, preflight, report, and +metadata artifacts under the configured workspace. ## Quickstart ```sh -weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json +weatherreporter generate daily --date 2026-05-29 --out ./daily.md ``` ## Documentation diff --git a/docs/cli.md b/docs/cli.md index 57b10e2..d2ce393 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,21 +1,23 @@ # Weatherreporter CLI -`weatherreporter generate daily` currently writes managed Daily preparation -artifacts under the configured workspace and runs `scriptorium render` as a -preflight check. Other report generation and scheduled runs still resolve -configuration, report definitions, and valid periods, then return a -not-implemented error. +`weatherreporter generate daily` currently writes a Daily Markdown report +through `scriptorium`, after writing managed preparation artifacts and running +`scriptorium render` as a preflight check. Other report generation and scheduled +runs still resolve configuration, report definitions, and valid periods, then +return a not-implemented error. ## Shortest Useful Command ```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 -Daily briefing, writes workspace artifacts, and invokes -`scriptorium render --input data_package= --format json`. When -`--out` is supplied, it also writes a copy of the data package to that path. +Daily briefing, writes workspace artifacts, invokes +`scriptorium render --input data_package= --format json`, then +invokes `scriptorium run --input data_package= --out `. +When `--out` is supplied, it also writes a copy of the Markdown report to that +path. ## Command Overview @@ -30,10 +32,10 @@ weatherreporter run evening ``` `generate daily` writes a briefing snapshot, prompt input data package, render -preflight output, and metadata file under the configured workspace. Other -`generate` commands resolve one report request and stop before report -generation. `run` commands resolve a scheduled batch request and stop before -execution. +preflight output, Markdown report, and metadata file under the configured +workspace. Other `generate` commands resolve one report request and stop before +report generation. `run` commands resolve a scheduled batch request and stop +before execution. ## Flags @@ -41,7 +43,7 @@ execution. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--units VALUE`: override configured Weather API units. - `--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. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. diff --git a/docs/internal/scriptorium-adapter.md b/docs/internal/scriptorium-adapter.md index eb0137d..52f9b65 100644 --- a/docs/internal/scriptorium-adapter.md +++ b/docs/internal/scriptorium-adapter.md @@ -5,7 +5,7 @@ This document describes the implemented `scriptorium` subprocess adapter. ## Purpose `internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt -wiring without LLM generation. +wiring and `scriptorium run` to generate report artifacts. ## Inputs and Outputs @@ -13,12 +13,13 @@ Input: - prompt ID - prompt input data package path +- report output path for `run` - configured binary, config path, profile, timeout, and extra arguments Output: -- captured stdout -- captured stderr +- captured stdout, with truncation tracking +- captured stderr, with truncation tracking - exit code - full argv used for inspection @@ -37,15 +38,22 @@ The render invocation shape is: scriptorium render --prompt --input data_package= --format json ``` +The run invocation shape is: + +```text +scriptorium run --prompt --input data_package= --out +``` + Configured `--config` and `--profile` values are added when present. Arguments are passed directly as argv, not through a shell. Stdout and stderr are captured separately. `SaveRenderResult` writes the captured result as JSON for inspection. ## Failure Behavior -Nonzero render exits return both the captured result and an error containing -the exit code and stderr. Command execution respects context cancellation and -the configured timeout. +Nonzero render and run exits return both the captured result and an error +containing the exit code and stderr. Run exit code `2` is treated as an error +but may still produce a report artifact. Command execution respects context +cancellation and the configured timeout. ## Tests @@ -59,5 +67,5 @@ Inspect: - `scriptorium` details stay inside the adapter package. - The input name for prompt packages is always `data_package`. -- Render preflight is orchestration behavior; final report generation is not - implemented in this adapter yet. +- Render preflight remains orchestration behavior; this adapter only exposes the + subprocess operations. diff --git a/docs/internal/state.md b/docs/internal/state.md index c3d8efe..dbb2c47 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -16,12 +16,14 @@ Inputs: - briefing package - prompt input data package - `scriptorium render` result +- rendered report path preparation Outputs: - briefing snapshot JSON - prompt input data package JSON - render preflight JSON +- Markdown report path - metadata JSON - 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 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 -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 diff --git a/docs/operations.md b/docs/operations.md index 121db42..d1d54da 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,15 +2,15 @@ ## Normal Workflow -The implemented preparation workflow is: +The implemented Daily generation workflow is: ```text weatherreporter generate daily --date 2026-05-29 ``` The command fetches weather data, builds the Daily briefing, builds the prompt -input data package, runs `scriptorium render`, and writes inspectable artifacts -under the configured workspace. +input data package, runs `scriptorium render`, runs `scriptorium run`, and +writes inspectable artifacts under the configured workspace. ## Filesystem Layout @@ -36,8 +36,8 @@ workspace/ .md ``` -The report path is reserved in metadata for the eventual rendered report. The -current workflow does not write the Markdown report. +The Markdown report is written to the managed report path. When `--out` is +provided, the managed report is also copied to that path. ## Run Identifiers @@ -52,7 +52,7 @@ date do not overwrite each other. ## Metadata -Each Daily preparation writes metadata that links: +Each Daily generation writes metadata that links: - RunID - report ID and prompt ID @@ -61,7 +61,7 @@ Each Daily preparation writes metadata that links: - briefing snapshot path - prompt input data package path - preflight output path -- reserved rendered report path +- rendered report path ## 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 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 remote storage behavior. diff --git a/internal/adapters/scriptorium/runner.go b/internal/adapters/scriptorium/runner.go index f9a7b56..6540210 100644 --- a/internal/adapters/scriptorium/runner.go +++ b/internal/adapters/scriptorium/runner.go @@ -2,24 +2,28 @@ package scriptorium import ( - "bytes" "context" "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" "time" ) +const maxCapturedOutputBytes = 1024 * 1024 + type CommandRunner interface { Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) } type CommandResult struct { - Stdout []byte - Stderr []byte - ExitCode int + Stdout []byte + Stderr []byte + StdoutTruncated bool + StderrTruncated bool + ExitCode int } type ExecRunner struct{} @@ -33,22 +37,28 @@ func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout t defer cancel() cmd := exec.CommandContext(runCtx, name, args...) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr + stdout := &limitedBuffer{limit: maxCapturedOutputBytes} + stderr := &limitedBuffer{limit: maxCapturedOutputBytes} + cmd.Stdout = stdout + cmd.Stderr = stderr err := cmd.Run() - result := CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes(), ExitCode: 0} - if err == nil { - return result, nil + result := CommandResult{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + StdoutTruncated: stdout.Truncated(), + StderrTruncated: stderr.Truncated(), + ExitCode: 0, } - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() + if err == nil { return result, nil } if runCtx.Err() != nil { return result, runCtx.Err() } + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + return result, nil + } return result, err } @@ -66,11 +76,29 @@ type RenderRequest struct { DataPackagePath string } +type RunRequest struct { + PromptID string + DataPackagePath string + OutputPath string +} + type RenderResult struct { - Command []string `json:"command"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - ExitCode int `json:"exitCode"` + Command []string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdoutTruncated,omitempty"` + StderrTruncated bool `json:"stderrTruncated,omitempty"` + ExitCode int `json:"exitCode"` +} + +type RunResult struct { + Command []string `json:"command"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdoutTruncated,omitempty"` + StderrTruncated bool `json:"stderrTruncated,omitempty"` + ExitCode int `json:"exitCode"` + OutputPath string `json:"outputPath"` } func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) { @@ -94,10 +122,12 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e return nil, fmt.Errorf("run scriptorium render: %w", err) } result := &RenderResult{ - Command: append([]string{binary}, args...), - Stdout: string(commandResult.Stdout), - Stderr: string(commandResult.Stderr), - ExitCode: commandResult.ExitCode, + Command: append([]string{binary}, args...), + Stdout: string(commandResult.Stdout), + Stderr: string(commandResult.Stderr), + StdoutTruncated: commandResult.StdoutTruncated, + StderrTruncated: commandResult.StderrTruncated, + ExitCode: commandResult.ExitCode, } if commandResult.ExitCode != 0 { return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr) @@ -105,6 +135,44 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e return result, nil } +func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) { + if req.PromptID == "" { + return nil, fmt.Errorf("prompt id is required") + } + if req.DataPackagePath == "" { + return nil, fmt.Errorf("data package path is required") + } + if req.OutputPath == "" { + return nil, fmt.Errorf("output path is required") + } + binary := r.Binary + if binary == "" { + binary = "scriptorium" + } + commands := r.Commands + if commands == nil { + commands = ExecRunner{} + } + args := r.runArgs(req) + commandResult, err := commands.Run(ctx, binary, args, r.Timeout) + if err != nil { + return nil, fmt.Errorf("run scriptorium: %w", err) + } + result := &RunResult{ + Command: append([]string{binary}, args...), + Stdout: string(commandResult.Stdout), + Stderr: string(commandResult.Stderr), + StdoutTruncated: commandResult.StdoutTruncated, + StderrTruncated: commandResult.StderrTruncated, + ExitCode: commandResult.ExitCode, + OutputPath: req.OutputPath, + } + if commandResult.ExitCode != 0 { + return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr) + } + return result, nil +} + func (r Runner) renderArgs(req RenderRequest) []string { args := []string{"render"} if r.ConfigPath != "" { @@ -122,6 +190,23 @@ func (r Runner) renderArgs(req RenderRequest) []string { return args } +func (r Runner) runArgs(req RunRequest) []string { + args := []string{"run"} + if r.ConfigPath != "" { + args = append(args, "--config", r.ConfigPath) + } + if r.Profile != "" { + args = append(args, "--profile", r.Profile) + } + args = append(args, + "--prompt", req.PromptID, + "--input", "data_package="+req.DataPackagePath, + "--out", req.OutputPath, + ) + args = append(args, r.ExtraArgs...) + return args +} + func SaveRenderResult(path string, result *RenderResult) error { if result == nil { return fmt.Errorf("render result is required") @@ -152,3 +237,38 @@ func SaveRenderResult(path string, result *RenderResult) error { } 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) diff --git a/internal/adapters/scriptorium/runner_test.go b/internal/adapters/scriptorium/runner_test.go index f31ed6f..0791e72 100644 --- a/internal/adapters/scriptorium/runner_test.go +++ b/internal/adapters/scriptorium/runner_test.go @@ -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 { name string args []string diff --git a/internal/app/app.go b/internal/app/app.go index 2755c6f..0c1e6ba 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,8 @@ package app import ( "context" "fmt" + "os" + "path/filepath" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" @@ -59,12 +61,12 @@ type DailyBriefingRequest struct { OutputPath string } -type DailyPreparationRequest struct { - Config config.Config - Resolved report.Resolved - DataPackagePath string - Renderer Renderer - Store state.Store +type DailyReportRequest struct { + Config config.Config + Resolved report.Resolved + OutputPath string + Renderer Renderer + Store state.Store } type DailyBriefingResult struct { @@ -72,20 +74,24 @@ type DailyBriefingResult struct { OutputPath string } -type DailyPreparationResult struct { +type DailyReportResult struct { Briefing briefing.Package BriefingPath string DataPackage promptinput.Package DataPackagePath string PreflightPath string + ReportPath string + OutputPath string Metadata state.Metadata MetadataPath string PriorSnapshot *state.PriorSnapshot RenderResult *scriptorium.RenderResult + RunResult *scriptorium.RunResult } type Renderer interface { Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) + Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) } func Generate(ctx context.Context, req GenerateRequest) error { @@ -94,10 +100,10 @@ func Generate(ctx context.Context, req GenerateRequest) error { return err } if resolved.Definition.ID == report.DailyToday { - _, err := PrepareDailyReport(ctx, DailyPreparationRequest{ - Config: req.Config, - Resolved: resolved, - DataPackagePath: req.OutputPath, + _, err := GenerateDailyReport(ctx, DailyReportRequest{ + Config: req.Config, + Resolved: resolved, + OutputPath: req.OutputPath, }) return err } @@ -226,7 +232,7 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail 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 if store == nil { defaultStore, err := defaultStore(req.Config) @@ -269,11 +275,6 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail if err != nil { return nil, err } - if req.DataPackagePath != "" && req.DataPackagePath != dataPackagePath { - if err := promptinput.Save(req.DataPackagePath, dataPackage); err != nil { - return nil, err - } - } renderer := req.Renderer if renderer == nil { @@ -313,16 +314,46 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail 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, BriefingPath: briefingPath, DataPackage: dataPackage, DataPackagePath: dataPackagePath, PreflightPath: preflightPath, + ReportPath: reportPath, + OutputPath: outputPath, Metadata: metadata, MetadataPath: metadataPath, PriorSnapshot: priorSnapshot, RenderResult: renderResult, + RunResult: runResult, }, nil } @@ -354,3 +385,30 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { 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 +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 34c3d9c..612fad5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -133,7 +133,7 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) { } } -func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) { +func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" @@ -148,42 +148,58 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ - result: &scriptorium.RenderResult{ + renderResult: &scriptorium.RenderResult{ Command: []string{"scriptorium", "render"}, Stdout: `{"prepared":true}`, 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{ - Config: cfg, - Resolved: resolved, - DataPackagePath: dataPackagePath, - Renderer: renderer, + result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: outputPath, + Renderer: renderer, }) if err != nil { - t.Fatalf("PrepareDailyReport() error = %v", err) + t.Fatalf("GenerateDailyReport() error = %v", err) } - if renderer.calls != 1 { - t.Fatalf("renderer calls = %d, want 1", renderer.calls) + if renderer.renderCalls != 1 { + t.Fatalf("render calls = %d, want 1", renderer.renderCalls) } - if renderer.request.PromptID != "weather.daily_report" { - t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID) + if renderer.runCalls != 1 { + t.Fatalf("run calls = %d, want 1", renderer.runCalls) } - if renderer.request.DataPackagePath != result.DataPackagePath { - t.Fatalf("DataPackagePath = %q, want managed path %q", renderer.request.DataPackagePath, result.DataPackagePath) + if renderer.renderRequest.PromptID != "weather.daily_report" { + 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 { t.Fatalf("expected artifact %q: %v", path, err) } } - if _, err := os.Stat(dataPackagePath); err != nil { - t.Fatalf("expected requested data package copy %q: %v", dataPackagePath, err) + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected requested report output %q: %v", outputPath, err) } - data, err := os.ReadFile(dataPackagePath) + data, err := os.ReadFile(result.DataPackagePath) if err != nil { 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 { 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) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" @@ -220,7 +246,7 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ - result: &scriptorium.RenderResult{ + renderResult: &scriptorium.RenderResult{ Command: []string{"scriptorium", "render"}, Stderr: "render failed", ExitCode: 1, @@ -228,13 +254,13 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { err: errors.New("scriptorium render exited with code 1: render failed"), } - _, err = PrepareDailyReport(context.Background(), DailyPreparationRequest{ + _, err = GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: resolved, Renderer: renderer, }) 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) if err != nil { @@ -255,6 +281,60 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { if _, err := os.Stat(paths.Metadata); err != nil { 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) { @@ -356,14 +436,33 @@ func mustParse(value string) time.Time { } type recordingRenderer struct { - calls int - request scriptorium.RenderRequest - result *scriptorium.RenderResult - err error + renderCalls int + runCalls int + renderRequest scriptorium.RenderRequest + 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) { - r.calls++ - r.request = req - return r.result, r.err + r.renderCalls++ + r.renderRequest = req + 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 } diff --git a/internal/cli/root.go b/internal/cli/root.go index 3ec2ba8..4bc28fd 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ Options: --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --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 { @@ -210,7 +210,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") if includeOutput { - fs.StringVar(&opts.Output, "out", "", "extra data package copy path") + fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path") } } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 061cd1a..fbc3215 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -71,7 +71,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { } } -func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) { +func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := 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 { 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 stderr bytes.Buffer runner := Runner{Clock: fixedClock()} @@ -95,9 +95,23 @@ func TestRunGenerateDailyWritesDataPackageAndRunsPreflight(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - data, err := os.ReadFile(outPath) + report, err := os.ReadFile(outPath) 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"`) { 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`) { 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) { @@ -269,7 +290,27 @@ func dailyServer(t *testing.T) *httptest.Server { func writeFakeScriptorium(t *testing.T, dir string) string { t.Helper() 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 { t.Fatalf("write fake scriptorium: %v", err) } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index a87b459..7e33c3b 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -117,6 +117,17 @@ func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resol 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) { if metadata.RunID == "" { return "", fmt.Errorf("metadata run id is required") diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index aab0f86..937fcac 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -60,6 +60,13 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { if err != nil { 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) if err != nil { t.Fatalf("Paths() error = %v", err) @@ -69,14 +76,14 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { Metadata: paths.Metadata, DataPackage: dataPackagePath, Preflight: preflightPath, - RenderedReport: paths.RenderedReport, + RenderedReport: renderedReportPath, }) metadataPath, err := store.SaveMetadata(context.Background(), metadata) if err != nil { 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 { 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 { 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) { diff --git a/internal/state/store.go b/internal/state/store.go index 50eb73b..1242b94 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -15,6 +15,7 @@ type Store interface { SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error) + PrepareRenderedReport(context.Context, report.Resolved) (string, error) SaveMetadata(context.Context, Metadata) (string, error) FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) }