From ece31567b84ef0de6c255c2de3d0f17047fbf01e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 1 Aug 2026 19:58:11 +0000 Subject: [PATCH] Remove historical inspection commands --- internal/app/inspect.go | 126 ------ internal/cli/root.go | 116 ----- internal/cli/run_test.go | 17 + internal/report/daily_report.go | 2 - internal/report/definition.go | 18 - internal/report/hourly_report.go | 6 +- internal/report/period_test.go | 11 +- internal/report/today_report.go | 8 +- internal/report/tomorrow_report.go | 8 +- internal/state/filesystem.go | 266 ----------- internal/state/filesystem_test.go | 416 ------------------ internal/state/metadata.go | 121 ++--- internal/state/metadata_reached_paths_test.go | 8 + internal/state/store.go | 11 - 14 files changed, 76 insertions(+), 1058 deletions(-) delete mode 100644 internal/app/inspect.go delete mode 100644 internal/state/filesystem_test.go diff --git a/internal/app/inspect.go b/internal/app/inspect.go deleted file mode 100644 index cd25f5d..0000000 --- a/internal/app/inspect.go +++ /dev/null @@ -1,126 +0,0 @@ -package app - -import ( - "context" - "fmt" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "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" - "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" -) - -type InspectReportsRequest struct { - Config config.Config - Limit int -} - -type InspectRunRequest struct { - Config config.Config - RunID string -} - -type SourceInspection struct { - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []briefing.SourceMetadata `json:"sources,omitempty"` - Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"` -} - -func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) { - store, err := state.NewFilesystemStore(req.Config.Workspace) - if err != nil { - return nil, err - } - return store.ListReports(ctx, req.Limit) -} - -func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) { - inspection, err := inspectRun(ctx, req) - return inspection.metadata, err -} - -func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) { - inspection, err := inspectRun(ctx, req) - if err != nil { - return module.Snapshot{}, err - } - return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath) -} - -func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) { - inspection, err := inspectRun(ctx, req) - if err != nil { - return promptinput.Package{}, err - } - return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath) -} - -func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) { - inspection, err := inspectRun(ctx, req) - if err != nil { - return nil, err - } - resolved, err := resolvedFromMetadata(inspection.metadata) - if err != nil { - return nil, err - } - return inspection.store.FindPriorSnapshot(ctx, resolved) -} - -func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) { - inspection, err := inspectRun(ctx, req) - if err != nil { - return SourceInspection{}, err - } - metadata := inspection.metadata - return SourceInspection{ - RunID: metadata.RunID, - ReportID: metadata.ReportID, - SourceLocation: metadata.SourceLocation, - Sources: metadata.Sources, - Warnings: metadata.SourceWarnings, - }, nil -} - -type runInspection struct { - store *state.FilesystemStore - metadata state.Metadata -} - -func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) { - store, err := state.NewFilesystemStore(req.Config.Workspace) - if err != nil { - return runInspection{}, err - } - metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) - if err != nil { - return runInspection{}, err - } - return runInspection{store: store, metadata: metadata}, nil -} - -func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) { - definition, err := report.DefaultRegistry().Lookup(metadata.ReportID) - if err != nil { - return report.Resolved{}, err - } - location, err := timeutil.LoadLocation(metadata.Timezone) - if err != nil { - return report.Resolved{}, err - } - if !metadata.ValidPeriod.IsValid() { - return report.Resolved{}, fmt.Errorf("metadata valid period for run id %q is invalid", metadata.RunID) - } - return report.Resolved{ - Definition: definition, - GeneratedAt: metadata.GeneratedAt, - Timezone: location.String(), - ValidPeriod: metadata.ValidPeriod, - }, nil -} diff --git a/internal/cli/root.go b/internal/cli/root.go index e631021..f2dac87 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -26,12 +26,6 @@ Usage: weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] - weatherreporter inspect reports [--config PATH] [--limit N] - weatherreporter inspect metadata [--config PATH] RUN_ID - weatherreporter inspect modules [--config PATH] RUN_ID - weatherreporter inspect data-package [--config PATH] RUN_ID - weatherreporter inspect prior [--config PATH] RUN_ID - weatherreporter inspect sources [--config PATH] RUN_ID Options: -h, --help Show this help message. @@ -108,8 +102,6 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr } } return err - case "inspect": - return r.runInspect(ctx, args[1:], stdout) default: return fmt.Errorf("unknown command %q", args[0]) } @@ -130,81 +122,6 @@ type generateOptions struct { Date string } -type inspectOptions struct { - ConfigPath string - Limit int - RunID string -} - -type inspectRunCommand struct { - Name string - Inspect func(context.Context, app.InspectRunRequest) (any, error) -} - -var inspectRunCommands = []inspectRunCommand{ - {Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { - return app.InspectMetadata(ctx, req) - }}, - {Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { - return app.InspectModules(ctx, req) - }}, - {Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { - return app.InspectDataPackage(ctx, req) - }}, - {Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { - return app.InspectPriorSnapshot(ctx, req) - }}, - {Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) { - return app.InspectSources(ctx, req) - }}, -} - -func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error { - if len(args) == 0 { - return fmt.Errorf("inspect requires a command") - } - command := args[0] - switch command { - case "reports": - opts, err := parseInspectReportsFlags(args[1:]) - if err != nil { - return err - } - cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) - if err != nil { - return err - } - records, err := app.InspectReports(ctx, app.InspectReportsRequest{Config: cfg, Limit: opts.Limit}) - if err != nil { - return err - } - return writeJSON(stdout, records) - default: - for _, candidate := range inspectRunCommands { - if candidate.Name == command { - return runInspectRunCommand(ctx, stdout, candidate, args[1:]) - } - } - return fmt.Errorf("unknown inspect command %q", command) - } -} - -func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error { - opts, err := parseInspectRunFlags(command.Name, args) - if err != nil { - return err - } - cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) - if err != nil { - return err - } - value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) - if err != nil { - return err - } - return writeJSON(stdout, value) -} - func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { req, _, err := r.resolveGenerateAction(args) return req, err @@ -366,39 +283,6 @@ func parseRunFlags(args []string) (commonOptions, error) { return opts, nil } -func parseInspectReportsFlags(args []string) (inspectOptions, error) { - fs := flag.NewFlagSet("inspect reports", flag.ContinueOnError) - fs.SetOutput(io.Discard) - opts := inspectOptions{Limit: 20} - fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") - fs.IntVar(&opts.Limit, "limit", 20, "maximum reports to list") - if err := fs.Parse(args); err != nil { - return inspectOptions{}, err - } - if fs.NArg() > 0 { - return inspectOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0)) - } - if opts.Limit < 0 { - return inspectOptions{}, fmt.Errorf("limit must be zero or greater") - } - return opts, nil -} - -func parseInspectRunFlags(command string, args []string) (inspectOptions, error) { - fs := flag.NewFlagSet("inspect "+command, flag.ContinueOnError) - fs.SetOutput(io.Discard) - opts := inspectOptions{} - fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") - if err := fs.Parse(args); err != nil { - return inspectOptions{}, err - } - if fs.NArg() != 1 { - return inspectOptions{}, fmt.Errorf("inspect %s requires a run id", command) - } - opts.RunID = fs.Arg(0) - return opts, nil -} - func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.Units, "units", "", "weather API units") diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index b633e94..eeee1ed 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -1,8 +1,11 @@ package cli import ( + "bytes" + "context" "os" "path/filepath" + "strings" "testing" "time" @@ -40,3 +43,17 @@ func TestResolveRunActionConstructsOneExecutor(t *testing.T) { }) } } + +func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr) + if err == nil || err.Error() != `unknown command "inspect"` { + t.Fatalf("Run(inspect) error = %v", err) + } + if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil { + t.Fatalf("Run(--help) error = %v", err) + } + if strings.Contains(stdout.String(), "inspect") { + t.Fatalf("help contains removed inspect command:\n%s", stdout.String()) + } +} diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go index 20a1b6a..2e781fa 100644 --- a/internal/report/daily_report.go +++ b/internal/report/daily_report.go @@ -15,14 +15,12 @@ func dailyDefinition() Definition { PromptVersion: "2.0.0", TemplateID: "daily", GeneratedTextSchemaID: "daily", - ComparisonStrategy: CompareSameValidDate, ArtifactGroup: "daily", OutputName: "daily.md", DistributorPathTemplates: []string{ "daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", }, - CompatiblePriorIDs: []ID{Daily}, Modules: dailyModules(), resolve: resolveDaily, runIDDisambiguator: validStartDateRunIDDisambiguator, diff --git a/internal/report/definition.go b/internal/report/definition.go index 1a8b0f0..d579cca 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -19,13 +19,6 @@ const ( Hourly ID = "hourly" ) -type ComparisonStrategy string - -const ( - CompareSameValidDate ComparisonStrategy = "same_valid_date" - CompareRollingWindow ComparisonStrategy = "rolling_window" -) - type Batch string const ( @@ -40,11 +33,9 @@ type Definition struct { PromptVersion string TemplateID string GeneratedTextSchemaID string - ComparisonStrategy ComparisonStrategy ArtifactGroup string OutputName string DistributorPathTemplates []string - CompatiblePriorIDs []ID Modules []module.ConfigItem Morning bool Evening bool @@ -76,15 +67,6 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) { return d.resolve(req) } -func (d Definition) CompatibleWithPrior(id ID) bool { - for _, compatibleID := range d.CompatiblePriorIDs { - if id == compatibleID { - return true - } - } - return false -} - func (d Definition) ModuleIDs() []module.ID { ids := make([]module.ID, 0, len(d.Modules)) for _, item := range d.Modules { diff --git a/internal/report/hourly_report.go b/internal/report/hourly_report.go index 58728d6..17caf38 100644 --- a/internal/report/hourly_report.go +++ b/internal/report/hourly_report.go @@ -17,15 +17,13 @@ func hourlyDefinition() Definition { PromptVersion: "2.0.0", TemplateID: "hourly", GeneratedTextSchemaID: "hourly", - ComparisonStrategy: CompareRollingWindow, ArtifactGroup: "hourly", OutputName: "hourly.md", DistributorPathTemplates: []string{ "hourly/index.md", }, - CompatiblePriorIDs: []ID{Hourly}, - Modules: hourlyModules(), - resolve: resolveHourly, + Modules: hourlyModules(), + resolve: resolveHourly, } } diff --git a/internal/report/period_test.go b/internal/report/period_test.go index 3586b46..ac861bd 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -87,23 +87,22 @@ func TestCommandAndConfigurationNamesRejectRetiredReports(t *testing.T) { func TestRegistryDefinitionsPreserveRetainedContracts(t *testing.T) { tests := []struct { id ID - comparison ComparisonStrategy morning bool evening bool outputName string paths []string }{ - {Daily, CompareSameValidDate, false, false, "daily.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md"}}, - {Today, CompareSameValidDate, true, false, "today.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "today/index.md"}}, - {Tomorrow, CompareSameValidDate, false, true, "tomorrow.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "tomorrow/index.md"}}, - {Hourly, CompareRollingWindow, false, false, "hourly.md", []string{"hourly/index.md"}}, + {Daily, false, false, "daily.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md"}}, + {Today, true, false, "today.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "today/index.md"}}, + {Tomorrow, false, true, "tomorrow.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md", "tomorrow/index.md"}}, + {Hourly, false, false, "hourly.md", []string{"hourly/index.md"}}, } registry := DefaultRegistry() for _, tt := range tests { t.Run(string(tt.id), func(t *testing.T) { definition := registry.MustLookup(tt.id) - if definition.ComparisonStrategy != tt.comparison || definition.Morning != tt.morning || definition.Evening != tt.evening || definition.OutputName != tt.outputName { + if definition.Morning != tt.morning || definition.Evening != tt.evening || definition.OutputName != tt.outputName { t.Fatalf("definition = %#v, want retained report contract", definition) } if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") { diff --git a/internal/report/today_report.go b/internal/report/today_report.go index e266047..6e90703 100644 --- a/internal/report/today_report.go +++ b/internal/report/today_report.go @@ -13,7 +13,6 @@ func todayDefinition() Definition { PromptVersion: "2.0.0", TemplateID: "today", GeneratedTextSchemaID: "today", - ComparisonStrategy: CompareSameValidDate, ArtifactGroup: "today", OutputName: "today.md", DistributorPathTemplates: []string{ @@ -21,10 +20,9 @@ func todayDefinition() Definition { "daily/{valid_start_date}/index.md", "today/index.md", }, - CompatiblePriorIDs: []ID{Today}, - Modules: todayModules(), - Morning: true, - resolve: resolveToday, + Modules: todayModules(), + Morning: true, + resolve: resolveToday, } } diff --git a/internal/report/tomorrow_report.go b/internal/report/tomorrow_report.go index b635812..46ca6de 100644 --- a/internal/report/tomorrow_report.go +++ b/internal/report/tomorrow_report.go @@ -13,7 +13,6 @@ func tomorrowDefinition() Definition { PromptVersion: "2.0.0", TemplateID: "tomorrow", GeneratedTextSchemaID: "tomorrow", - ComparisonStrategy: CompareSameValidDate, ArtifactGroup: "tomorrow", OutputName: "tomorrow.md", DistributorPathTemplates: []string{ @@ -21,10 +20,9 @@ func tomorrowDefinition() Definition { "daily/{valid_start_date}/index.md", "tomorrow/index.md", }, - CompatiblePriorIDs: []ID{Tomorrow}, - Modules: tomorrowModules(), - Evening: true, - resolve: resolveTomorrow, + Modules: tomorrowModules(), + Evening: true, + resolve: resolveTomorrow, } } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index c47dadb..c9e290e 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -2,13 +2,10 @@ package state import ( "context" - "encoding/json" "fmt" "os" "path/filepath" - "sort" "strings" - "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" @@ -39,20 +36,6 @@ type ArtifactPaths struct { RenderContext string `json:"renderContext,omitempty"` } -type ReportRecord struct { - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt string `json:"generatedAt"` - ValidStart string `json:"validStart"` - ValidEnd string `json:"validEnd"` - MetadataPath string `json:"metadataPath"` - ReportPath string `json:"reportPath,omitempty"` - Warnings int `json:"warnings"` - metadata Metadata -} - func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) { if cfg.Root == "" { return nil, fmt.Errorf("workspace root is required") @@ -262,228 +245,6 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st return metadata.MetadataPath, nil } -func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) { - if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate { - return nil, nil - } - group := resolved.Definition.ArtifactGroup - if group == "" { - return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID) - } - dirs, err := s.metadataDirectories(resolved, group) - if err != nil { - return nil, err - } - - var candidates []Metadata - for _, dir := range dirs { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - continue - } - return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err) - } - for _, entry := range entries { - if entry.IsDir() || !isMetadataFilename(entry.Name()) { - 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 !resolved.Definition.CompatibleWithPrior(metadata.ReportID) { - continue - } - if !comparablePeriod(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], - ModuleSnapshotPath: candidates[0].ModuleSnapshotPath, - }, nil -} - -func (s *FilesystemStore) ListReports(_ context.Context, limit int) ([]ReportRecord, error) { - if s == nil { - return nil, fmt.Errorf("state store is required") - } - root := s.join(s.snapshotsDir) - if _, err := os.Stat(root); err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("inspect %q: %w", root, err) - } - var records []ReportRecord - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return fmt.Errorf("inspect %q: %w", path, err) - } - if entry.IsDir() || !isMetadataFilename(entry.Name()) { - return nil - } - record, err := s.reportRecord(path) - if err != nil { - return err - } - records = append(records, record) - return nil - }) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - sort.Slice(records, func(i, j int) bool { - return records[i].metadata.GeneratedAt.After(records[j].metadata.GeneratedAt) - }) - if limit > 0 && len(records) > limit { - records = records[:limit] - } - return records, nil -} - -func (s *FilesystemStore) LoadMetadataByRunID(ctx context.Context, runID string) (Metadata, string, error) { - if strings.TrimSpace(runID) == "" { - return Metadata{}, "", fmt.Errorf("run id is required") - } - records, err := s.ListReports(ctx, 0) - if err != nil { - return Metadata{}, "", err - } - for _, record := range records { - if record.RunID == runID { - return record.metadata, record.MetadataPath, nil - } - } - return Metadata{}, "", fmt.Errorf("metadata for run id %q was not found", runID) -} - -func (s *FilesystemStore) LoadDataPackage(_ context.Context, path string) (promptinput.Package, error) { - if path == "" { - return promptinput.Package{}, fmt.Errorf("data package path is required") - } - data, err := os.ReadFile(path) - if err != nil { - return promptinput.Package{}, fmt.Errorf("read %q: %w", path, err) - } - pkg, err := promptinput.LoadYAML(data) - if err != nil { - return promptinput.Package{}, err - } - return pkg, nil -} - -func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (module.Snapshot, error) { - if path == "" { - return module.Snapshot{}, fmt.Errorf("module snapshot path is required") - } - var snapshot module.Snapshot - if err := readJSON(path, &snapshot); err != nil { - return module.Snapshot{}, err - } - if err := snapshot.Validate(); err != nil { - return module.Snapshot{}, err - } - return snapshot, nil -} - -func (s *FilesystemStore) LoadGeneratedText(_ context.Context, path string) ([]byte, error) { - if path == "" { - return nil, fmt.Errorf("generated text path is required") - } - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %q: %w", path, err) - } - return data, nil -} - -func (s *FilesystemStore) LoadPromptPreparation(_ context.Context, path string) (PromptPreparationArtifact, error) { - if path == "" { - return PromptPreparationArtifact{}, fmt.Errorf("prompt preparation path is required") - } - var artifact PromptPreparationArtifact - if err := readJSON(path, &artifact); err != nil { - return PromptPreparationArtifact{}, err - } - if err := artifact.Validate(); err != nil { - return PromptPreparationArtifact{}, err - } - return artifact, nil -} - -func (s *FilesystemStore) LoadPromptExecution(_ context.Context, path string) (PromptExecutionArtifact, error) { - if path == "" { - return PromptExecutionArtifact{}, fmt.Errorf("prompt execution path is required") - } - var artifact PromptExecutionArtifact - if err := readJSON(path, &artifact); err != nil { - return PromptExecutionArtifact{}, err - } - if err := artifact.Validate(); err != nil { - return PromptExecutionArtifact{}, err - } - return artifact, nil -} - -func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error { - if path == "" { - return fmt.Errorf("render context path is required") - } - if target == nil { - return fmt.Errorf("render context target is required") - } - return readJSON(path, target) -} - -func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) { - var metadata Metadata - if err := readJSON(path, &metadata); err != nil { - return ReportRecord{}, err - } - metadata.MetadataPath = path - return ReportRecord{ - RunID: metadata.RunID, - ReportID: metadata.ReportID, - Variant: metadata.Variant, - PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt.Format(time.RFC3339Nano), - ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano), - ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano), - MetadataPath: path, - ReportPath: metadata.RenderedReportPath, - Warnings: len(metadata.SourceWarnings), - metadata: metadata, - }, nil -} - -func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group string) ([]string, error) { - paths, err := s.Paths(resolved) - if err != nil { - return nil, err - } - return []string{filepath.Dir(paths.Metadata)}, nil -} - func (s *FilesystemStore) join(parts ...string) string { all := append([]string{s.root}, parts...) return filepath.Join(all...) @@ -553,30 +314,3 @@ func validatePathSegment(name string, value string) error { } return nil } - -func isMetadataFilename(name string) bool { - if !strings.HasPrefix(name, "metadata.") || !strings.HasSuffix(name, ".json") { - return false - } - runID := strings.TrimSuffix(strings.TrimPrefix(name, "metadata."), ".json") - return strings.TrimSpace(runID) != "" && !strings.ContainsAny(runID, `/\`) && runID != "." && runID != ".." -} - -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 sameValidDate(metadata Metadata, resolved report.Resolved) bool { - return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02") -} - -func comparablePeriod(metadata Metadata, resolved report.Resolved) bool { - return resolved.Definition.ComparisonStrategy == report.CompareSameValidDate && sameValidDate(metadata, resolved) -} diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go deleted file mode 100644 index 44b4480..0000000 --- a/internal/state/filesystem_test.go +++ /dev/null @@ -1,416 +0,0 @@ -package state - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" -) - -func TestFilesystemPathsUseExactPromptArtifactLayout(t *testing.T) { - store := newFilesystemTestStore(t) - tests := []struct { - id report.ID - now string - date string - group string - validDate string - runID string - }{ - {report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T12:00:00-05:00", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29"}, - {report.Today, "2026-05-29T05:00:00-05:00", "", "today", "2026-05-29", "20260529T100000.000000000Z_today"}, - {report.Tomorrow, "2026-05-29T18:00:00-05:00", "", "tomorrow", "2026-05-30", "20260529T230000.000000000Z_tomorrow"}, - {report.Hourly, "2026-05-29T05:00:00-05:00", "", "hourly", "2026-05-29", "20260529T100000.000000000Z_hourly"}, - } - for _, test := range tests { - t.Run(string(test.id), func(t *testing.T) { - resolved := resolveStateReport(t, test.id, test.now, test.date) - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - want := ArtifactPaths{ - ModuleSnapshot: filepath.Join(store.root, "snapshots", test.group, test.validDate, "modules."+test.runID+".json"), - Metadata: filepath.Join(store.root, "snapshots", test.group, test.validDate, "metadata."+test.runID+".json"), - DataPackage: filepath.Join(store.root, "data-packages", test.group, test.validDate, "data_package."+test.runID+".yaml"), - Preparation: filepath.Join(store.root, "preflight", test.group, test.validDate, "prompt_preparation."+test.runID+".json"), - Execution: filepath.Join(store.root, "snapshots", test.group, test.validDate, "prompt_execution."+test.runID+".json"), - Notification: filepath.Join(store.root, "notifications", test.group, test.validDate, "distributor."+test.runID+".json"), - RenderedReport: filepath.Join(store.root, "reports", test.group, test.validDate, "report."+test.runID+".md"), - GeneratedTextRaw: filepath.Join(store.root, "snapshots", test.group, test.validDate, "generated_text_raw."+test.runID+".json"), - GeneratedText: filepath.Join(store.root, "snapshots", test.group, test.validDate, "generated_text."+test.runID+".json"), - RenderContext: filepath.Join(store.root, "snapshots", test.group, test.validDate, "render_context."+test.runID+".json"), - } - if paths != want { - t.Fatalf("Paths() = %#v, want %#v", paths, want) - } - }) - } -} - -func TestPromptArtifactsAndMetadataRoundTrip(t *testing.T) { - store := newFilesystemTestStore(t) - resolved := resolveStateReport(t, report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T12:00:00-05:00") - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - preparation := preparationArtifactFor(resolved, paths) - preparationPath, err := store.SavePromptPreparation(context.Background(), resolved, preparation) - if err != nil { - t.Fatalf("SavePromptPreparation() error = %v", err) - } - execution := executionArtifactFor(resolved, paths) - executionPath, err := store.SavePromptExecution(context.Background(), resolved, execution) - if err != nil { - t.Fatalf("SavePromptExecution() error = %v", err) - } - loadedPreparation, err := store.LoadPromptPreparation(context.Background(), preparationPath) - if err != nil || loadedPreparation.Preparation == nil || loadedPreparation.Preparation.PromptHash != "prompt-hash" { - t.Fatalf("LoadPromptPreparation() = %#v, %v", loadedPreparation, err) - } - loadedExecution, err := store.LoadPromptExecution(context.Background(), executionPath) - if err != nil || loadedExecution.Provenance == nil || loadedExecution.Provenance.RunID != "provider-run" || loadedExecution.Validation == nil { - t.Fatalf("LoadPromptExecution() = %#v, %v", loadedExecution, err) - } - - metadata := promptMetadataFor(resolved, paths) - metadata.PreparationPath = preparationPath - metadata.ExecutionPath = executionPath - metadata.GeneratedTextRawPath = paths.GeneratedTextRaw - metadataPath, err := store.SaveMetadata(context.Background(), metadata) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - data, err := os.ReadFile(metadataPath) - if err != nil { - t.Fatalf("read metadata: %v", err) - } - text := string(data) - if strings.Contains(text, "preflightPath") || strings.Contains(text, "generatedTextResultPath") || strings.Contains(text, "metadataPath") { - t.Fatalf("v2 metadata contains legacy or runtime aliases: %s", text) - } - loadedMetadata, loadedPath, err := store.LoadMetadataByRunID(context.Background(), metadata.RunID) - if err != nil || loadedPath != metadataPath || loadedMetadata.PreparationPath != preparationPath || loadedMetadata.ExecutionPath != executionPath { - t.Fatalf("LoadMetadataByRunID() = %#v, %q, %v", loadedMetadata, loadedPath, err) - } -} - -func TestMetadataLegacyCompatibilityAndV2OnlyWrites(t *testing.T) { - legacy := Metadata{ - SchemaVersion: MetadataSchemaVersionV1, RunID: "legacy-run", ReportID: report.ID("three_day"), - PromptID: "weather.three_day", ModuleSnapshotPath: "/archive/modules.json", DataPackagePath: "/archive/data.yaml", - PreflightPath: "/archive/render.json", GeneratedTextResultPath: "/archive/result.json", - } - data, err := json.Marshal(legacy) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - text := string(data) - if !strings.Contains(text, "preflightPath") || !strings.Contains(text, "generatedTextResultPath") || strings.Contains(text, "preparationPath") || strings.Contains(text, "executionPath") { - t.Fatalf("v1 metadata wire fields = %s", text) - } - var decoded Metadata - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if decoded.PreparationPath != legacy.PreflightPath || decoded.ExecutionPath != legacy.GeneratedTextResultPath { - t.Fatalf("normalized compatibility aliases = %#v", decoded) - } - remarshaled, err := json.Marshal(decoded) - if err != nil || !strings.Contains(string(remarshaled), "preflightPath") || strings.Contains(string(remarshaled), "preparationPath") { - t.Fatalf("remarshaled v1 metadata = %s, %v", remarshaled, err) - } - if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v99"}`), &decoded); err == nil { - t.Fatal("Unmarshal() error = nil, want unknown schema rejection") - } - - store := newFilesystemTestStore(t) - legacy.MetadataPath = filepath.Join(store.root, "snapshots", "legacy", "metadata.legacy-run.json") - if _, err := store.SaveMetadata(context.Background(), legacy); err == nil { - t.Fatal("SaveMetadata(v1) error = nil, want v2-only write rejection") - } -} - -func TestReportDiscoveryAndArtifactInspection(t *testing.T) { - store := newFilesystemTestStore(t) - older := resolveStateReport(t, report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T12:00:00-05:00") - newer := resolveStateReport(t, report.Today, "2026-05-29T08:00:00-05:00", "") - olderPaths := saveStateMetadata(t, store, older) - newerPaths := saveStateMetadata(t, store, newer) - - snapshot, err := module.NewSnapshot([]module.Output{{ID: module.Metadata, StanzaName: "metadata", Value: map[string]any{"run_id": older.Metadata().RunID}}}) - if err != nil { - t.Fatalf("NewSnapshot() error = %v", err) - } - modulePath, err := store.SaveModuleSnapshot(context.Background(), older, snapshot) - if err != nil { - t.Fatalf("SaveModuleSnapshot() error = %v", err) - } - pkg, err := promptinput.Build(promptinput.BuildRequest{ - Metadata: promptinput.Metadata{ - RunID: older.Metadata().RunID, ReportID: older.Definition.ID, PromptID: older.Definition.PromptID, - GeneratedAt: older.GeneratedAt, Timezone: older.Timezone, ValidPeriod: older.ValidPeriod, - }, - Modules: snapshot, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - dataPath, err := store.SaveDataPackage(context.Background(), older, pkg) - if err != nil { - t.Fatalf("SaveDataPackage() error = %v", err) - } - loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), modulePath) - if err != nil || len(loadedSnapshot.Outputs) != 1 { - t.Fatalf("LoadModuleSnapshot() = %#v, %v", loadedSnapshot, err) - } - loadedPackage, err := store.LoadDataPackage(context.Background(), dataPath) - if err != nil || loadedPackage.RunID != older.Metadata().RunID { - t.Fatalf("LoadDataPackage() = %#v, %v", loadedPackage, err) - } - - records, err := store.ListReports(context.Background(), 0) - if err != nil || len(records) != 2 { - t.Fatalf("ListReports() = %#v, %v", records, err) - } - if records[0].RunID != newer.Metadata().RunID || records[0].MetadataPath != newerPaths.Metadata || records[1].MetadataPath != olderPaths.Metadata { - t.Fatalf("ordered report records = %#v", records) - } - loadedMetadata, loadedPath, err := store.LoadMetadataByRunID(context.Background(), older.Metadata().RunID) - if err != nil || loadedPath != olderPaths.Metadata || len(loadedMetadata.Sources) != 1 || loadedMetadata.Sources[0].Name != "weather-api" { - t.Fatalf("LoadMetadataByRunID() = %#v, %q, %v", loadedMetadata, loadedPath, err) - } -} - -func TestListReportsRetainsHistoricalV1ReportIDs(t *testing.T) { - store := newFilesystemTestStore(t) - for i, id := range []report.ID{"three_day", "weekend", "storm"} { - generatedAt := time.Date(2026, 5, 20+i, 12, 0, 0, 0, time.UTC) - metadata := Metadata{ - SchemaVersion: MetadataSchemaVersionV1, RunID: "historical-" + string(id), ReportID: id, - PromptID: "weather." + string(id), GeneratedAt: generatedAt, Timezone: "UTC", - ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)}, - ModuleSnapshotPath: "/archive/modules.json", DataPackagePath: "/archive/data.yaml", - PreflightPath: "/archive/render.json", GeneratedTextResultPath: "/archive/result.json", - } - path := filepath.Join(store.root, "snapshots", string(id), "2026-05-20", "metadata."+metadata.RunID+".json") - writeJSONFixture(t, path, metadata) - } - records, err := store.ListReports(context.Background(), 0) - if err != nil || len(records) != 3 { - t.Fatalf("ListReports() = %#v, %v", records, err) - } - for _, record := range records { - if record.ReportID != "three_day" && record.ReportID != "weekend" && record.ReportID != "storm" { - t.Fatalf("unexpected historical report record: %#v", record) - } - metadata, path, err := store.LoadMetadataByRunID(context.Background(), record.RunID) - if err != nil || path != record.MetadataPath || metadata.PreparationPath != "/archive/render.json" || metadata.ExecutionPath != "/archive/result.json" { - t.Fatalf("LoadMetadataByRunID(%q) = %#v, %q, %v", record.RunID, metadata, path, err) - } - } -} - -func TestFilesystemWritesAreAtomicAndRejectUnsafePaths(t *testing.T) { - store := newFilesystemTestStore(t) - resolved := resolveStateReport(t, report.Hourly, "2026-05-29T05:00:00-05:00", "") - path, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"value":"first"}`)) - if err != nil { - t.Fatalf("first SaveGeneratedTextRaw() error = %v", err) - } - if _, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"value":"second"}`)); err != nil { - t.Fatalf("second SaveGeneratedTextRaw() error = %v", err) - } - data, err := os.ReadFile(path) - if err != nil || string(data) != `{"value":"second"}` { - t.Fatalf("atomic replacement = %q, %v", data, err) - } - entries, err := os.ReadDir(filepath.Dir(path)) - if err != nil || len(entries) != 1 || entries[0].Name() != filepath.Base(path) { - t.Fatalf("artifact directory after atomic write = %#v, %v", entries, err) - } - - metadata := promptMetadataFor(resolved, mustStatePaths(t, store, resolved)) - metadata.PreparationPath = "/saved/preparation.json" - metadata.MetadataPath = filepath.Join(t.TempDir(), "outside.json") - if _, err := store.SaveMetadata(context.Background(), metadata); err == nil { - t.Fatal("SaveMetadata(outside workspace) error = nil") - } - - cfg := config.Defaults().Workspace - cfg.Root = t.TempDir() - cfg.SnapshotsDir = "../snapshots" - if _, err := NewFilesystemStore(cfg); err == nil { - t.Fatal("NewFilesystemStore(unsafe directory) error = nil") - } - unsafeResolved := resolved - unsafeResolved.Definition.ID = report.ID("hourly/bad") - if _, err := store.Paths(unsafeResolved); err == nil { - t.Fatal("Paths(unsafe run id) error = nil") - } -} - -func TestFindPriorSnapshotForSupportedReports(t *testing.T) { - tests := []struct { - id report.ID - firstNow string - secondNow string - date string - wantPrior bool - }{ - {report.Daily, "2026-05-29T05:00:00-05:00", "2026-05-29T08:00:00-05:00", "2026-05-29T12:00:00-05:00", true}, - {report.Today, "2026-05-29T05:00:00-05:00", "2026-05-29T08:00:00-05:00", "", true}, - {report.Tomorrow, "2026-05-29T17:00:00-05:00", "2026-05-29T18:00:00-05:00", "", true}, - {report.Hourly, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "", false}, - } - for _, test := range tests { - t.Run(string(test.id), func(t *testing.T) { - store := newFilesystemTestStore(t) - first := resolveStateReport(t, test.id, test.firstNow, test.date) - second := resolveStateReport(t, test.id, test.secondNow, test.date) - paths := saveStateMetadata(t, store, first) - prior, err := store.FindPriorSnapshot(context.Background(), second) - if err != nil { - t.Fatalf("FindPriorSnapshot() error = %v", err) - } - if !test.wantPrior && prior != nil { - t.Fatalf("FindPriorSnapshot() = %#v, want nil", prior) - } - if test.wantPrior && (prior == nil || prior.Metadata.RunID != first.Metadata().RunID || prior.ModuleSnapshotPath != paths.ModuleSnapshot) { - t.Fatalf("FindPriorSnapshot() = %#v, want run %q", prior, first.Metadata().RunID) - } - }) - } -} - -func newFilesystemTestStore(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 resolveStateReport(t *testing.T, id report.ID, nowValue, dateValue string) report.Resolved { - t.Helper() - location, err := time.LoadLocation("America/Chicago") - if err != nil { - t.Fatalf("LoadLocation() error = %v", err) - } - now, err := time.Parse(time.RFC3339, nowValue) - if err != nil { - t.Fatalf("parse now: %v", err) - } - req := report.ResolveRequest{Now: now, Location: location} - if dateValue != "" { - req.Date, err = time.Parse(time.RFC3339, dateValue) - if err != nil { - t.Fatalf("parse date: %v", err) - } - } - resolved, err := report.DefaultRegistry().Resolve(id, req) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - return resolved -} - -func mustStatePaths(t *testing.T, store *FilesystemStore, resolved report.Resolved) ArtifactPaths { - t.Helper() - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - return paths -} - -func promptMetadataFor(resolved report.Resolved, paths ArtifactPaths) Metadata { - metadata := BuildPromptMetadataFromBriefingMetadata(resolved, briefing.Metadata{ - Sources: []briefing.SourceMetadata{{Name: "weather-api", FetchedAt: resolved.GeneratedAt}}, - SourceWarnings: []weatherdata.SourceWarning{}, - }, ArtifactPaths{ModuleSnapshot: paths.ModuleSnapshot, Metadata: paths.Metadata, DataPackage: paths.DataPackage}) - return metadata -} - -func saveStateMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved) ArtifactPaths { - t.Helper() - paths := mustStatePaths(t, store, resolved) - metadata := promptMetadataFor(resolved, paths) - metadata.PreparationPath = paths.Preparation - if _, err := store.SaveMetadata(context.Background(), metadata); err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - return paths -} - -func preparationArtifactFor(resolved report.Resolved, paths ArtifactPaths) PromptPreparationArtifact { - artifact := validPreparationArtifact() - metadata := resolved.Metadata() - artifact.ReportID, artifact.RunID = metadata.ReportID, metadata.RunID - artifact.PromptID, artifact.PromptVersion = resolved.Definition.PromptID, resolved.Definition.PromptVersion - artifact.DataPackagePath = paths.DataPackage - artifact.Preparation.PromptID, artifact.Preparation.PromptVersion = artifact.PromptID, artifact.PromptVersion - artifact.Preparation.PromptHash = "prompt-hash" - return artifact -} - -func validPreparationArtifact() PromptPreparationArtifact { - started := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) - return PromptPreparationArtifact{ - SchemaVersion: PromptPreparationSchemaVersion, Status: PromptPreparationSucceeded, - ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", - DataPackagePath: "/workspace/data.yaml", Preparation: &promptexec.Preparation{PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0"}, - StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second, - } -} - -func executionArtifactFor(resolved report.Resolved, paths ArtifactPaths) PromptExecutionArtifact { - artifact := validExecutionArtifact() - metadata := resolved.Metadata() - artifact.ReportID, artifact.RunID = metadata.ReportID, metadata.RunID - artifact.PromptID, artifact.PromptVersion = resolved.Definition.PromptID, resolved.Definition.PromptVersion - artifact.Provenance.PromptID, artifact.Provenance.PromptVersion = artifact.PromptID, artifact.PromptVersion - artifact.Paths.RawOutputPath = paths.GeneratedTextRaw - return artifact -} - -func validExecutionArtifact() PromptExecutionArtifact { - started := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) - validation := promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "daily.generated_text.schema.json", nil) - return PromptExecutionArtifact{ - SchemaVersion: PromptExecutionSchemaVersion, Status: PromptExecutionSucceeded, - ReportID: report.Daily, RunID: "weatherreporter-run", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", - Provenance: &PromptExecutionProvenance{RunID: "provider-run", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0", PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: "profile", BackendID: "backend", ModelName: "model", StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second}, - Validation: &validation, StartedAt: started, EndedAt: started.Add(time.Second), Duration: time.Second, - } -} - -func writeJSONFixture(t *testing.T, path string, value any) { - t.Helper() - data, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal fixture: %v", err) - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("create fixture directory: %v", err) - } - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatalf("write fixture: %v", err) - } -} diff --git a/internal/state/metadata.go b/internal/state/metadata.go index 9828d2f..2a7bc22 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -13,13 +13,10 @@ import ( ) const ( - MetadataSchemaVersionV1 = "weatherreporter.metadata.v1" - MetadataSchemaVersion = "weatherreporter.metadata.v2" + MetadataSchemaVersion = "weatherreporter.metadata.v2" ) -// Metadata is the durable record used for report discovery and inspection. -// V1 fields remain internal compatibility values and are emitted only for V1 -// records; V2 records have no legacy aliases in their JSON representation. +// Metadata is the durable record used by the transitional state package. type Metadata struct { SchemaVersion string `json:"schemaVersion"` RunID string `json:"runId"` @@ -45,39 +42,32 @@ type Metadata struct { GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` GeneratedTextPath string `json:"generatedTextPath,omitempty"` RenderContextPath string `json:"renderContextPath,omitempty"` - - // These paths are retained only to read legacy V1 records. They are never - // emitted in V2 metadata. - PreflightPath string `json:"-"` - GeneratedTextResultPath string `json:"-"` } type metadataJSON 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"` - Location *briefing.LocationContext `json:"location,omitempty"` - SourceLocationID string `json:"sourceLocationId,omitempty"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []briefing.SourceMetadata `json:"sources,omitempty"` - SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` - ModuleSnapshotPath string `json:"moduleSnapshotPath"` - DataPackagePath string `json:"dataPackagePath"` - PreparationPath string `json:"preparationPath,omitempty"` - ExecutionPath string `json:"executionPath,omitempty"` - PreflightPath string `json:"preflightPath,omitempty"` - NotificationPath string `json:"notificationPath,omitempty"` - RenderedReportPath string `json:"renderedReportPath,omitempty"` - GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` - GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` - GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"` - GeneratedTextPath string `json:"generatedTextPath,omitempty"` - RenderContextPath string `json:"renderContextPath,omitempty"` + 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"` + Location *briefing.LocationContext `json:"location,omitempty"` + SourceLocationID string `json:"sourceLocationId,omitempty"` + SourceLocation string `json:"sourceLocation,omitempty"` + Sources []briefing.SourceMetadata `json:"sources,omitempty"` + SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` + ModuleSnapshotPath string `json:"moduleSnapshotPath"` + DataPackagePath string `json:"dataPackagePath"` + PreparationPath string `json:"preparationPath,omitempty"` + ExecutionPath string `json:"executionPath,omitempty"` + NotificationPath string `json:"notificationPath,omitempty"` + RenderedReportPath string `json:"renderedReportPath,omitempty"` + GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` + GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` + GeneratedTextPath string `json:"generatedTextPath,omitempty"` + RenderContextPath string `json:"renderContextPath,omitempty"` } func (m Metadata) MarshalJSON() ([]byte, error) { @@ -92,53 +82,24 @@ func (m Metadata) MarshalJSON() ([]byte, error) { GeneratedTextSchemaID: m.GeneratedTextSchemaID, GeneratedTextRawPath: m.GeneratedTextRawPath, GeneratedTextPath: m.GeneratedTextPath, RenderContextPath: m.RenderContextPath, } - switch m.SchemaVersion { - case MetadataSchemaVersionV1: - w.PreflightPath = m.PreflightPath - w.GeneratedTextResultPath = m.GeneratedTextResultPath - case MetadataSchemaVersion: - w.PreparationPath = m.PreparationPath - w.ExecutionPath = m.ExecutionPath - default: + if m.SchemaVersion != MetadataSchemaVersion { return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) } + w.PreparationPath = m.PreparationPath + w.ExecutionPath = m.ExecutionPath return json.Marshal(w) } func (m *Metadata) UnmarshalJSON(data []byte) error { - var header struct { - SchemaVersion string `json:"schemaVersion"` - } - if err := json.Unmarshal(data, &header); err != nil { + type metadataWire Metadata + var decoded metadataWire + if err := json.Unmarshal(data, &decoded); err != nil { return err } - if header.SchemaVersion != MetadataSchemaVersionV1 && header.SchemaVersion != MetadataSchemaVersion { - return fmt.Errorf("unsupported metadata schema version %q", header.SchemaVersion) - } - var w metadataJSON - if err := json.Unmarshal(data, &w); err != nil { - return err - } - *m = Metadata{ - SchemaVersion: w.SchemaVersion, RunID: w.RunID, ReportID: w.ReportID, - Variant: w.Variant, PromptID: w.PromptID, GeneratedAt: w.GeneratedAt, - Timezone: w.Timezone, ValidPeriod: w.ValidPeriod, Location: w.Location, - SourceLocationID: w.SourceLocationID, SourceLocation: w.SourceLocation, - Sources: w.Sources, SourceWarnings: w.SourceWarnings, - ModuleSnapshotPath: w.ModuleSnapshotPath, DataPackagePath: w.DataPackagePath, - NotificationPath: w.NotificationPath, RenderedReportPath: w.RenderedReportPath, - GeneratedTextSchemaID: w.GeneratedTextSchemaID, GeneratedTextRawPath: w.GeneratedTextRawPath, - GeneratedTextPath: w.GeneratedTextPath, RenderContextPath: w.RenderContextPath, - } - if w.SchemaVersion == MetadataSchemaVersionV1 { - m.PreflightPath = w.PreflightPath - m.GeneratedTextResultPath = w.GeneratedTextResultPath - m.PreparationPath = w.PreflightPath - m.ExecutionPath = w.GeneratedTextResultPath - } else { - m.PreparationPath = w.PreparationPath - m.ExecutionPath = w.ExecutionPath + if decoded.SchemaVersion != MetadataSchemaVersion { + return fmt.Errorf("unsupported metadata schema version %q", decoded.SchemaVersion) } + *m = Metadata(decoded) return nil } @@ -155,18 +116,12 @@ func (m Metadata) Validate() error { if strings.TrimSpace(m.MetadataPath) == "" { return fmt.Errorf("metadata path is required") } - switch m.SchemaVersion { - case MetadataSchemaVersionV1: - if strings.TrimSpace(m.PreflightPath) == "" { - return fmt.Errorf("metadata preflight path is required") - } - case MetadataSchemaVersion: - if strings.TrimSpace(m.PreparationPath) == "" { - return fmt.Errorf("metadata preparation path is required") - } - default: + if m.SchemaVersion != MetadataSchemaVersion { return fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) } + if strings.TrimSpace(m.PreparationPath) == "" { + return fmt.Errorf("metadata preparation path is required") + } return nil } diff --git a/internal/state/metadata_reached_paths_test.go b/internal/state/metadata_reached_paths_test.go index 1c35e68..b859219 100644 --- a/internal/state/metadata_reached_paths_test.go +++ b/internal/state/metadata_reached_paths_test.go @@ -1,6 +1,7 @@ package state import ( + "encoding/json" "testing" "time" @@ -36,3 +37,10 @@ func TestBuildPromptMetadataIncludesOnlyExistingArtifacts(t *testing.T) { t.Fatalf("metadata includes paths for unreached artifacts: %#v", metadata) } } + +func TestMetadataRejectsRetiredSchema(t *testing.T) { + var metadata Metadata + if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v1"}`), &metadata); err == nil { + t.Fatal("Unmarshal() error = nil, want retired schema rejection") + } +} diff --git a/internal/state/store.go b/internal/state/store.go index 415e65b..f94ebdc 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -25,17 +25,6 @@ type Store interface { SaveRenderContext(context.Context, report.Resolved, any) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error) SaveMetadata(context.Context, Metadata) (string, error) - FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) - LoadModuleSnapshot(context.Context, string) (module.Snapshot, error) - LoadGeneratedText(context.Context, string) ([]byte, error) - LoadPromptPreparation(context.Context, string) (PromptPreparationArtifact, error) - LoadPromptExecution(context.Context, string) (PromptExecutionArtifact, error) - LoadRenderContext(context.Context, string, any) error -} - -type PriorSnapshot struct { - Metadata Metadata - ModuleSnapshotPath string } const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1"