Add filesystem state metadata baseline
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
26
docs/cli.md
26
docs/cli.md
@@ -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
69
docs/internal/state.md
Normal 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
73
docs/operations.md
Normal 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.
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
285
internal/state/filesystem.go
Normal file
285
internal/state/filesystem.go
Normal 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")
|
||||||
|
}
|
||||||
209
internal/state/filesystem_test.go
Normal file
209
internal/state/filesystem_test.go
Normal 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")
|
||||||
|
}
|
||||||
53
internal/state/metadata.go
Normal file
53
internal/state/metadata.go
Normal 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
25
internal/state/store.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user