8 Commits

27 changed files with 824 additions and 613 deletions

View File

@@ -4,36 +4,47 @@ This document describes the implemented workflow coordinator in `internal/app`.
## Purpose
`internal/app` coordinates top-level use cases: generating one report, running
morning or evening batches, building inspectable briefing artifacts, fetching
weather bundles, and reading existing artifacts for inspection.
`internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data,
builds briefing and prompt-input artifacts, invokes Scriptorium through the
adapter boundary, persists managed state, runs batches, and reads existing
artifacts for inspection.
## Inputs And Outputs
Inputs:
- app request structs containing config, report or batch selection, clock time,
optional report date, optional Storm Report bounds, output paths, renderer
fakes, or state-store fakes
- `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
and internal composition
- resolved report definitions from `internal/report`
- forecast bundles from the Weather API adapter
- prior briefing snapshots from `internal/state`
- forecast bundles from `internal/adapters/weatherapi`
- prior snapshots loaded from `internal/state`
- optional renderer and state-store fakes for tests
Outputs:
- report results with briefing, data package, preflight, report, metadata,
prior snapshot, Recent Changes, and Scriptorium results
- batch summaries with per-report status and artifact paths
- generated report results with briefing, data package, preflight, report,
metadata, prior snapshot, Recent Changes, and Scriptorium result details
- batch summaries with per-report status, artifact paths, and error text
- saved Weather API bundle JSON for fetch workflows
- inspection JSON values for reports, metadata, briefings, data packages, prior
snapshots, and source provenance
## Boundaries
- The package coordinates workflow order.
- It does not parse CLI flags, load YAML files directly, implement HTTP calls,
derive forecast facts, define report periods, compare Markdown, or construct
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
facts, define report periods, compare rendered Markdown, or construct
Scriptorium argv.
Report selection and report identity policy come from `internal/report`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
and persisted metadata stay in `internal/state`.
## Config Fields Used
- `weather_api.*` for Weather API client construction and briefing metadata
@@ -42,36 +53,57 @@ Outputs:
- `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds
## External Adapters Used
Output copy flags are command request fields. They are not configuration
defaults.
- `internal/adapters/weatherapi` for forecast bundle fetching
- `internal/adapters/scriptorium` for render preflight and report generation
- `internal/state` filesystem store for persisted artifacts
## Generation Workflow
## State Or Manifest Behavior
Single-report generation follows this order:
Generation saves the briefing snapshot, data package, preflight result when
available, rendered report, and metadata. Metadata links all managed artifact
paths. Inspection workflows read existing state and do not fetch weather data or
invoke Scriptorium.
1. Resolve the command report to a `report.Resolved` value.
2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`.
4. Fetch a Weather API bundle.
5. Build a report-specific briefing package.
6. Save the briefing snapshot.
7. Compute Recent Changes from structured prior and current briefings.
8. Build and save the Scriptorium `data_package`.
9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available.
11. Save metadata for inspection.
12. Run Scriptorium report generation to the managed report path.
13. Copy the managed report to the requested `--out` path when provided.
14. Save metadata with the managed report path.
## Skip And Resume Behavior
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and
metadata remain inspectable.
There is no resume workflow. Batch generation continues remaining independent
reports after one report fails, then reports aggregate success and failure
counts.
## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
from report definitions. Batch generation continues independent reports after a
failure, records each result, writes compact status lines to stderr, emits a
JSON summary to stdout, and returns an aggregate error when any report failed.
## Inspection Workflow
Inspection workflows load existing filesystem state only. They do not fetch
weather data or invoke Scriptorium. Run-specific inspect commands share the same
store and metadata lookup path, then load the requested artifact or derived
inspection view.
## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data.
- Weather API or briefing errors stop that report before Scriptorium is called.
- Render preflight runs before Scriptorium report generation.
- If render preflight returns a result and an error, preflight JSON and metadata
are persisted before the error is returned.
- If Scriptorium report generation returns an error after writing output,
metadata and the managed report path remain inspectable.
- Batch failures are recorded per report and surfaced through aggregate batch
failure.
- Weather API and briefing errors stop that report before Scriptorium runs.
- Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context.
- Metadata and artifact path errors include filesystem context.
- Batch failures are recorded per report and surfaced through an aggregate
batch error.
## Tests
@@ -84,6 +116,8 @@ Inspect:
## Invariants
- Report behavior is resolved through `internal/report`.
- Generated reports use the same app request and result types regardless of
report ID.
- Render preflight precedes Scriptorium report generation.
- Recent Changes are computed from structured briefing snapshots.
- Metadata links artifacts produced for a run.

