Add filesystem state metadata baseline
This commit is contained in:
@@ -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)
|
||||
|
||||
26
docs/cli.md
26
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=<path> --format json`.
|
||||
Daily briefing, writes workspace artifacts, and invokes
|
||||
`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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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 (
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
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