diff --git a/docs/cli.md b/docs/cli.md index 6049f16..c332796 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,6 +50,12 @@ weatherreporter generate weekend weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 weatherreporter run morning weatherreporter run evening +weatherreporter inspect reports +weatherreporter inspect metadata RUN_ID +weatherreporter inspect briefing RUN_ID +weatherreporter inspect data-package RUN_ID +weatherreporter inspect prior RUN_ID +weatherreporter inspect sources RUN_ID ``` `generate daily`, `generate tomorrow`, `generate three-day`, @@ -63,6 +69,9 @@ an independent report failure, print a JSON aggregate summary to stdout, write compact report status logs to stderr, and return nonzero when any report failed. +`inspect` commands read the configured workspace and emit JSON to stdout. They +do not fetch weather data or invoke `scriptorium`. + ## Flags - `-h`, `--help`: show help. @@ -74,6 +83,25 @@ failed. - `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. +- `--limit N`: maximum report records for `inspect reports`; defaults to 20, + and `0` means no limit. Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339 timestamps with explicit offsets. + +## Inspection + +```sh +weatherreporter inspect reports --limit 10 +weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today +weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today +weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today +weatherreporter inspect prior 20260529T100000.000000000Z_daily_today +weatherreporter inspect sources 20260529T100000.000000000Z_daily_today +``` + +`inspect reports` lists recent generated runs with artifact paths and warning +counts. The other commands require a RunID. `inspect prior` returns the prior +comparable snapshot metadata selected from stored metadata, or `null` when no +prior comparable snapshot exists. `inspect sources` shows source provenance and +source warnings without dumping full weather payloads. diff --git a/docs/internal/state.md b/docs/internal/state.md index 0934b90..ebbb9c2 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -27,6 +27,8 @@ Outputs: - metadata JSON - prior comparable snapshot metadata when available - prior briefing package when loaded by path +- recent report records for inspection +- metadata and data package lookup by RunID ## Boundaries @@ -54,8 +56,10 @@ date and returns the latest earlier compatible run. Daily Today and Daily Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior 3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend Outlook snapshots for the same weekend window. The store can load a briefing -snapshot by path for structured comparison. The store prepares the managed -Markdown report path before `scriptorium run` writes it. +snapshot by path for structured comparison. The store can list metadata-backed +report records and load metadata or data packages by RunID for inspection. The +store prepares the managed Markdown report path before `scriptorium run` writes +it. ## Failure Behavior diff --git a/docs/operations.md b/docs/operations.md index 26a024e..4eb5d88 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -123,6 +123,26 @@ Each generated report writes metadata that links: Run summaries include each report ID, prompt ID, RunID, status, error text when applicable, valid period, and artifact paths known to the application. +## Inspection + +Use `weatherreporter inspect reports` to list recent generated runs from the +configured workspace. The output includes RunID, report ID, valid period, +metadata path, briefing path, report path, and source warning count. + +Run-specific inspection commands emit JSON for a single RunID: + +```text +weatherreporter inspect metadata RUN_ID +weatherreporter inspect briefing RUN_ID +weatherreporter inspect data-package RUN_ID +weatherreporter inspect prior RUN_ID +weatherreporter inspect sources RUN_ID +``` + +`inspect prior` shows the prior comparable snapshot selected from stored +metadata, or `null` when none exists. `inspect sources` shows source provenance +and source warnings without dumping full weather payloads. + ## Recent Changes When a prior comparable Daily briefing snapshot exists for the same valid local diff --git a/internal/app/app_test.go b/internal/app/app_test.go index cf9ac23..13e167b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -726,6 +726,132 @@ func TestGenerateStormReportWritesReport(t *testing.T) { } } +func TestInspectGeneratedReportArtifacts(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + runBody: "# Daily Report\n", + } + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: renderer, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + + records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1}) + if err != nil { + t.Fatalf("InspectReports() error = %v", err) + } + if len(records) != 1 || records[0].RunID != result.Metadata.RunID { + t.Fatalf("records = %#v, want generated run", records) + } + metadata, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) + if err != nil { + t.Fatalf("InspectMetadata() error = %v", err) + } + if metadata.BriefingPath != result.BriefingPath || metadata.DataPackagePath != result.DataPackagePath { + t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata) + } + briefingPackage, err := InspectBriefing(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) + if err != nil { + t.Fatalf("InspectBriefing() error = %v", err) + } + if briefingPackage.Metadata.RunID != result.Metadata.RunID { + t.Fatalf("briefing RunID = %q, want %q", briefingPackage.Metadata.RunID, result.Metadata.RunID) + } + dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) + if err != nil { + t.Fatalf("InspectDataPackage() error = %v", err) + } + if dataPackage.RunID != result.Metadata.RunID { + t.Fatalf("data package RunID = %q, want %q", dataPackage.RunID, result.Metadata.RunID) + } + sources, err := InspectSources(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) + if err != nil { + t.Fatalf("InspectSources() error = %v", err) + } + if len(sources.Sources) == 0 || len(sources.Warnings) == 0 { + t.Fatalf("sources = %#v, want provenance and warnings", sources) + } +} + +func TestInspectPriorSnapshot(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + store, err := state.NewFilesystemStore(cfg.Workspace) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + priorResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T04:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(prior) error = %v", err) + } + currentResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(current) error = %v", err) + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + runBody: "# Daily Report\n", + } + if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil { + t.Fatalf("GenerateReport(prior) error = %v", err) + } + current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store}) + if err != nil { + t.Fatalf("GenerateReport(current) error = %v", err) + } + + prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID}) + if err != nil { + t.Fatalf("InspectPriorSnapshot() error = %v", err) + } + if prior == nil || prior.Metadata.RunID != priorResolved.Metadata().RunID { + t.Fatalf("prior = %#v, want previous generated run", prior) + } +} + +func TestInspectMissingMetadata(t *testing.T) { + cfg := config.Defaults() + cfg.Workspace.Root = t.TempDir() + + _, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: "missing"}) + if err == nil { + t.Fatal("InspectMetadata() error = nil, want missing metadata error") + } + if !strings.Contains(err.Error(), "metadata for run id") { + t.Fatalf("error = %q, want missing run id context", err.Error()) + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" diff --git a/internal/app/inspect.go b/internal/app/inspect.go new file mode 100644 index 0000000..38e171a --- /dev/null +++ b/internal/app/inspect.go @@ -0,0 +1,123 @@ +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/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/state" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +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 []forecast.SourceWarning `json:"warnings,omitempty"` +} + +func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) { + store, err := defaultStore(req.Config) + if err != nil { + return nil, err + } + return store.ListReports(ctx, req.Limit) +} + +func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) { + store, err := defaultStore(req.Config) + if err != nil { + return state.Metadata{}, err + } + metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) + return metadata, err +} + +func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) { + store, err := defaultStore(req.Config) + if err != nil { + return briefing.Package{}, err + } + metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) + if err != nil { + return briefing.Package{}, err + } + return store.LoadBriefing(ctx, metadata.BriefingPath) +} + +func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) { + store, err := defaultStore(req.Config) + if err != nil { + return promptinput.Package{}, err + } + metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) + if err != nil { + return promptinput.Package{}, err + } + return store.LoadDataPackage(ctx, metadata.DataPackagePath) +} + +func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) { + store, err := defaultStore(req.Config) + if err != nil { + return nil, err + } + metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) + if err != nil { + return nil, err + } + resolved, err := resolvedFromMetadata(metadata) + if err != nil { + return nil, err + } + return store.FindPriorSnapshot(ctx, resolved) +} + +func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) { + metadata, err := InspectMetadata(ctx, req) + if err != nil { + return SourceInspection{}, err + } + return SourceInspection{ + RunID: metadata.RunID, + ReportID: metadata.ReportID, + SourceLocation: metadata.SourceLocation, + Sources: metadata.Sources, + Warnings: metadata.SourceWarnings, + }, 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 0e6e260..4ec47f5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -23,6 +23,12 @@ Usage: weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] + weatherreporter inspect reports [--config PATH] [--limit N] + weatherreporter inspect metadata [--config PATH] RUN_ID + weatherreporter inspect briefing [--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. @@ -74,6 +80,8 @@ 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]) } @@ -94,6 +102,107 @@ type generateOptions struct { End string } +type inspectOptions struct { + ConfigPath string + Limit int + RunID string +} + +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) + case "metadata": + opts, err := parseInspectRunFlags(command, args[1:]) + if err != nil { + return err + } + cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) + if err != nil { + return err + } + metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) + if err != nil { + return err + } + return writeJSON(stdout, metadata) + case "briefing": + opts, err := parseInspectRunFlags(command, args[1:]) + if err != nil { + return err + } + cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) + if err != nil { + return err + } + pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) + if err != nil { + return err + } + return writeJSON(stdout, pkg) + case "data-package": + opts, err := parseInspectRunFlags(command, args[1:]) + if err != nil { + return err + } + cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) + if err != nil { + return err + } + pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) + if err != nil { + return err + } + return writeJSON(stdout, pkg) + case "prior": + opts, err := parseInspectRunFlags(command, args[1:]) + if err != nil { + return err + } + cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) + if err != nil { + return err + } + prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) + if err != nil { + return err + } + return writeJSON(stdout, prior) + case "sources": + opts, err := parseInspectRunFlags(command, args[1:]) + if err != nil { + return err + } + cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath}) + if err != nil { + return err + } + sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID}) + if err != nil { + return err + } + return writeJSON(stdout, sources) + default: + return fmt.Errorf("unknown inspect command %q", command) + } +} + func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { if r.Clock == nil { r.Clock = timeutil.SystemClock{} @@ -230,12 +339,51 @@ 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 writeRunSummary(stdout io.Writer, result *app.BatchResult) error { encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") return encoder.Encode(result) } +func writeJSON(stdout io.Writer, value any) error { + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} + func writeRunLogs(stderr io.Writer, result *app.BatchResult) { if stderr == nil || result == nil { return diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index d9a5ddf..68b83c1 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -48,12 +48,12 @@ func TestRunUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - err := Run(context.Background(), []string{"inspect"}, &stdout, &stderr) + err := Run(context.Background(), []string{"unknown"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want unknown command error") } - if !strings.Contains(err.Error(), `unknown command "inspect"`) { + if !strings.Contains(err.Error(), `unknown command "unknown"`) { t.Fatalf("Run() error = %q, want unknown command message", err.Error()) } } @@ -506,6 +506,81 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { } } +func TestRunInspectGeneratedArtifacts(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + runner := Runner{Clock: fixedClock()} + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runner.Run(context.Background(), []string{ + "generate", "daily", + "--config", configPath, + "--date", "2026-05-29", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run(generate) error = %v", err) + } + dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) + if err != nil { + t.Fatalf("glob data package: %v", err) + } + if len(dataPackageMatches) != 1 { + t.Fatalf("data package files = %#v, want one", dataPackageMatches) + } + runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json") + + stdout.Reset() + err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) + if err != nil { + t.Fatalf("Run(inspect reports) error = %v", err) + } + if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"metadataPath"`) { + t.Fatalf("inspect reports output missing run:\n%s", stdout.String()) + } + + for _, command := range []string{"metadata", "briefing", "data-package", "sources"} { + stdout.Reset() + err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr) + if err != nil { + t.Fatalf("Run(inspect %s) error = %v", command, err) + } + if !strings.Contains(stdout.String(), runID) { + t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String()) + } + } + if !strings.Contains(stdout.String(), `"warnings"`) { + t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String()) + } +} + +func TestRunInspectMissingMetadata(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yml") + configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr) + if err == nil { + t.Fatal("Run(inspect metadata) error = nil, want missing metadata error") + } + if !strings.Contains(err.Error(), "metadata for run id") { + t.Fatalf("error = %q, want missing run id context", err.Error()) + } +} + func TestResolveGenerateCommands(t *testing.T) { runner := Runner{Clock: fixedClock()} tests := []struct { diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 098e86c..04922d1 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strings" + "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" @@ -32,6 +33,21 @@ type ArtifactPaths struct { RenderedReport string `json:"renderedReport,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"` + BriefingPath string `json:"briefingPath"` + 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") @@ -213,6 +229,95 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R }, 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() || !strings.HasSuffix(entry.Name(), ".metadata.json") { + 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") + } + var pkg promptinput.Package + if err := readJSON(path, &pkg); err != nil { + return promptinput.Package{}, err + } + return pkg, nil +} + +func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) { + var metadata Metadata + if err := readJSON(path, &metadata); err != nil { + return ReportRecord{}, err + } + 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, + BriefingPath: metadata.BriefingPath, + 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 {