View File

@@ -1,57 +1,81 @@
# Report Registry Internals
This document describes report identity, valid-period resolution, batch
membership, and comparison declarations in `internal/report`.
membership, output naming, artifact grouping, and comparison declarations in
`internal/report`.
## Purpose
`internal/report` centralizes report definitions so report IDs, prompt IDs,
default output names, comparison strategies, and valid periods are declared in
one package.
`internal/report` is the canonical source for report definitions. App, state,
briefing, and CLI wiring consume resolved definitions instead of owning report
identity policy themselves.
## Inputs And Outputs
## Definition Fields
Inputs:
Each report definition declares:
- report ID or batch name
- generation time
- timezone
- optional Daily date override
- optional Storm Report start and end times
- report ID and display name
- Scriptorium prompt ID
- valid-period resolver
- comparison strategy
- managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- morning or evening batch membership
Outputs:
## Implemented Reports
- `report.Resolved` values with definition metadata and half-open valid periods
- `report.Metadata` values used by briefing and persisted metadata builders
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
All implemented report definitions are eligible for generation.
## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller.
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
the configured timezone or RFC3339 timestamps with explicit offsets. End time
must be after start time.
## Boundaries
- This package defines report identity and time coverage only.
- It does not fetch weather data, build briefings, compare snapshots, write
state, parse CLI flags, or invoke Scriptorium.
`internal/report` defines report metadata and time coverage. It does not fetch
weather data, build briefings, compare briefing contents, write state, parse CLI
flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Report
output path copying uses default output names from report definitions.
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions.
## External Adapters Used
## State And App Usage
None.
## State Or Manifest Behavior
None directly. Resolved metadata contributes RunID, report ID, prompt ID,
generation time, timezone, and valid period to later briefing and state
metadata.
## Skip And Resume Behavior
No resume behavior. Morning batch resolution skips Weekend Outlook on Sunday.
- State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
- Generation checks `Generated`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
- RunIDs include the resolved report ID.
## Failure Behavior
- Unknown reports and batch names return actionable errors.
- Unknown report IDs and batch names return actionable errors.
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
- Storm Report resolution requires start and end, with end after start.
@@ -68,4 +92,6 @@ Inspect:
- Report selection goes through the registry.
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Valid periods are half-open intervals independent of rendered report text.
- Comparison strategy is declared by report definition.
- Artifact grouping, batch output filenames, generated-report eligibility,
comparison compatibility, and comparison strategy are declared by report
definition.

View File

