From 7ac73f758be3bbc804a5588e7571d85e439fc164 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 17:40:38 +0000 Subject: [PATCH] Add filesystem state metadata baseline --- README.md | 8 +- docs/cli.md | 26 +-- docs/internal/state.md | 69 ++++++++ docs/operations.md | 73 ++++++++ internal/app/app.go | 87 ++++++--- internal/app/app_test.go | 31 +++- internal/cli/root.go | 4 +- internal/report/definition.go | 2 +- internal/state/filesystem.go | 285 ++++++++++++++++++++++++++++++ internal/state/filesystem_test.go | 209 ++++++++++++++++++++++ internal/state/metadata.go | 53 ++++++ internal/state/store.go | 25 +++ 12 files changed, 822 insertions(+), 50 deletions(-) create mode 100644 docs/internal/state.md create mode 100644 docs/operations.md create mode 100644 internal/state/filesystem.go create mode 100644 internal/state/filesystem_test.go create mode 100644 internal/state/metadata.go create mode 100644 internal/state/store.go diff --git a/README.md b/README.md index c9d6a81..b41883b 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application can currently prepare a Daily prompt input data package and run -`scriptorium render` as a preflight check. Full rendered reports are tracked in -the roadmap and are not implemented yet. +The application can currently prepare a Daily prompt input data package, persist +inspectable workspace metadata, and run `scriptorium render` as a preflight +check. Full rendered reports are tracked in the roadmap and are not implemented +yet. ## Quickstart @@ -17,6 +18,7 @@ weatherreporter generate daily --date 2026-05-29 --out ./daily.data_package.json - [CLI reference](docs/cli.md) - [Configuration reference](docs/config.md) +- [Operations guide](docs/operations.md) - [Architecture policy](docs/policy/architecture.md) - [Development policy](docs/policy/development.md) - [Implementation roadmap](docs/roadmap/initial.md) diff --git a/docs/cli.md b/docs/cli.md index b160d8c..57b10e2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,9 +1,10 @@ # Weatherreporter CLI -`weatherreporter generate daily` currently writes a Daily prompt input data -package and runs `scriptorium render` as a preflight check. Other report -generation and scheduled runs still resolve configuration, report definitions, -and valid periods, then return a not-implemented error. +`weatherreporter generate daily` currently writes managed Daily preparation +artifacts under the configured workspace and runs `scriptorium render` as a +preflight check. Other report generation and scheduled runs still resolve +configuration, report definitions, and valid periods, then return a +not-implemented error. ## 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 -Daily briefing, writes the `data_package` JSON artifact to `--out`, and invokes -`scriptorium render --input data_package= --format json`. +Daily briefing, writes workspace artifacts, and invokes +`scriptorium render --input data_package= --format json`. When +`--out` is supplied, it also writes a copy of the data package to that path. ## Command Overview @@ -27,11 +29,11 @@ weatherreporter run morning weatherreporter run evening ``` -`generate daily` writes a data package JSON artifact, writes the Daily briefing -snapshot under the configured workspace, and writes the render preflight output -under the configured workspace. Other `generate` commands resolve one report -request and stop before report generation. `run` commands resolve a scheduled -batch request and stop before execution. +`generate daily` writes a briefing snapshot, prompt input data package, render +preflight output, and metadata file under the configured workspace. Other +`generate` commands resolve one report request and stop before report +generation. `run` commands resolve a scheduled batch request and stop before +execution. ## 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`. - `--units VALUE`: override configured Weather API units. - `--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. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. diff --git a/docs/internal/state.md b/docs/internal/state.md new file mode 100644 index 0000000..c3d8efe --- /dev/null +++ b/docs/internal/state.md @@ -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. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..121db42 --- /dev/null +++ b/docs/operations.md @@ -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/ + .briefing.json + .metadata.json + data-packages/ + daily/ + YYYY-MM-DD/ + .data_package.json + preflight/ + daily/ + YYYY-MM-DD/ + .render.json + reports/ + daily/ + .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. diff --git a/internal/app/app.go b/internal/app/app.go index b6c06c4..2755c6f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,7 +4,6 @@ package app import ( "context" "fmt" - "path/filepath" "time" "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/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) @@ -64,6 +64,7 @@ type DailyPreparationRequest struct { Resolved report.Resolved DataPackagePath string Renderer Renderer + Store state.Store } type DailyBriefingResult struct { @@ -77,6 +78,9 @@ type DailyPreparationResult struct { DataPackage promptinput.Package DataPackagePath string PreflightPath string + Metadata state.Metadata + MetadataPath string + PriorSnapshot *state.PriorSnapshot RenderResult *scriptorium.RenderResult } @@ -206,7 +210,15 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail } outputPath := req.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 { return nil, err @@ -215,6 +227,23 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail } 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}) if err != nil { return nil, err @@ -227,8 +256,8 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail return nil, err } - briefingPath := defaultBriefingPath(req.Config, req.Resolved) - if err := briefing.Save(briefingPath, briefingPackage); err != nil { + briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage) + if err != nil { return nil, err } @@ -236,13 +265,15 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail if err != nil { return nil, err } - dataPackagePath := req.DataPackagePath - if dataPackagePath == "" { - dataPackagePath = defaultDataPackagePath(req.Config, req.Resolved) - } - if err := promptinput.Save(dataPackagePath, dataPackage); err != nil { + dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage) + if err != nil { return nil, err } + if req.DataPackagePath != "" && req.DataPackagePath != dataPackagePath { + if err := promptinput.Save(req.DataPackagePath, dataPackage); err != nil { + return nil, err + } + } renderer := req.Renderer if renderer == nil { @@ -259,12 +290,25 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail DataPackagePath: dataPackagePath, }) - preflightPath := defaultPreflightPath(req.Config, req.Resolved) + preflightPath := paths.Preflight 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 } } + 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 { return nil, renderErr } @@ -275,6 +319,9 @@ func PrepareDailyReport(ctx context.Context, req DailyPreparationRequest) (*Dail DataPackage: dataPackage, DataPackagePath: dataPackagePath, PreflightPath: preflightPath, + Metadata: metadata, + MetadataPath: metadataPath, + PriorSnapshot: priorSnapshot, RenderResult: renderResult, }, nil } @@ -304,20 +351,6 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie }, summary) } -func defaultBriefingPath(cfg config.Config, resolved report.Resolved) string { - validDate := resolved.ValidPeriod.Start.Format("2006-01-02") - 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) +func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { + return state.NewFilesystemStore(cfg.Workspace) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 7bb114a..34c3d9c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -14,6 +14,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/state" ) func TestFetchAndSaveBundle(t *testing.T) { @@ -127,7 +128,7 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) { if err != nil { 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) } } @@ -171,14 +172,17 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) { if renderer.request.PromptID != "weather.daily_report" { t.Fatalf("PromptID = %q, want weather.daily_report", renderer.request.PromptID) } - if renderer.request.DataPackagePath != dataPackagePath { - t.Fatalf("DataPackagePath = %q, want %q", renderer.request.DataPackagePath, dataPackagePath) + if renderer.request.DataPackagePath != result.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 { 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) if err != nil { t.Fatalf("read data package: %v", err) @@ -193,6 +197,12 @@ func TestPrepareDailyReportWritesDataPackageAndPreflight(t *testing.T) { if !strings.Contains(string(preflight), `prepared`) { 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) { @@ -226,7 +236,15 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { if err == nil { 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) if readErr != nil { t.Fatalf("read failed preflight: %v", readErr) @@ -234,6 +252,9 @@ func TestPrepareDailyReportPersistsFailedPreflight(t *testing.T) { if !strings.Contains(string(preflight), `"exitCode": 1`) { 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) { diff --git a/internal/cli/root.go b/internal/cli/root.go index ea3503f..3ec2ba8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ Options: --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --units VALUE Override weather API units. --tz NAME Override weather API timezone. - --out PATH Override the generated data package path for generate daily. + --out PATH Write an extra data package copy for generate daily. ` type Runner struct { @@ -210,7 +210,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") if includeOutput { - fs.StringVar(&opts.Output, "out", "", "generated data package path") + fs.StringVar(&opts.Output, "out", "", "extra data package copy path") } } diff --git a/internal/report/definition.go b/internal/report/definition.go index 55690b2..fa4623f 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -77,7 +77,7 @@ type Metadata struct { func (r Resolved) Metadata() 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, PromptID: r.Definition.PromptID, GeneratedAt: r.GeneratedAt, diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go new file mode 100644 index 0000000..a87b459 --- /dev/null +++ b/internal/state/filesystem.go @@ -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") +} diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go new file mode 100644 index 0000000..aab0f86 --- /dev/null +++ b/internal/state/filesystem_test.go @@ -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") +} diff --git a/internal/state/metadata.go b/internal/state/metadata.go new file mode 100644 index 0000000..d35109a --- /dev/null +++ b/internal/state/metadata.go @@ -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, + } +} diff --git a/internal/state/store.go b/internal/state/store.go new file mode 100644 index 0000000..50eb73b --- /dev/null +++ b/internal/state/store.go @@ -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 +}