Add filesystem state metadata baseline

This commit is contained in:
2026-05-29 17:40:38 +00:00
parent e556ca5edc
commit 7ac73f758b
12 changed files with 822 additions and 50 deletions

View File

@@ -3,9 +3,10 @@
`weatherreporter` is a Go application for preparing human-facing weather `weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. reports from normalized forecast data.
The application can currently prepare a Daily prompt input data package and run The application can currently prepare a Daily prompt input data package, persist
`scriptorium render` as a preflight check. Full rendered reports are tracked in inspectable workspace metadata, and run `scriptorium render` as a preflight
the roadmap and are not implemented yet. check. Full rendered reports are tracked in the roadmap and are not implemented
yet.
## Quickstart ## Quickstart
@@ -17,6 +18,7 @@ weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json
- [CLI reference](docs/cli.md) - [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md) - [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md)
- [Architecture policy](docs/policy/architecture.md) - [Architecture policy](docs/policy/architecture.md)
- [Development policy](docs/policy/development.md) - [Development policy](docs/policy/development.md)
- [Implementation roadmap](docs/roadmap/initial.md) - [Implementation roadmap](docs/roadmap/initial.md)

View File

@@ -1,9 +1,10 @@
# Weatherreporter CLI # Weatherreporter CLI
`weatherreporter generate daily` currently writes a Daily prompt input data `weatherreporter generate daily` currently writes managed Daily preparation
package and runs `scriptorium render` as a preflight check. Other report artifacts under the configured workspace and runs `scriptorium render` as a
generation and scheduled runs still resolve configuration, report definitions, preflight check. Other report generation and scheduled runs still resolve
and valid periods, then return a not-implemented error. configuration, report definitions, and valid periods, then return a
not-implemented error.
## Shortest Useful Command ## Shortest Useful Command
@@ -12,8 +13,9 @@ weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json
``` ```
The command parses flags, loads configuration, fetches weather data, builds a The command parses flags, loads configuration, fetches weather data, builds a
Daily briefing, writes the `data_package` JSON artifact to `--out`, and invokes Daily briefing, writes workspace artifacts, and invokes
`scriptorium render --input data_package=<path> --format json`. `scriptorium render --input data_package=<managed_path> --format json`. When
`--out` is supplied, it also writes a copy of the data package to that path.
## Command Overview ## Command Overview
@@ -27,11 +29,11 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`generate daily` writes a data package JSON artifact, writes the Daily briefing `generate daily` writes a briefing snapshot, prompt input data package, render
snapshot under the configured workspace, and writes the render preflight output preflight output, and metadata file under the configured workspace. Other
under the configured workspace. Other `generate` commands resolve one report `generate` commands resolve one report request and stop before report
request and stop before report generation. `run` commands resolve a scheduled generation. `run` commands resolve a scheduled batch request and stop before
batch request and stop before execution. execution.
## Flags ## Flags
@@ -39,7 +41,7 @@ batch request and stop before execution.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units. - `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone. - `--tz NAME`: override configured Weather API timezone.
- `--out PATH`: data package output path for `generate daily`; reserved for later generated report output on other `generate` commands. - `--out PATH`: optional extra data package copy for `generate daily`; reserved for later generated report output on other `generate` commands.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`. - `--end TIME`: required end time for `generate storm`.

69
docs/internal/state.md Normal file
View File

@@ -0,0 +1,69 @@
# State Internals
This document describes the implemented filesystem state boundary.
## Purpose
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
prior Daily snapshot lookup.
## Inputs and Outputs
Inputs:
- workspace configuration
- resolved report definition and valid period
- briefing package
- prompt input data package
- `scriptorium render` result
Outputs:
- briefing snapshot JSON
- prompt input data package JSON
- render preflight JSON
- metadata JSON
- prior Daily snapshot metadata when available
## Boundaries
- This package owns managed workspace layout and narrow path validation.
- It does not fetch weather data, derive forecasts, build prompt inputs, invoke
`scriptorium`, or compare briefing contents.
## Config Fields Used
- `workspace.root`
- `workspace.snapshots_dir`
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
## State Behavior
Managed artifact names use RunID, which is generated from report generation time
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
local date. Prior Daily snapshot lookup reads metadata for the same valid local
date and returns the latest earlier run.
## Failure Behavior
Writes are atomic where practical: JSON is written to a temporary file in the
target directory and then renamed into place. Invalid workspace paths and
missing required metadata fields produce actionable errors.
## Tests
Inspect:
- `internal/state/filesystem_test.go`
- `internal/app/app_test.go`
## Invariants
- Managed paths stay under the configured workspace root.
- Metadata links the artifacts produced for a run.
- Prior lookup is based on structured metadata, not rendered report text.

73
docs/operations.md Normal file
View File

@@ -0,0 +1,73 @@
# Weatherreporter Operations
## Normal Workflow
The implemented preparation workflow is:
```text
weatherreporter generate daily --date 2026-05-29
```
The command fetches weather data, builds the Daily briefing, builds the prompt
input data package, runs `scriptorium render`, and writes inspectable artifacts
under the configured workspace.
## Filesystem Layout
The default workspace root is `workspace`.
```text
workspace/
snapshots/
daily/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.json
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
reports/
daily/
<run_id>.md
```
The report path is reserved in metadata for the eventual rendered report. The
current workflow does not write the Markdown report.
## Run Identifiers
Run IDs are based on generation time plus report ID, such as:
```text
20260529T100000.123456789Z_daily_today
```
Managed artifact filenames use the RunID so repeated runs for the same valid
date do not overwrite each other.
## Metadata
Each Daily preparation writes metadata that links:
- RunID
- report ID and prompt ID
- generation time and valid period
- source location, source hashes, and source warnings
- briefing snapshot path
- prompt input data package path
- preflight output path
- reserved rendered report path
## Recovery
If render preflight exits nonzero after producing a result, the captured stdout,
stderr, exit code, and command are still written to the preflight artifact, and
metadata is still written for inspection.
The application does not currently implement resume, cleanup, archive, or
remote storage behavior.

View File

@@ -4,7 +4,6 @@ package app
import ( import (
"context" "context"
"fmt" "fmt"
"path/filepath"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -14,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -64,6 +64,7 @@ type DailyPreparationRequest struct {
Resolved report.Resolved Resolved report.Resolved
DataPackagePath string DataPackagePath string
Renderer Renderer Renderer Renderer
Store state.Store
} }
type DailyBriefingResult struct { type DailyBriefingResult struct {
@@ -77,6 +78,9 @@ type DailyPreparationResult struct {
DataPackage promptinput.Package DataPackage promptinput.Package
DataPackagePath string DataPackagePath string
PreflightPath string PreflightPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RenderResult *scriptorium.RenderResult RenderResult *scriptorium.RenderResult
} }
@@ -206,7 +210,15 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
} }
outputPath := req.OutputPath outputPath := req.OutputPath
if outputPath == "" { if outputPath == "" {
outputPath = defaultBriefingPath(req.Config, req.Resolved) store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
outputPath = paths.Briefing
} }
if err := briefing.Save(outputPath, pkg); err != nil { if err := briefing.Save(outputPath, pkg); err != nil {
return nil, err return nil, err
@@ -215,6 +227,23 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
} }
func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*DailyPreparationResult, error) { func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*DailyPreparationResult, error) {
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
priorSnapshot, err := store.FindPriorDailySnapshot(ctx, req.Resolved)
if err != nil {
return nil, err
}
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -227,8 +256,8 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
return nil, err return nil, err
} }
briefingPath := defaultBriefingPath(req.Config, req.Resolved) briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage)
if err := briefing.Save(briefingPath, briefingPackage); err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -236,13 +265,15 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
if err != nil { if err != nil {
return nil, err return nil, err
} }
dataPackagePath := req.DataPackagePath dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage)
if dataPackagePath == "" { if err != nil {
dataPackagePath = defaultDataPackagePath(req.Config, req.Resolved)
}
if err := promptinput.Save(dataPackagePath, dataPackage); err != nil {
return nil, err return nil, err
} }
if req.DataPackagePath != "" && req.DataPackagePath != dataPackagePath {
if err := promptinput.Save(req.DataPackagePath, dataPackage); err != nil {
return nil, err
}
}
renderer := req.Renderer renderer := req.Renderer
if renderer == nil { if renderer == nil {
@@ -259,12 +290,25 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
}) })
preflightPath := defaultPreflightPath(req.Config, req.Resolved) preflightPath := paths.Preflight
if renderResult != nil { if renderResult != nil {
if err := scriptorium.SaveRenderResult(preflightPath, renderResult); err != nil { var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
if err != nil {
return nil, err return nil, err
} }
} }
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if renderErr != nil { if renderErr != nil {
return nil, renderErr return nil, renderErr
} }
@@ -275,6 +319,9 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail
DataPackage: dataPackage, DataPackage: dataPackage,
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
PreflightPath: preflightPath, PreflightPath: preflightPath,
Metadata: metadata,
MetadataPath: metadataPath,
PriorSnapshot: priorSnapshot,
RenderResult: renderResult, RenderResult: renderResult,
}, nil }, nil
} }
@@ -304,20 +351,6 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie
}, summary) }, summary)
} }
func defaultBriefingPath(cfg config.Config, resolved report.Resolved) string { func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") return state.NewFilesystemStore(cfg.Workspace)
filename := validDate + "." + string(resolved.Definition.ID) + ".briefing.json"
return filepath.Join(cfg.Workspace.Root, cfg.Workspace.SnapshotsDir, "daily", validDate, filename)
}
func defaultDataPackagePath(cfg config.Config, resolved report.Resolved) string {
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filename := resolved.Metadata().RunID + ".data_package.json"
return filepath.Join(cfg.Workspace.Root, cfg.Workspace.DataPackagesDir, "daily", validDate, filename)
}
func defaultPreflightPath(cfg config.Config, resolved report.Resolved) string {
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filename := resolved.Metadata().RunID + ".render.json"
return filepath.Join(cfg.Workspace.Root, cfg.Workspace.PreflightDir, "daily", validDate, filename)
} }

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
) )
func TestFetchAndSaveBundle(t *testing.T) { func TestFetchAndSaveBundle(t *testing.T) {
@@ -127,7 +128,7 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err) t.Fatalf("GenerateDailyBriefing() error = %v", err)
} }
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "2026-05-29.daily_today.briefing.json")) { 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) t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
} }
} }
@@ -171,14 +172,17 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
if renderer.request.PromptID != "weather.daily_report" { if renderer.request.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID) t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID)
} }
if renderer.request.DataPackagePath != dataPackagePath { if renderer.request.DataPackagePath != result.DataPackagePath {
t.Fatalf("DataPackagePath = %q, want %q", renderer.request.DataPackagePath, dataPackagePath) t.Fatalf("DataPackagePath = %q, want managed path %q", renderer.request.DataPackagePath, result.DataPackagePath)
} }
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath} { for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.MetadataPath} {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err) t.Fatalf("expected artifact %q: %v", path, err)
} }
} }
if _, err := os.Stat(dataPackagePath); err != nil {
t.Fatalf("expected requested data package copy %q: %v", dataPackagePath, err)
}
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
@@ -193,6 +197,12 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) {
if !strings.Contains(string(preflight), `prepared`) { if !strings.Contains(string(preflight), `prepared`) {
t.Fatalf("preflight output missing render stdout:\n%s", string(preflight)) t.Fatalf("preflight output missing render stdout:\n%s", string(preflight))
} }
if result.Metadata.RunID != resolved.Metadata().RunID {
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
}
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath {
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
}
} }
func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
@@ -226,7 +236,15 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("PrepareDailyReport() error = nil, want render error") t.Fatal("PrepareDailyReport() error = nil, want render error")
} }
preflightPath := defaultPreflightPath(cfg, resolved) 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)
}
preflightPath := paths.Preflight
preflight, readErr := os.ReadFile(preflightPath) preflight, readErr := os.ReadFile(preflightPath)
if readErr != nil { if readErr != nil {
t.Fatalf("read failed preflight: %v", readErr) t.Fatalf("read failed preflight: %v", readErr)
@@ -234,6 +252,9 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) {
if !strings.Contains(string(preflight), `"exitCode": 1`) { if !strings.Contains(string(preflight), `"exitCode": 1`) {
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight)) t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
} }
if _, err := os.Stat(paths.Metadata); err != nil {
t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err)
}
} }
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {

View File

@@ -28,7 +28,7 @@ Options:
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Override the generated data package path for generate daily. --out PATH Write an extra data package copy for generate daily.
` `
type Runner struct { type Runner struct {
@@ -210,7 +210,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
if includeOutput { if includeOutput {
fs.StringVar(&opts.Output, "out", "", "generated data package path") fs.StringVar(&opts.Output, "out", "", "extra data package copy path")
} }
} }

View File

@@ -77,7 +77,7 @@ type Metadata struct {
func (r Resolved) Metadata() Metadata { func (r Resolved) Metadata() Metadata {
return Metadata{ return Metadata{
RunID: r.GeneratedAt.UTC().Format("20060102T150405Z") + "_" + string(r.Definition.ID), RunID: r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID),
ReportID: r.Definition.ID, ReportID: r.Definition.ID,
PromptID: r.Definition.PromptID, PromptID: r.Definition.PromptID,
GeneratedAt: r.GeneratedAt, GeneratedAt: r.GeneratedAt,

View File

@@ -0,0 +1,285 @@
package state
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"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"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type FilesystemStore struct {
root string
snapshotsDir string
reportsDir string
dataPackagesDir string
preflightDir string
}
type ArtifactPaths struct {
Briefing string `json:"briefing"`
Metadata string `json:"metadata"`
DataPackage string `json:"dataPackage"`
Preflight string `json:"preflight"`
RenderedReport string `json:"renderedReport,omitempty"`
}
func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) {
if cfg.Root == "" {
return nil, fmt.Errorf("workspace root is required")
}
for name, value := range map[string]string{
"snapshots_dir": cfg.SnapshotsDir,
"reports_dir": cfg.ReportsDir,
"data_packages_dir": cfg.DataPackagesDir,
"preflight_dir": cfg.PreflightDir,
} {
if err := validateRelativeDir(name, value); err != nil {
return nil, err
}
}
return &FilesystemStore{
root: filepath.Clean(cfg.Root),
snapshotsDir: filepath.Clean(cfg.SnapshotsDir),
reportsDir: filepath.Clean(cfg.ReportsDir),
dataPackagesDir: filepath.Clean(cfg.DataPackagesDir),
preflightDir: filepath.Clean(cfg.PreflightDir),
}, nil
}
func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) {
if s == nil {
return ArtifactPaths{}, fmt.Errorf("state store is required")
}
metadata := resolved.Metadata()
if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required")
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return ArtifactPaths{}, err
}
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID
return ArtifactPaths{
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.json"),
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
}, nil
}
func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
return "", err
}
return paths.Briefing, nil
}
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := promptinput.Validate(pkg); err != nil {
return "", err
}
if err := 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")
}
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
return "", err
}
return paths.Preflight, nil
}
func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) {
if metadata.RunID == "" {
return "", fmt.Errorf("metadata run id is required")
}
if metadata.BriefingPath == "" {
return "", fmt.Errorf("metadata briefing path is required")
}
if metadata.DataPackagePath == "" {
return "", fmt.Errorf("metadata data package path is required")
}
if metadata.PreflightPath == "" {
return "", fmt.Errorf("metadata preflight path is required")
}
path := metadataPathFromStored(metadata)
if path == "" {
return "", fmt.Errorf("metadata path cannot be resolved")
}
if err := writeJSONAtomic(path, metadata); err != nil {
return "", err
}
return path, nil
}
func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate {
return nil, nil
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return nil, err
}
if group != "daily" {
return nil, nil
}
paths, err := s.Paths(resolved)
if err != nil {
return nil, err
}
dir := filepath.Dir(paths.Metadata)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
}
var candidates []Metadata
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
continue
}
path := filepath.Join(dir, entry.Name())
var metadata Metadata
if err := readJSON(path, &metadata); err != nil {
return nil, err
}
if metadata.RunID == resolved.Metadata().RunID {
continue
}
if metadata.ReportID != report.DailyToday && metadata.ReportID != report.DailyTomorrow {
continue
}
if !sameValidDate(metadata, resolved) {
continue
}
if !metadata.GeneratedAt.Before(resolved.GeneratedAt) {
continue
}
candidates = append(candidates, metadata)
}
if len(candidates) == 0 {
return nil, nil
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt)
})
return &PriorSnapshot{
Metadata: candidates[0],
BriefingPath: candidates[0].BriefingPath,
}, nil
}
func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...)
return filepath.Join(all...)
}
func validateRelativeDir(name string, value string) error {
if value == "" {
return fmt.Errorf("%s is required", name)
}
if filepath.IsAbs(value) {
return fmt.Errorf("%s must be relative to workspace root", name)
}
cleaned := filepath.Clean(value)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return fmt.Errorf("%s must stay within workspace root", name)
}
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 {
return fmt.Errorf("read %q: %w", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("decode %q: %w", path, err)
}
return nil
}
func metadataPathFromStored(metadata Metadata) string {
if metadata.BriefingPath == "" {
return ""
}
filename := metadata.RunID + ".metadata.json"
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
}
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
}

View File

@@ -0,0 +1,209 @@
package state
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"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"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestPathsUseRunIDAndWorkspace(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
for _, want := range []string{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.json"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
} {
if !strings.Contains(pathsString(paths), want) {
t.Fatalf("paths = %#v, want component %q", paths, want)
}
}
}
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
briefingPackage := stateBriefingPackage(resolved)
dataPackage, err := promptinput.Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err)
}
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`})
if err != nil {
t.Fatalf("SavePreflight() error = %v", err)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
for _, path := range []string{briefingPath, dataPackagePath, preflightPath, metadataPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
var decoded Metadata
data, err := os.ReadFile(metadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode metadata: %v", err)
}
if decoded.RunID != resolved.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
}
if decoded.BriefingPath != briefingPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
}
}
func TestFindPriorDailySnapshot(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")
briefingPackage := stateBriefingPackage(first)
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorDailySnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
if prior.BriefingPath != briefingPath {
t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath)
}
}
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
cfg := config.Defaults().Workspace
cfg.Root = t.TempDir()
cfg.SnapshotsDir = "../snapshots"
_, err := NewFilesystemStore(cfg)
if err == nil {
t.Fatal("NewFilesystemStore() error = nil, want unsafe path error")
}
if !strings.Contains(err.Error(), "within workspace root") {
t.Fatalf("error = %q, want path safety context", err.Error())
}
}
func newTestStore(t *testing.T) *FilesystemStore {
t.Helper()
cfg := config.Defaults().Workspace
cfg.Root = t.TempDir()
store, err := NewFilesystemStore(cfg)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
return store
}
func resolveDailyAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.DailyToday, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func stateBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
Variant: "today",
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
}
}
func pathsString(paths ArtifactPaths) string {
return strings.Join([]string{
paths.Briefing,
paths.Metadata,
paths.DataPackage,
paths.Preflight,
paths.RenderedReport,
}, "\n")
}

View File

@@ -0,0 +1,53 @@
package state
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const MetadataSchemaVersion = "weatherreporter.metadata.v1"
type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
BriefingPath string `json:"briefingPath"`
DataPackagePath string `json:"dataPackagePath"`
PreflightPath string `json:"preflightPath"`
RenderedReportPath string `json:"renderedReportPath,omitempty"`
}
func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, paths ArtifactPaths) Metadata {
metadata := resolved.Metadata()
return Metadata{
SchemaVersion: MetadataSchemaVersion,
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: briefingPackage.Metadata.Variant,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod,
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
SourceLocation: briefingPackage.Metadata.SourceLocation,
Sources: briefingPackage.Metadata.Sources,
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
BriefingPath: paths.Briefing,
DataPackagePath: paths.DataPackage,
PreflightPath: paths.Preflight,
RenderedReportPath: paths.RenderedReport,
}
}

25
internal/state/store.go Normal file
View File

@@ -0,0 +1,25 @@
// Package state persists report artifacts and metadata.
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"
)
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)
SaveMetadata(context.Context, Metadata) (string, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
}
type PriorSnapshot struct {
Metadata Metadata
BriefingPath string
}