@@ -6,8 +6,9 @@ This document describes the subprocess adapter in
## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation while isolating subprocess details from domain
packages.
for Markdown report generation. It isolates subprocess execution, argv
construction, timeout handling, output capture, and exit-code interpretation
from app and domain packages.
## Inputs And Outputs
@@ -17,19 +18,25 @@ Inputs:
- prompt input data package path
- report output path for `run`
- configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
Outputs:
- argv used for execution
- captured stdout and stderr with truncation flags
- captured stdout and stderr
- truncation flags for captured output
- exit code
- report output path for `run`
## Boundaries
- This adapter owns Scriptorium argv construction and subprocess execution.
- It does not choose report types, build prompt input, fetch weather data,
decide workflow order, or persist workflow metadata.
`internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render and run operations.
State persistence uses a state-owned preflight artifact shape; app
orchestration converts render results before saving.
## Config Fields Used
@@ -39,29 +46,43 @@ Outputs:
- `scriptorium.timeout`
- `scriptorium.extra_args`
## External Adapters Used
## Commands
- external `scriptorium` CLI
Render preflight argv starts with:
See [Scriptorium integration](../integrations/scriptorium.md) for the external
CLI contract used by this project.
```text
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
```
## State Or Manifest Behavior
Report generation argv starts with:
`SaveRenderResult` can write render results atomically. The app and state store
own managed preflight paths and metadata links.
```text
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
## Skip And Resume Behavior
Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
None. Context cancellation and configured timeout stop subprocess execution.
## Execution Behavior
The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request
validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at
1 MiB each, and marked as truncated when the cap is reached.
## Failure Behavior
- Missing prompt ID, data package path, or run output path returns an error.
- Subprocess start or context errors are wrapped with operation context.
- Nonzero render and run exits return captured output plus an error containing
exit code and stderr.
- Captured stdout and stderr are size-limited and marked when truncated.
- Missing prompt ID or data package path returns an error before subprocess
execution.
- Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error
containing the exit code and stderr.
## Tests
@@ -75,4 +96,5 @@ Inspect:
- No shell interpolation is used.
- The Scriptorium input name is `data_package`.
- Render and run preserve command-specific result structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -4,7 +4,7 @@ This document describes filesystem state in `internal/state`.
## Purpose
`internal/state` owns managed artifact paths, atomic JSON writes, persisted
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
## Inputs And Outputs
@@ -15,7 +15,7 @@ Inputs:
- resolved report definition and valid period
- briefing package
- prompt input data package
- Scriptorium render result
- preflight artifact
- rendered report path preparation request
- RunID for inspection lookups
@@ -32,10 +32,13 @@ Outputs:
## Boundaries
- This package owns managed workspace layout, path validation, filesystem
writes, and metadata reads.
- It does not fetch weather data, derive forecasts, build prompt input content,
compare briefing contents, invoke Scriptorium, or parse CLI flags.
`internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare briefing contents,
invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
converts adapter render results into that shape before saving.
## Config Fields Used
@@ -48,27 +51,48 @@ Outputs:
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
## External Adapters Used
## Managed Layout
- local filesystem
Paths are derived from the resolved report definition's artifact group, the
valid-period start date for JSON artifacts, and the RunID.
## State Or Manifest Behavior
```text
<workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
reports/<artifact_group>/<run_id>.md
```
Managed paths are grouped by report family and valid-period start date for JSON
artifacts. Reports are written under the report group. Metadata is stored beside
briefing snapshots and links briefing, data package, preflight, and report
paths. Report listing walks metadata files under the snapshots directory.
Metadata is stored beside briefing snapshots and links the briefing, data
package, preflight, and report paths. Report listing walks metadata files under
the snapshots directory.
Prior snapshot lookup reads metadata and selects the latest earlier compatible
snapshot. Daily Today and Daily Tomorrow are compatible with each other for the
same valid local date. 3-Day Outlook compares with prior 3-Day snapshots for
the same valid local date. Weekend Outlook compares with prior Weekend snapshots
for the same weekend window. Storm Report currently has no prior lookup.
## Prior Lookup
## Skip And Resume Behavior
Prior snapshot lookup reads stored metadata through the shared lookup path and
selects the latest earlier snapshot whose report ID is compatible with the
current report definition.
There is no resume workflow. Missing metadata directories return no inspection
records or no prior snapshot rather than creating state.
- Daily Today and Daily Tomorrow are compatible with each other for the same
valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
window.
- Storm Report currently has no prior lookup because explicit event-window
comparison is not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
prepared by creating their parent directory; Scriptorium writes the report body
to the prepared path. Extra Markdown copies are handled by app orchestration.
Inspection helpers read existing metadata, briefing, and data package files.
Missing metadata directories return no inspection records or no prior snapshot
rather than creating state.
## Failure Behavior
@@ -88,5 +112,6 @@ Inspect:
## Invariants
- Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -12,6 +12,7 @@ Developers and LLM coding agents should use it with
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
- `internal/config`: configuration structs, defaults, loading, overrides, and
validation.
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/forecast`: normalized bundle types and deterministic forecast

View File

