Add filesystem state metadata baseline

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

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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")
}
}

View File

@@ -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,

View File

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

View File

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

View File

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

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

@@ -0,0 +1,25 @@
// Package state persists report artifacts and metadata.
package state
import (
"context"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type Store interface {
Paths(report.Resolved) (ArtifactPaths, error)
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
SaveMetadata(context.Context, Metadata) (string, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
}
type PriorSnapshot struct {
Metadata Metadata
BriefingPath string
}