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

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

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 {
name string
args []string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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