@@ -3,12 +3,9 @@ package scriptorium
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
binary := r.Binary
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
args := r.renderArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
execution, err := r.execute(ctx, r.renderArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium render: %w", err)
}
result := &RenderResult{
Command: append([]string{binary}, args...),
Stdout: string(commandResult.Stdout),
Stderr: string(commandResult.Stderr),
StdoutTruncated: commandResult.StdoutTruncated,
StderrTruncated: commandResult.StderrTruncated,
ExitCode: commandResult.ExitCode,
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct {
binary string
args []string
result CommandResult
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary
if binary == "" {
binary = "scriptorium"
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if commands == nil {
commands = ExecRunner{}
}
args := r.runArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
result, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
return execution{}, 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,
return execution{binary: binary, args: args, result: result}, nil
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
}
return result, nil
func (e execution) argv() []string {
return append([]string{e.binary}, e.args...)
}
func (r Runner) renderArgs(req RenderRequest) []string {
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
return args
}
func SaveRenderResult(path string, result *RenderResult) error {
if result == nil {
return fmt.Errorf("render result is required")
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("marshal render result: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary preflight file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary preflight file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary preflight file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save preflight %q: %w", path, err)
}
return nil
}
type limitedBuffer struct {
data []byte
limit int

View File

@@ -11,14 +11,13 @@ import (
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
@@ -398,29 +397,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
}
func SaveBundle(path string, bundle *forecast.Bundle) error {
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return fmt.Errorf("marshal forecast bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary bundle file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary bundle file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary bundle file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save bundle %q: %w", path, err)
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err)
}
return nil
}

View File

@@ -4,9 +4,7 @@ package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -14,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -68,8 +67,6 @@ type BriefingRequest struct {
OutputPath string
}
type DailyBriefingRequest = BriefingRequest
type ReportRequest struct {
Config config.Config
Resolved report.Resolved
@@ -78,15 +75,11 @@ type ReportRequest struct {
Store state.Store
}
type DailyReportRequest = ReportRequest
type BriefingResult struct {
Package briefing.Package
OutputPath string
}
type DailyBriefingResult = BriefingResult
type ReportResult struct {
Briefing briefing.Package
BriefingPath string
@@ -103,8 +96,6 @@ type ReportResult struct {
RunResult *scriptorium.RunResult
}
type DailyReportResult = ReportResult
type BatchResult struct {
Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"`
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if err != nil {
return err
}
if isGeneratedReport(resolved.Definition.ID) {
if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) {
if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented")
}
}
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
}
func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" {
if outputDir == "" || definition.BatchOutputName == "" {
return ""
}
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
return filepath.Join(outputDir, name)
}
func isGeneratedReport(id report.ID) bool {
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
}
func isDailyReport(id report.ID) bool {
return id == report.DailyToday || id == report.DailyTomorrow
return filepath.Join(outputDir, definition.BatchOutputName)
}
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
return bundle, nil
}
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
return GenerateBriefing(ctx, req)
}
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
}
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
return GenerateReport(ctx, req)
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store
if store == nil {
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
preflightPath := paths.Preflight
if renderResult != nil {
var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
if err != nil {
return nil, err
}
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
OutputPath: reportPath,
})
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil
}
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
return BuildBriefing(req, bundle)
}
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
@@ -588,10 +558,6 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace)
}
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
return recentChanges(ctx, store, priorSnapshot, current, cfg)
}
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil {
return nil, nil
@@ -618,29 +584,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
}
}
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)
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
if result == nil {
return state.PreflightArtifact{}
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
return state.PreflightArtifact{
Command: append([]string(nil), result.Command...),
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
}
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

@@ -70,11 +70,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
}
}
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
func TestGenerateBriefingWritesArtifact(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg := dailyTestConfig(t, server)
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
@@ -85,13 +83,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
}
path := filepath.Join(t.TempDir(), "daily.briefing.json")
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg,
Resolved: resolved,
OutputPath: path,
})
if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err)
t.Fatalf("GenerateBriefing() error = %v", err)
}
if result.OutputPath != path {
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
@@ -108,11 +106,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
}
}
func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
func TestGenerateBriefingDefaultPath(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
@@ -123,23 +119,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg,
Resolved: resolved,
})
if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err)
t.Fatalf("GenerateBriefing() error = %v", err)
}
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
}
}
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
@@ -165,14 +159,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
}
outputPath := filepath.Join(t.TempDir(), "daily.md")
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err)
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 {
@@ -193,14 +187,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
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(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err)
}
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
@@ -236,7 +223,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
}
}
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -259,13 +246,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"),
}
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
_, err = GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
})
if err == nil {
t.Fatal("GenerateDailyReport() error = nil, want render error")
t.Fatal("GenerateReport() error = nil, want render error")
}
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
@@ -291,7 +278,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
}
}
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -315,13 +302,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
runBody: "# Daily Report\n",
}
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
_, err = GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
})
if err == nil {
t.Fatal("GenerateDailyReport() error = nil, want run error")
t.Fatal("GenerateReport() 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)
@@ -342,7 +329,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
}
}
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -393,14 +380,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
runBody: "# Daily Report\n",
}
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err)
t.Fatalf("GenerateReport() error = %v", err)
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
@@ -433,13 +420,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
runBody: "# Tomorrow Planning Brief\n",
}
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err)
t.Fatalf("GenerateReport() error = %v", err)
}
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
@@ -506,14 +493,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
runBody: "# Tomorrow Planning Brief\n",
}
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err)
t.Fatalf("GenerateReport() error = %v", err)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
@@ -1021,6 +1008,23 @@ func mustParse(value string) time.Time {
return parsed
}
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func assertPathsExist(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
}
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
low := 50.0
high := 58.0

