Remove historical inspection commands
This commit is contained in:
@@ -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
|
|
||||||
}
|
|
||||||
@@ -26,12 +26,6 @@ Usage:
|
|||||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
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 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 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:
|
Options:
|
||||||
-h, --help Show this help message.
|
-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
|
return err
|
||||||
case "inspect":
|
|
||||||
return r.runInspect(ctx, args[1:], stdout)
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown command %q", args[0])
|
return fmt.Errorf("unknown command %q", args[0])
|
||||||
}
|
}
|
||||||
@@ -130,81 +122,6 @@ type generateOptions struct {
|
|||||||
Date string
|
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) {
|
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||||
req, _, err := r.resolveGenerateAction(args)
|
req, _, err := r.resolveGenerateAction(args)
|
||||||
return req, err
|
return req, err
|
||||||
@@ -366,39 +283,6 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
|||||||
return opts, nil
|
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) {
|
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
||||||
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
||||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ func dailyDefinition() Definition {
|
|||||||
PromptVersion: "2.0.0",
|
PromptVersion: "2.0.0",
|
||||||
TemplateID: "daily",
|
TemplateID: "daily",
|
||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
OutputName: "daily.md",
|
OutputName: "daily.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
"daily/{valid_start_date}/{run_id}.md",
|
"daily/{valid_start_date}/{run_id}.md",
|
||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
},
|
},
|
||||||
CompatiblePriorIDs: []ID{Daily},
|
|
||||||
Modules: dailyModules(),
|
Modules: dailyModules(),
|
||||||
resolve: resolveDaily,
|
resolve: resolveDaily,
|
||||||
runIDDisambiguator: validStartDateRunIDDisambiguator,
|
runIDDisambiguator: validStartDateRunIDDisambiguator,
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ const (
|
|||||||
Hourly ID = "hourly"
|
Hourly ID = "hourly"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComparisonStrategy string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
|
||||||
CompareRollingWindow ComparisonStrategy = "rolling_window"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Batch string
|
type Batch string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -40,11 +33,9 @@ type Definition struct {
|
|||||||
PromptVersion string
|
PromptVersion string
|
||||||
TemplateID string
|
TemplateID string
|
||||||
GeneratedTextSchemaID string
|
GeneratedTextSchemaID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
|
||||||
ArtifactGroup string
|
ArtifactGroup string
|
||||||
OutputName string
|
OutputName string
|
||||||
DistributorPathTemplates []string
|
DistributorPathTemplates []string
|
||||||
CompatiblePriorIDs []ID
|
|
||||||
Modules []module.ConfigItem
|
Modules []module.ConfigItem
|
||||||
Morning bool
|
Morning bool
|
||||||
Evening bool
|
Evening bool
|
||||||
@@ -76,15 +67,6 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
|||||||
return d.resolve(req)
|
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 {
|
func (d Definition) ModuleIDs() []module.ID {
|
||||||
ids := make([]module.ID, 0, len(d.Modules))
|
ids := make([]module.ID, 0, len(d.Modules))
|
||||||
for _, item := range d.Modules {
|
for _, item := range d.Modules {
|
||||||
|
|||||||
@@ -17,15 +17,13 @@ func hourlyDefinition() Definition {
|
|||||||
PromptVersion: "2.0.0",
|
PromptVersion: "2.0.0",
|
||||||
TemplateID: "hourly",
|
TemplateID: "hourly",
|
||||||
GeneratedTextSchemaID: "hourly",
|
GeneratedTextSchemaID: "hourly",
|
||||||
ComparisonStrategy: CompareRollingWindow,
|
|
||||||
ArtifactGroup: "hourly",
|
ArtifactGroup: "hourly",
|
||||||
OutputName: "hourly.md",
|
OutputName: "hourly.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
"hourly/index.md",
|
"hourly/index.md",
|
||||||
},
|
},
|
||||||
CompatiblePriorIDs: []ID{Hourly},
|
Modules: hourlyModules(),
|
||||||
Modules: hourlyModules(),
|
resolve: resolveHourly,
|
||||||
resolve: resolveHourly,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,23 +87,22 @@ func TestCommandAndConfigurationNamesRejectRetiredReports(t *testing.T) {
|
|||||||
func TestRegistryDefinitionsPreserveRetainedContracts(t *testing.T) {
|
func TestRegistryDefinitionsPreserveRetainedContracts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
id ID
|
id ID
|
||||||
comparison ComparisonStrategy
|
|
||||||
morning bool
|
morning bool
|
||||||
evening bool
|
evening bool
|
||||||
outputName string
|
outputName string
|
||||||
paths []string
|
paths []string
|
||||||
}{
|
}{
|
||||||
{Daily, CompareSameValidDate, false, false, "daily.md", []string{"daily/{valid_start_date}/{run_id}.md", "daily/{valid_start_date}/index.md"}},
|
{Daily, 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"}},
|
{Today, 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"}},
|
{Tomorrow, 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"}},
|
{Hourly, false, false, "hourly.md", []string{"hourly/index.md"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
registry := DefaultRegistry()
|
registry := DefaultRegistry()
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(string(tt.id), func(t *testing.T) {
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
definition := registry.MustLookup(tt.id)
|
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)
|
t.Fatalf("definition = %#v, want retained report contract", definition)
|
||||||
}
|
}
|
||||||
if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") {
|
if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ func todayDefinition() Definition {
|
|||||||
PromptVersion: "2.0.0",
|
PromptVersion: "2.0.0",
|
||||||
TemplateID: "today",
|
TemplateID: "today",
|
||||||
GeneratedTextSchemaID: "today",
|
GeneratedTextSchemaID: "today",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "today",
|
ArtifactGroup: "today",
|
||||||
OutputName: "today.md",
|
OutputName: "today.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
@@ -21,10 +20,9 @@ func todayDefinition() Definition {
|
|||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
"today/index.md",
|
"today/index.md",
|
||||||
},
|
},
|
||||||
CompatiblePriorIDs: []ID{Today},
|
Modules: todayModules(),
|
||||||
Modules: todayModules(),
|
Morning: true,
|
||||||
Morning: true,
|
resolve: resolveToday,
|
||||||
resolve: resolveToday,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ func tomorrowDefinition() Definition {
|
|||||||
PromptVersion: "2.0.0",
|
PromptVersion: "2.0.0",
|
||||||
TemplateID: "tomorrow",
|
TemplateID: "tomorrow",
|
||||||
GeneratedTextSchemaID: "tomorrow",
|
GeneratedTextSchemaID: "tomorrow",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "tomorrow",
|
ArtifactGroup: "tomorrow",
|
||||||
OutputName: "tomorrow.md",
|
OutputName: "tomorrow.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
@@ -21,10 +20,9 @@ func tomorrowDefinition() Definition {
|
|||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
"tomorrow/index.md",
|
"tomorrow/index.md",
|
||||||
},
|
},
|
||||||
CompatiblePriorIDs: []ID{Tomorrow},
|
Modules: tomorrowModules(),
|
||||||
Modules: tomorrowModules(),
|
Evening: true,
|
||||||
Evening: true,
|
resolve: resolveTomorrow,
|
||||||
resolve: resolveTomorrow,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ package state
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
@@ -39,20 +36,6 @@ type ArtifactPaths struct {
|
|||||||
RenderContext string `json:"renderContext,omitempty"`
|
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) {
|
func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) {
|
||||||
if cfg.Root == "" {
|
if cfg.Root == "" {
|
||||||
return nil, fmt.Errorf("workspace root is required")
|
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
|
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 {
|
func (s *FilesystemStore) join(parts ...string) string {
|
||||||
all := append([]string{s.root}, parts...)
|
all := append([]string{s.root}, parts...)
|
||||||
return filepath.Join(all...)
|
return filepath.Join(all...)
|
||||||
@@ -553,30 +314,3 @@ func validatePathSegment(name string, value string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,13 +13,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MetadataSchemaVersionV1 = "weatherreporter.metadata.v1"
|
MetadataSchemaVersion = "weatherreporter.metadata.v2"
|
||||||
MetadataSchemaVersion = "weatherreporter.metadata.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata is the durable record used for report discovery and inspection.
|
// Metadata is the durable record used by the transitional state package.
|
||||||
// V1 fields remain internal compatibility values and are emitted only for V1
|
|
||||||
// records; V2 records have no legacy aliases in their JSON representation.
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
@@ -45,39 +42,32 @@ type Metadata struct {
|
|||||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||||
RenderContextPath string `json:"renderContextPath,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 {
|
type metadataJSON struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
Location *briefing.LocationContext `json:"location,omitempty"`
|
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||||
DataPackagePath string `json:"dataPackagePath"`
|
DataPackagePath string `json:"dataPackagePath"`
|
||||||
PreparationPath string `json:"preparationPath,omitempty"`
|
PreparationPath string `json:"preparationPath,omitempty"`
|
||||||
ExecutionPath string `json:"executionPath,omitempty"`
|
ExecutionPath string `json:"executionPath,omitempty"`
|
||||||
PreflightPath string `json:"preflightPath,omitempty"`
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
NotificationPath string `json:"notificationPath,omitempty"`
|
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
|
||||||
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
|
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||||
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
|
||||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Metadata) MarshalJSON() ([]byte, error) {
|
func (m Metadata) MarshalJSON() ([]byte, error) {
|
||||||
@@ -92,53 +82,24 @@ func (m Metadata) MarshalJSON() ([]byte, error) {
|
|||||||
GeneratedTextSchemaID: m.GeneratedTextSchemaID, GeneratedTextRawPath: m.GeneratedTextRawPath,
|
GeneratedTextSchemaID: m.GeneratedTextSchemaID, GeneratedTextRawPath: m.GeneratedTextRawPath,
|
||||||
GeneratedTextPath: m.GeneratedTextPath, RenderContextPath: m.RenderContextPath,
|
GeneratedTextPath: m.GeneratedTextPath, RenderContextPath: m.RenderContextPath,
|
||||||
}
|
}
|
||||||
switch m.SchemaVersion {
|
if m.SchemaVersion != MetadataSchemaVersion {
|
||||||
case MetadataSchemaVersionV1:
|
|
||||||
w.PreflightPath = m.PreflightPath
|
|
||||||
w.GeneratedTextResultPath = m.GeneratedTextResultPath
|
|
||||||
case MetadataSchemaVersion:
|
|
||||||
w.PreparationPath = m.PreparationPath
|
|
||||||
w.ExecutionPath = m.ExecutionPath
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion)
|
return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion)
|
||||||
}
|
}
|
||||||
|
w.PreparationPath = m.PreparationPath
|
||||||
|
w.ExecutionPath = m.ExecutionPath
|
||||||
return json.Marshal(w)
|
return json.Marshal(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Metadata) UnmarshalJSON(data []byte) error {
|
func (m *Metadata) UnmarshalJSON(data []byte) error {
|
||||||
var header struct {
|
type metadataWire Metadata
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
var decoded metadataWire
|
||||||
}
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
if err := json.Unmarshal(data, &header); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if header.SchemaVersion != MetadataSchemaVersionV1 && header.SchemaVersion != MetadataSchemaVersion {
|
if decoded.SchemaVersion != MetadataSchemaVersion {
|
||||||
return fmt.Errorf("unsupported metadata schema version %q", header.SchemaVersion)
|
return fmt.Errorf("unsupported metadata schema version %q", decoded.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
|
|
||||||
}
|
}
|
||||||
|
*m = Metadata(decoded)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,18 +116,12 @@ func (m Metadata) Validate() error {
|
|||||||
if strings.TrimSpace(m.MetadataPath) == "" {
|
if strings.TrimSpace(m.MetadataPath) == "" {
|
||||||
return fmt.Errorf("metadata path is required")
|
return fmt.Errorf("metadata path is required")
|
||||||
}
|
}
|
||||||
switch m.SchemaVersion {
|
if m.SchemaVersion != MetadataSchemaVersion {
|
||||||
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:
|
|
||||||
return fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package state
|
package state
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,3 +37,10 @@ func TestBuildPromptMetadataIncludesOnlyExistingArtifacts(t *testing.T) {
|
|||||||
t.Fatalf("metadata includes paths for unreached artifacts: %#v", metadata)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,17 +25,6 @@ type Store interface {
|
|||||||
SaveRenderContext(context.Context, report.Resolved, any) (string, error)
|
SaveRenderContext(context.Context, report.Resolved, any) (string, error)
|
||||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (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"
|
const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1"
|
||||||
|
|||||||
Reference in New Issue
Block a user