Add report artifact inspection commands
This commit is contained in:
28
docs/cli.md
28
docs/cli.md
@@ -50,6 +50,12 @@ weatherreporter generate weekend
|
|||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
weatherreporter run morning
|
weatherreporter run morning
|
||||||
weatherreporter run evening
|
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`,
|
`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
|
compact report status logs to stderr, and return nonzero when any report
|
||||||
failed.
|
failed.
|
||||||
|
|
||||||
|
`inspect` commands read the configured workspace and emit JSON to stdout. They
|
||||||
|
do not fetch weather data or invoke `scriptorium`.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
- `-h`, `--help`: show help.
|
- `-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.
|
- `--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`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end 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
|
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||||
timestamps with explicit offsets.
|
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.
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ Outputs:
|
|||||||
- metadata JSON
|
- metadata JSON
|
||||||
- prior comparable snapshot metadata when available
|
- prior comparable snapshot metadata when available
|
||||||
- prior briefing package when loaded by path
|
- prior briefing package when loaded by path
|
||||||
|
- recent report records for inspection
|
||||||
|
- metadata and data package lookup by RunID
|
||||||
|
|
||||||
## Boundaries
|
## 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
|
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
|
||||||
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
||||||
Outlook snapshots for the same weekend window. The store can load a briefing
|
Outlook snapshots for the same weekend window. The store can load a briefing
|
||||||
snapshot by path for structured comparison. The store prepares the managed
|
snapshot by path for structured comparison. The store can list metadata-backed
|
||||||
Markdown report path before `scriptorium run` writes it.
|
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
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,26 @@ Each generated report writes metadata that links:
|
|||||||
Run summaries include each report ID, prompt ID, RunID, status, error text when
|
Run summaries include each report ID, prompt ID, RunID, status, error text when
|
||||||
applicable, valid period, and artifact paths known to the application.
|
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
|
## Recent Changes
|
||||||
|
|
||||||
When a prior comparable Daily briefing snapshot exists for the same valid local
|
When a prior comparable Daily briefing snapshot exists for the same valid local
|
||||||
|
|||||||
@@ -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) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
|||||||
123
internal/app/inspect.go
Normal file
123
internal/app/inspect.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ Usage:
|
|||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
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 morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||||
weatherreporter run evening [--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:
|
Options:
|
||||||
-h, --help Show this help message.
|
-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
|
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])
|
||||||
}
|
}
|
||||||
@@ -94,6 +102,107 @@ type generateOptions struct {
|
|||||||
End string
|
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) {
|
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||||
if r.Clock == nil {
|
if r.Clock == nil {
|
||||||
r.Clock = timeutil.SystemClock{}
|
r.Clock = timeutil.SystemClock{}
|
||||||
@@ -230,12 +339,51 @@ 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 writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
|
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
|
||||||
encoder := json.NewEncoder(stdout)
|
encoder := json.NewEncoder(stdout)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
return encoder.Encode(result)
|
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) {
|
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
|
||||||
if stderr == nil || result == nil {
|
if stderr == nil || result == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ func TestRunUnknownCommand(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr 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 {
|
if err == nil {
|
||||||
t.Fatal("Run() error = nil, want unknown command error")
|
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())
|
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) {
|
func TestResolveGenerateCommands(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
@@ -32,6 +33,21 @@ type ArtifactPaths struct {
|
|||||||
RenderedReport string `json:"renderedReport,omitempty"`
|
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) {
|
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")
|
||||||
@@ -213,6 +229,95 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
|||||||
}, nil
|
}, 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) {
|
func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group string) ([]string, error) {
|
||||||
paths, err := s.Paths(resolved)
|
paths, err := s.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user