View File

@@ -40,59 +40,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
}
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
store, err := defaultStore(req.Config)
if err != nil {
return state.Metadata{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
return metadata, err
inspection, err := inspectRun(ctx, req)
return inspection.metadata, err
}
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
store, err := defaultStore(req.Config)
inspection, err := inspectRun(ctx, req)
if err != nil {
return briefing.Package{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return briefing.Package{}, err
}
return store.LoadBriefing(ctx, metadata.BriefingPath)
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
}
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
store, err := defaultStore(req.Config)
inspection, err := inspectRun(ctx, req)
if err != nil {
return promptinput.Package{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return promptinput.Package{}, err
}
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
}
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
store, err := defaultStore(req.Config)
inspection, err := inspectRun(ctx, req)
if err != nil {
return nil, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
resolved, err := resolvedFromMetadata(inspection.metadata)
if err != nil {
return nil, err
}
resolved, err := resolvedFromMetadata(metadata)
if err != nil {
return nil, err
}
return store.FindPriorSnapshot(ctx, resolved)
return inspection.store.FindPriorSnapshot(ctx, resolved)
}
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
metadata, err := InspectMetadata(ctx, req)
inspection, err := inspectRun(ctx, req)
if err != nil {
return SourceInspection{}, err
}
metadata := inspection.metadata
return SourceInspection{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
}, nil
}
type runInspection struct {
store *state.FilesystemStore
metadata state.Metadata
}
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
store, err := defaultStore(req.Config)
if err != nil {
return runInspection{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return runInspection{}, err
}
return runInspection{store: store, metadata: metadata}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil {

View File

@@ -2,12 +2,10 @@
package briefing
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -78,29 +76,8 @@ func BuildMetadata(ctx BuildContext) Metadata {
}
func Save(path string, pkg Package) error {
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal briefing package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary briefing file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary briefing file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary briefing file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save briefing %q: %w", path, err)
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
return fmt.Errorf("save briefing package: %w", err)
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
@@ -72,7 +73,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
result, err := app.RunBatchDetailed(ctx, req)
if result != nil {
writeRunLogs(stderr, result)
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
return encodeErr
}
if result.Failed > 0 {
@@ -108,6 +109,29 @@ type inspectOptions struct {
RunID string
}
type inspectRunCommand struct {
Name string
Inspect func(context.Context, app.InspectRunRequest) (any, error)
}
var inspectRunCommands = []inspectRunCommand{
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectMetadata(ctx, req)
}},
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectBriefing(ctx, req)
}},
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectDataPackage(ctx, req)
}},
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectPriorSnapshot(ctx, req)
}},
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectSources(ctx, req)
}},
}
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("inspect requires a command")
@@ -128,81 +152,32 @@ func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer)
return err
}
return writeJSON(stdout, records)
case "metadata":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, metadata)
case "briefing":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "data-package":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "prior":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, prior)
case "sources":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, sources)
default:
for _, candidate := range inspectRunCommands {
if candidate.Name == command {
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
}
}
return fmt.Errorf("unknown inspect command %q", command)
}
}
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
opts, err := parseInspectRunFlags(command.Name, args)
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, value)
}
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
}
report, ok := reportKind(args[0])
reportKind, ok := reportKind(args[0])
if !ok {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
}
opts, err := parseGenerateFlags(report, args[1:])
opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil {
return app.GenerateRequest{}, err
}
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
Output: opts.Output,
})
if err != nil {
return app.GenerateRequest{}, err
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
req := app.GenerateRequest{
Config: cfg,
Report: report,
Report: reportKind,
OutputPath: opts.Output,
Now: r.Clock.Now(),
}
switch report {
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if opts.End == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
}
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location)
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil {
return app.GenerateRequest{}, err
}
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
if err != nil {
return app.GenerateRequest{}, err
}
if !req.StormEnd.After(req.StormStart) {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
}
req.StormStart = period.Start
req.StormEnd = period.End
}
return req, nil
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
return opts, nil
}
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")

View File

@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "storm.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
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", "storm", "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])
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "tomorrow.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
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-30", "*.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])
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
@@ -581,6 +561,45 @@ func TestRunInspectMissingMetadata(t *testing.T) {
}
}
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml")
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
for _, command := range commands {
t.Run(command+" requires run id", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing run id error")
}
if !strings.Contains(err.Error(), "requires a run id") {
t.Fatalf("error = %q, want missing run id context", err.Error())
}
})
t.Run(command+" accepts config", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing metadata error")
}
if !strings.Contains(err.Error(), "metadata for run id") {
t.Fatalf("error = %q, want missing metadata context", err.Error())
}
})
}
}
func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()}
tests := []struct {
@@ -643,7 +662,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
_, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing start error")
}
if !strings.Contains(err.Error(), "requires --start") {
t.Fatalf("error = %q, want missing start", err.Error())
}
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing end error")
}
@@ -652,6 +679,26 @@ func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
}
}
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--tz", "America/Chicago",
"--start", "2026-05-29T18:00",
"--end", "2026-05-30T06:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
t.Fatalf("StormStart = %q, want local Chicago time", got)
}
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
t.Fatalf("StormEnd = %q, want local Chicago time", got)
}
}
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
runner := Runner{Clock: fixedClock()}
@@ -668,6 +715,22 @@ func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
}
}
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-30T06:00",
"--end", "2026-05-29T18:00",
})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
}
if !strings.Contains(err.Error(), "end time after start time") {
t.Fatalf("error = %q, want invalid bounds context", err.Error())
}
}
func TestResolveRunCommands(t *testing.T) {
tests := []struct {
name string
@@ -739,6 +802,28 @@ func dailyServer(t *testing.T) *httptest.Server {
return server
}
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return configPath
}
func oneArtifact(t *testing.T, root string, parts ...string) string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
if err != nil {
t.Fatalf("glob artifact: %v", err)
}
if len(matches) != 1 {
t.Fatalf("artifact matches = %#v, want one", matches)
}
return matches[0]
}
func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")

View File

@@ -17,7 +17,6 @@ type Config struct {
MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"`
Reports ReportOutputConfig `yaml:"reports"`
Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
}
@@ -52,11 +51,6 @@ type WorkspaceConfig struct {
PreflightDir string `yaml:"preflight_dir"`
}
type ReportOutputConfig struct {
OutputDir string `yaml:"output_dir"`
Paths map[string]string `yaml:"paths"`
}
type DaypartConfig struct {
Name string `yaml:"name"`
Start string `yaml:"start"`

View File

@@ -92,7 +92,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
}
func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
@@ -102,7 +102,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
}
if cfg.Reports.OutputDir != "./out" {
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
}
}

View File

@@ -28,10 +28,6 @@ func Defaults() Config {
DataPackagesDir: "data-packages",
PreflightDir: "preflight",
},
Reports: ReportOutputConfig{
OutputDir: "reports",
Paths: map[string]string{},
},
Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"},

View File

@@ -12,7 +12,6 @@ type LoadOptions struct {
Path string
Units string
Timezone string
Output string
}
func Load(opts LoadOptions) (Config, error) {
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
if opts.Timezone != "" {
cfg.WeatherAPI.Timezone = opts.Timezone
}
if opts.Output != "" {
cfg.Reports.OutputDir = opts.Output
}
if err := Validate(cfg); err != nil {
return Config{}, err
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
}
if cfg.Reports.Paths == nil {
cfg.Reports.Paths = map[string]string{}
}
return nil
}

View File

@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required")
}
if cfg.Reports.OutputDir == "" {
return fmt.Errorf("reports.output_dir is required")
}
if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry")
}

View File

@@ -0,0 +1,48 @@
// Package fileutil provides narrow filesystem helpers for durable artifacts.
package fileutil
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func WriteFileAtomic(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func WriteJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
return WriteFileAtomic(path, data)
}
func CopyFileAtomic(source string, target string) error {
data, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read %q: %w", source, err)
}
return WriteFileAtomic(target, data)
}

View File

@@ -0,0 +1,103 @@
package fileutil
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFileAtomicCreatesParentDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "artifact.txt")
if err := WriteFileAtomic(path, []byte("artifact")); err != nil {
t.Fatalf("WriteFileAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "artifact" {
t.Fatalf("data = %q, want artifact", data)
}
}
func TestWriteFileAtomicOverwritesTarget(t *testing.T) {
path := filepath.Join(t.TempDir(), "artifact.txt")
if err := WriteFileAtomic(path, []byte("old")); err != nil {
t.Fatalf("WriteFileAtomic() initial error = %v", err)
}
if err := WriteFileAtomic(path, []byte("new")); err != nil {
t.Fatalf("WriteFileAtomic() overwrite error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "new" {
t.Fatalf("data = %q, want new", data)
}
}
func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
err := WriteFileAtomic(target, []byte("data"))
if err == nil {
t.Fatal("WriteFileAtomic() error = nil, want rename error")
}
if !strings.Contains(err.Error(), "save") {
t.Fatalf("error = %q, want save context", err.Error())
}
matches, err := filepath.Glob(filepath.Join(dir, ".target.*.tmp"))
if err != nil {
t.Fatalf("Glob() error = %v", err)
}
if len(matches) != 0 {
t.Fatalf("temporary files = %v, want none", matches)
}
}
func TestWriteJSONAtomic(t *testing.T) {
path := filepath.Join(t.TempDir(), "artifact.json")
if err := WriteJSONAtomic(path, map[string]string{"status": "ok"}); err != nil {
t.Fatalf("WriteJSONAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "{\n \"status\": \"ok\"\n}" {
t.Fatalf("json = %q, want indented object", data)
}
}
func TestCopyFileAtomic(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source.txt")
target := filepath.Join(dir, "nested", "target.txt")
if err := os.WriteFile(source, []byte("copied"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := CopyFileAtomic(source, target); err != nil {
t.Fatalf("CopyFileAtomic() error = %v", err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "copied" {
t.Fatalf("data = %q, want copied", data)
}
}

View File

@@ -2,14 +2,12 @@
package promptinput
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -117,29 +115,8 @@ func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil {
return err
}
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal data package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary data package file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary data package file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary data package file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save data package %q: %w", path, err)
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
return fmt.Errorf("save data package: %w", err)
}
return nil
}

View File

@@ -39,6 +39,10 @@ type Definition struct {
PromptID string
ComparisonStrategy ComparisonStrategy
DefaultOutputName string
ArtifactGroup string
BatchOutputName string
Generated bool
CompatiblePriorIDs []ID
Morning bool
Evening bool
resolve func(ResolveRequest) (timeutil.Period, error)
@@ -51,6 +55,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
return d.resolve(req)
}
func (d Definition) CompatibleWithPrior(id ID) bool {
for _, compatibleID := range d.CompatiblePriorIDs {
if id == compatibleID {
return true
}
}
return false
}
type ResolveRequest struct {
Now time.Time
Location *time.Location

View File

@@ -1,6 +1,7 @@
package report
import (
"reflect"
"strings"
"testing"
"time"
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
}
}
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct {
id ID
artifactGroup string
batchOutputName string
generated bool
compatiblePriorIDs []ID
}{
{
id: DailyToday,
artifactGroup: "daily",
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: DailyTomorrow,
artifactGroup: "daily",
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: ThreeDay,
artifactGroup: "three-day",
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
},
{
id: Weekend,
artifactGroup: "weekend",
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
},
{
id: Storm,
artifactGroup: "storm",
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if definition.ArtifactGroup != tt.artifactGroup {
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
}
if definition.BatchOutputName != tt.batchOutputName {
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
}
if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
for _, id := range tt.compatiblePriorIDs {
if !definition.CompatibleWithPrior(id) {
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
}
}
})
}
}
func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})

View File

@@ -14,6 +14,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "daily.md",
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Morning: true,
resolve: resolveDailyToday,
},
@@ -23,6 +27,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "tomorrow.md",
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Evening: true,
resolve: resolveDailyTomorrow,
},
@@ -32,6 +40,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "three_day.md",
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Morning: true,
resolve: resolveThreeDay,
},
@@ -41,6 +53,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow,
DefaultOutputName: "weekend.md",
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Morning: true,
resolve: resolveWeekend,
},
@@ -50,6 +66,10 @@ func DefaultRegistry() Registry {
PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow,
DefaultOutputName: "storm.md",
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
Generated: true,
CompatiblePriorIDs: []ID{Storm},
resolve: resolveStorm,
},
}

View File

@@ -10,9 +10,9 @@ import (
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required")
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return ArtifactPaths{}, err
group := resolved.Definition.ArtifactGroup
if group == "" {
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
}
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
return "", err
}
return paths.Briefing, nil
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
if err := promptinput.Validate(pkg); err != nil {
return "", err
}
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err
}
return paths.DataPackage, nil
}
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
if result == nil {
return "", fmt.Errorf("render result is required")
}
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
return "", err
}
return paths.Preflight, nil
@@ -161,23 +158,19 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
if path == "" {
return "", fmt.Errorf("metadata path cannot be resolved")
}
if err := writeJSONAtomic(path, metadata); err != nil {
if err := fileutil.WriteJSONAtomic(path, metadata); err != nil {
return "", err
}
return path, nil
}
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
return s.FindPriorSnapshot(ctx, resolved)
}
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return nil, nil
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return nil, err
group := resolved.Definition.ArtifactGroup
if group == "" {
return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
}
dirs, err := s.metadataDirectories(resolved, group)
if err != nil {
@@ -205,7 +198,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
if metadata.RunID == resolved.Metadata().RunID {
continue
}
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
continue
}
if !comparablePeriod(metadata, resolved) {
@@ -343,19 +336,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
return dirs, nil
}
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
switch group {
case "daily":
return prior == report.DailyToday || prior == report.DailyTomorrow
case "three-day":
return prior == report.ThreeDay && current == report.ThreeDay
case "weekend":
return prior == report.Weekend && current == report.Weekend
default:
return false
}
}
func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...)
return filepath.Join(all...)
@@ -375,48 +355,6 @@ func validateRelativeDir(name string, value string) error {
return nil
}
func reportGroup(id report.ID) (string, error) {
switch id {
case report.DailyToday, report.DailyTomorrow:
return "daily", nil
case report.ThreeDay:
return "three-day", nil
case report.Weekend:
return "weekend", nil
case report.Storm:
return "storm", nil
default:
return "", fmt.Errorf("unknown report %q", id)
}
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func readJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {

View File

@@ -9,7 +9,6 @@ import (
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err)
}
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`})
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
if err != nil {
t.Fatalf("SavePreflight() error = %v", err)
}
@@ -67,6 +66,17 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
t.Fatalf("write rendered report: %v", err)
}
var preflight PreflightArtifact
preflightData, err := os.ReadFile(preflightPath)
if err != nil {
t.Fatalf("read preflight: %v", err)
}
if err := json.Unmarshal(preflightData, &preflight); err != nil {
t.Fatalf("decode preflight: %v", err)
}
if preflight.Stdout != `{"ok":true}` {
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
@@ -114,7 +124,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
}
}
func TestFindPriorDailySnapshot(t *testing.T) {
func TestFindPriorSnapshot(t *testing.T) {
store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
@@ -138,12 +148,12 @@ func TestFindPriorDailySnapshot(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorDailySnapshot(context.Background(), second)
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
@@ -153,7 +163,7 @@ func TestFindPriorDailySnapshot(t *testing.T) {
}
}
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
store := newTestStore(t)
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
@@ -177,12 +187,12 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate)
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior != nil {
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior)
t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
}
}

View File

@@ -4,7 +4,6 @@ package state
import (
"context"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -14,11 +13,10 @@ type Store interface {
Paths(report.Resolved) (ArtifactPaths, error)
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)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadBriefing(context.Context, string) (briefing.Package, error)
}
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
Metadata Metadata
BriefingPath string
}
type PreflightArtifact 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"`
}