21 Commits

Author SHA1 Message Date
8dd604afb4 Update default sections of the Area Forecast Discussion provided to different report types 2026-06-20 20:27:37 -05:00
52bb17c8fa Document CLI output contract 2026-06-20 23:01:14 +00:00
7952e4fb25 Wire CLI action summaries 2026-06-20 22:55:59 +00:00
0281327365 Centralize CLI output helpers 2026-06-20 22:49:51 +00:00
bf76eae301 Add CLI result summaries 2026-06-20 22:47:16 +00:00
0d47662cf9 Add detailed generate result 2026-06-20 22:43:49 +00:00
f4f009b904 Add a feature roadmap and staged implentation plan to harmonize CLI command outputs 2026-06-20 17:39:05 -05:00
3c1b753952 Tighten workspace artifact path handling 2026-06-20 09:09:56 -05:00
bdbab48d10 Document managed workspace artifact layout 2026-06-20 13:37:18 +00:00
16cc4b3f63 Update app workflow path expectations 2026-06-20 13:33:30 +00:00
0ef861ed8f Discover metadata with new workspace filenames 2026-06-20 13:31:22 +00:00
6ae7eb44cf Update managed workspace artifact paths 2026-06-20 13:29:41 +00:00
8f6aa8aa8b Add a feature roadmap and staged implentation plan to refactor the local workspace layout 2026-06-20 08:26:19 -05:00
b8e889ad13 Finalize and close the distributor report path refactor roadmap 2026-06-20 07:41:37 -05:00
15ee4af1a1 Document report-specific distributor paths 2026-06-20 03:00:29 +00:00
4c606eb39f Remove legacy distributor report path config 2026-06-20 02:58:41 +00:00
8d2ac163ae Use report-specific distributor paths 2026-06-20 02:55:06 +00:00
8709b5f4d8 Generalize distributor report path rendering 2026-06-20 02:49:39 +00:00
fd48ebecb8 Add per-report distributor path overrides 2026-06-20 02:46:05 +00:00
021e5dd8b1 Add report distributor path defaults 2026-06-20 02:42:20 +00:00
7adf5e1b08 Add a feature roadmap and implementation plan to refactor configuration for distributor output paths 2026-06-19 21:38:30 -05:00
46 changed files with 2812 additions and 1440 deletions

View File

@@ -20,15 +20,15 @@ Markdown report after final metadata is saved.
```text ```text
weatherreporter --help weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
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] [--quiet] --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] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID
@@ -37,10 +37,13 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
Implemented `generate` commands write a JSON module snapshot, YAML data package, Implemented `generate` commands emit a compact JSON summary to stdout on
preflight artifact, managed Markdown report, and metadata under the configured success. The summary includes command identity, report identity, RunID, status,
workspace. `--out` writes an extra Markdown copy for the operator; distributor valid period, and managed artifact paths. They also write a JSON module
notification uses the managed report path, not the extra copy. `generate daily`, snapshot, YAML data package, preflight artifact, managed Markdown report, and
metadata under the configured workspace. `--out` writes an extra Markdown copy
for the operator; distributor notification uses the managed report path, not
the extra copy. `generate daily`,
`generate today`, `generate tomorrow`, and `generate hourly` write managed `generate today`, `generate tomorrow`, and `generate hourly` write managed
generated-text artifacts, validate structured text from Scriptorium, and render generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires the managed Markdown report from embedded templates. `generate daily` requires
@@ -71,8 +74,91 @@ report fails, the batch notification is skipped for the whole batch.
Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
not included in `run morning` or `run evening`. not included in `run morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout. `inspect` commands read existing workspace artifacts and emit the requested
They do not collect weather data or invoke `scriptorium`. JSON data to stdout. They do not collect weather data or invoke `scriptorium`.
Inspection commands do not accept `--quiet`.
## Output
Action commands, meaning `generate` and `run`, emit JSON summaries to stdout by
default. Pre-run errors, such as invalid flags, missing required arguments, or
configuration load failures, return an error without emitting partial JSON.
`--quiet` suppresses successful action-command stdout and routine stderr. It
does not hide returned errors. Inspection commands are data-output commands;
they always write the requested JSON to stdout and are not quietable.
Generate summaries have this shape:
```json
{
"command": "generate",
"reportId": "today",
"reportName": "Today Report",
"promptId": "weather.today_generated_text",
"runId": "20260529T120000.000000000Z_today",
"status": "succeeded",
"generatedAt": "2026-05-29T12:00:00Z",
"validPeriod": {
"start": "2026-05-29T00:00:00-05:00",
"end": "2026-05-30T00:00:00-05:00"
},
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json",
"generatedTextRawPath": "workspace/snapshots/today/2026-05-29/generated_text_raw.20260529T120000.000000000Z_today.json",
"generatedTextResultPath": "workspace/snapshots/today/2026-05-29/generated_text_result.20260529T120000.000000000Z_today.json",
"generatedTextPath": "workspace/snapshots/today/2026-05-29/generated_text.20260529T120000.000000000Z_today.json",
"renderContextPath": "workspace/snapshots/today/2026-05-29/render_context.20260529T120000.000000000Z_today.json"
}
```
Markdown-path reports omit the generated-text fields. If distributor
notification is attempted, summaries include `notificationPath`; successful
notification also includes a compact `notification` object. If notification
fails after report artifacts exist, the summary has `"status": "failed"` and an
`error` string while retaining inspectable artifact paths.
Run summaries have this shape:
```json
{
"command": "run",
"batch": "morning",
"status": "succeeded",
"startedAt": "2026-05-29T12:00:00Z",
"finishedAt": "2026-05-29T12:01:00Z",
"total": 1,
"succeeded": 1,
"failed": 0,
"reports": [
{
"reportId": "today",
"reportName": "Today Report",
"promptId": "weather.today_generated_text",
"runId": "20260529T120000.000000000Z_today",
"status": "succeeded",
"generatedAt": "2026-05-29T12:00:00Z",
"validPeriod": {
"start": "2026-05-29T00:00:00-05:00",
"end": "2026-05-30T00:00:00-05:00"
},
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json"
}
]
}
```
`run` status is `failed` when any report failed or the top-level batch
notification failed. Batch stderr uses compact status lines, for example:
```text
report=today status=succeeded output="reports/today.md"
batch=morning total=2 succeeded=2 failed=0
```
## Flags ## Flags
@@ -82,6 +168,7 @@ They do not collect weather data or invoke `scriptorium`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`. - `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command. - `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`. - `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--quiet`: suppress successful stdout and routine stderr for `generate` and `run`.
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone. - `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting 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`.
@@ -105,6 +192,8 @@ weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports weatherreporter run morning --out-dir ./reports
weatherreporter run evening --out-dir ./reports weatherreporter run evening --out-dir ./reports
weatherreporter generate today --quiet
weatherreporter run morning --quiet
``` ```
## Inspection ## Inspection

View File

@@ -101,15 +101,6 @@ contains all managed Markdown reports from that batch.
Default: `weatherreporter.{location_id}.{report_id}`. Default: `weatherreporter.{location_id}.{report_id}`.
- `idempotency_key_template`: template for single-report distributor - `idempotency_key_template`: template for single-report distributor
idempotency keys. Default: `{bundle_id}.{run_id}`. idempotency keys. Default: `{bundle_id}.{run_id}`.
- `report_path_templates`: ordered list of templates for Markdown report paths
inside distributor bundles. For `generate <report>`, each rendered path maps
to the generated report's managed Markdown source. For `run morning` and
`run evening`, the templates are rendered once per included report and each
rendered path maps that report's managed Markdown source into the one batch
bundle. Default:
```yaml
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
```
- `batch.enabled`: whether batch distributor notification config is active - `batch.enabled`: whether batch distributor notification config is active
when distributor notification is enabled. Default: `true`. when distributor notification is enabled. Default: `true`.
- `batch.pipeline_id_template`: template for batch distributor pipeline IDs. - `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
@@ -124,10 +115,13 @@ contains all managed Markdown reports from that batch.
Single-report templates support `location_id`, `report_id`, `run_id`, Single-report templates support `location_id`, `report_id`, `run_id`,
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`, `artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and `valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and and `storm_id`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone. stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`. `storm_id` is derived from the storm report valid period as
`{valid_start_stamp}-{valid_end_stamp}`; it renders empty for non-storm
reports. `pipeline_id_template` and `idempotency_key_template` may also use
`bundle_id`.
The rendered pipeline ID selects the configured distributor `http_upload` The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The rendered bundle ID is the stable logical source identity for the workflow. The rendered bundle ID is the stable logical source identity for the
@@ -141,11 +135,24 @@ identify a specific retryable batch attempt.
Rendered report paths must be unique relative paths with `/` separators. They Rendered report paths must be unique relative paths with `/` separators. They
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`, must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
or `.distributor.json`. In a batch upload, uniqueness is checked across every or the reserved distributor sidecar basename, formed from a leading dot plus
`distributor.json`. In a batch upload, uniqueness is checked across every
rendered bundle path for every included report before distributor is called. rendered bundle path for every included report before distributor is called.
Managed Markdown report paths are the only upload source files; copies written Managed Markdown report paths are the only upload source files; copies written
with `--out` or `--out-dir` are never uploaded. with `--out` or `--out-dir` are never uploaded.
Distributor bundle paths are report-specific. Weatherreporter uses
`reports.<report>.distributor.path_templates` when that override is configured;
otherwise it uses the report definition defaults:
- `hourly`: `hourly/index.md`
- `daily`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`
- `today`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md`
- `tomorrow`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md`
- `three_day`: `three-day/{valid_start_date}/{run_id}.md`, `three-day/{valid_start_date}/index.md`
- `weekend`: `weekend/{valid_start_date}/{run_id}.md`, `weekend/{valid_start_date}/index.md`
- `storm`: `storm/{storm_id}/{run_id}.md`, `storm/{storm_id}/index.md`
The upload token is read from the environment variable named by `token_env` The upload token is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. Config files should after config loading and `secrets.directory` processing. Config files should
name the variable only; they should not contain the token value. name the variable only; they should not contain the token value.
@@ -178,7 +185,9 @@ the missing-source policy. Source override keys include `observations`,
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`. - `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
Workspace subdirectories must be relative paths that stay inside Workspace subdirectories must be relative paths that stay inside
`workspace.root`. `workspace.root`. Managed artifact paths below those directories are grouped by
artifact group and valid-period start date; the path template is not
configurable.
### `dayparts` ### `dayparts`
@@ -220,12 +229,19 @@ Each report entry supports:
- `deterministic_modules`: ordered module list. Entries may be string module - `deterministic_modules`: ordered module list. Entries may be string module
IDs or objects with `id` and optional `options`. IDs or objects with `id` and optional `options`.
- `distributor.path_templates`: optional ordered distributor bundle path
templates for this report. If omitted, the report definition defaults are
used. If present, the list must contain at least one template.
Example: Example:
```yaml ```yaml
reports: reports:
daily: daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
@@ -235,7 +251,7 @@ reports:
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - long_term
- spc_convective_discussion - spc_convective_discussion
- daily_planning - daily_planning
- hourly_forecast - hourly_forecast
@@ -276,7 +292,8 @@ Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading. combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`, `area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections` `key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD sections. includes all available AFD sections. Default report definitions may choose a
smaller report-specific subset, such as daily reports using only `long_term`.
The module registry accepts all module IDs documented in The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Unknown or unimplemented [Module Contract Internals](internal/module.md). Unknown or unimplemented

View File

@@ -104,7 +104,7 @@ func SubmitReport(reportPath, summaryPath string) error {
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes. - Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish. - Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`. Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames such as `manifest.json` and the distributor sidecar basename formed from a leading dot plus `distributor.json`.
## Idempotency And Status ## Idempotency And Status

View File

@@ -68,7 +68,8 @@ Invalid paths include:
- paths containing backslashes; - paths containing backslashes;
- `.` or `..` path segments; - `.` or `..` path segments;
- empty path segments; - empty path segments;
- any basename of `manifest.json` or `.distributor.json`. - any reserved basename, including `manifest.json` and the distributor sidecar
basename formed from a leading dot plus `distributor.json`.
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable. Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.

View File

@@ -84,7 +84,9 @@ defaults.
Single-report commands validate the report command, collect once through Single-report commands validate the report command, collect once through
`internal/collect`, resolve the requested report, and pass the resolved report `internal/collect`, resolve the requested report, and pass the resolved report
plus explicit collection into `GenerateReport`. plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
callers.
`GenerateReport` then uses this setup: `GenerateReport` then uses this setup:
@@ -163,10 +165,11 @@ all planned reports finish, app orchestration evaluates batch notification:
3. If any planned report failed, the batch notification result is `skipped` 3. If any planned report failed, the batch notification result is `skipped`
with reason `one or more reports failed`, and distributor is not called. with reason `one or more reports failed`, and distributor is not called.
4. If every report succeeded, app orchestration renders batch pipeline, bundle 4. If every report succeeded, app orchestration renders batch pipeline, bundle
ID, and idempotency key templates, renders `report_path_templates` for each ID, and idempotency key templates, renders report-specific distributor
included report, validates every managed source path and bundle path, checks paths for each included report, validates every managed source path and
duplicate bundle paths across the batch, calls the notifier once with a bundle path, checks duplicate bundle paths across the batch, calls the
multi-file request, and saves a batch notification debug artifact. notifier once with a multi-file request, and saves a batch notification
debug artifact.
Batch notification failure records a top-level failed notification, increments Batch notification failure records a top-level failed notification, increments
the aggregate batch failure count, and returns an aggregate batch error without the aggregate batch failure count, and returns an aggregate batch error without
@@ -190,7 +193,9 @@ inspection view.
- Generated-text report errors preserve available intermediate artifacts and do - Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies. not create extra output copies.
- Single-report notification errors are wrapped with report ID, RunID, and - Single-report notification errors are wrapped with report ID, RunID, and
managed report path context. managed report path context. Detailed generation returns the inspectable
report, metadata, and notification artifact paths when finalization has
already saved them.
- Batch notification errors are recorded on the top-level batch notification - Batch notification errors are recorded on the top-level batch notification
result and do not change individual report item status. result and do not change individual report item status.
- Metadata and artifact path errors include filesystem context. - Metadata and artifact path errors include filesystem context.

View File

@@ -90,7 +90,7 @@ builders run. Configured `location` values are prompt context only; Weather API
`area_forecast_discussion` uses optional `sections` configuration to include a `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Hourly Report defaults this module to subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`. `key_messages` and `short_term`; Daily Report defaults it to `long_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived `spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched report-period outlooks. It emits `checked: true` for a successfully fetched

67
docs/internal/cli.md Normal file
View File

@@ -0,0 +1,67 @@
# CLI Internals
This document describes command output ownership in `internal/cli`.
## Purpose
`internal/cli` owns command parsing, app request construction, help text, and
presentation of command results. It converts app-layer results into stable CLI
summaries and writes stdout/stderr through shared output helpers.
## Command Categories
- Action commands: `generate` and `run`. These perform work, write artifacts,
and return compact summaries.
- Inspection commands: `inspect reports`, `inspect metadata`, `inspect
modules`, `inspect data-package`, `inspect prior`, and `inspect sources`.
These read existing artifacts and return requested data.
Future commands must declare which category they belong to before adding output
behavior.
## Stdout And Stderr
Action commands write JSON summaries to stdout by default. `run` also writes
compact status lines to stderr through `writeBatchStatus`. `generate` does not
write routine stderr today. Pre-run errors return without partial JSON.
Inspection commands write requested JSON data to stdout with `writeJSON`. They
do not use action output helpers and do not support quiet mode.
Returned errors are not hidden by output helpers. The caller remains
responsible for displaying command errors.
## Quiet Mode
`--quiet` is supported only by action commands. It suppresses successful stdout
and routine stderr by passing `outputOptions{Quiet: true}` to
`writeActionResult`. It does not suppress returned errors.
Quiet mode is intentionally not accepted by inspection commands because
inspection stdout is the command result.
## Summary Ownership
CLI-safe summary structs live in `internal/cli/result.go`.
- `newGenerateSummary` converts `*app.ReportResult` plus an optional error into
the generate JSON contract.
- `newBatchSummary` converts `*app.BatchResult` into the run JSON contract and
derives the top-level run status.
Summary types must not expose full app internals, module contents, data package
contents, raw generated text, Scriptorium result bodies, or full distributor
payloads.
## Helper Path
New action commands should:
1. parse command-specific flags into CLI option structs;
2. call the app-layer use case;
3. convert app results into a CLI summary type;
4. write through `writeActionResult`;
5. use a status writer only for routine stderr status lines.
New inspection commands should call the app inspection use case and write the
returned data through `writeJSON`.

View File

@@ -61,12 +61,16 @@ and bundle paths from:
- `pipeline_id_template` - `pipeline_id_template`
- `bundle_id_template` - `bundle_id_template`
- `idempotency_key_template` - `idempotency_key_template`
- `report_path_templates` - report-specific path templates
For batch uploads, the app layer renders pipeline ID, bundle ID, and For batch uploads, the app layer renders pipeline ID, bundle ID, and
idempotency key from `notify.distributor.batch.*`, renders idempotency key from `notify.distributor.batch.*`, resolves report-specific
`report_path_templates` once per included report, and passes the resulting path templates once per included report, and passes the resulting multi-file
multi-file request to this adapter. request to this adapter.
Report-specific path resolution happens entirely in the app layer. Explicit
`reports.<report>.distributor.path_templates` overrides take precedence over
report definition defaults.
The token value is read from the environment variable named by `token_env` The token value is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. after config loading and `secrets.directory` processing.

View File

@@ -99,7 +99,8 @@ The default Daily Report module order is:
14. `hourly_forecast` 14. `hourly_forecast`
The embedded Daily template uses selected deterministic fields from these The embedded Daily template uses selected deterministic fields from these
module outputs after GeneratedText validation. module outputs after GeneratedText validation. Its `area_forecast_discussion`
item is configured to include only `long_term`.
## Today Composition ## Today Composition

View File

@@ -62,21 +62,22 @@ Workspace subdirectories must be relative paths that stay under
## Managed Layout ## Managed Layout
Paths are derived from the resolved report definition's artifact group, the Paths are derived from the resolved report definition's artifact group, the
valid-period start date for dated artifacts, and the RunID. valid-period start date, and the RunID. Filenames put the artifact kind before
the RunID.
```text ```text
<workspace.root>/ <workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
notifications/batches/<batch>/<YYYY-MM-DD>/<batch_run_id>.distributor.json notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
reports/<artifact_group>/<run_id>.md notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
``` ```
Metadata is stored beside module snapshots and links the module snapshot, data Metadata is stored beside module snapshots and links the module snapshot, data

View File

@@ -37,7 +37,9 @@ effective report timezone and is not included in `run morning` or
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved. Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for generated reports; it is not used `--out PATH` writes an extra Markdown copy for generated reports; it is not used
as the distributor upload source. as the distributor upload source. Generate commands emit a compact JSON summary
to stdout by default. Use `--quiet` to suppress successful stdout for cron jobs
or other schedulers that only need nonzero exits and external logs.
Batch commands: Batch commands:
@@ -64,7 +66,8 @@ after every planned report succeeds. If any report fails, the batch upload is
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
using report default filenames such as `today.md` and `tomorrow.md`; dynamic using report default filenames such as `today.md` and `tomorrow.md`; dynamic
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
distributor upload sources. distributor upload sources. Use `--quiet` to suppress successful batch summary
and status output; failures still return nonzero.
## Filesystem Layout ## Filesystem Layout
@@ -72,143 +75,26 @@ The default workspace root is `workspace`.
```text ```text
workspace/ workspace/
snapshots/ reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
daily/
YYYY-MM-DD/ snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
<run_id>.modules.json snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
<run_id>.generated_text.raw.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
<run_id>.generated_text.run.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
<run_id>.generated_text.json snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
<run_id>.render_context.json
today/ data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
YYYY-MM-DD/ preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
<run_id>.modules.json
<run_id>.metadata.json notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
<run_id>.generated_text.raw.json notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
three-day/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
hourly/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
tomorrow/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.yaml
today/
YYYY-MM-DD/
<run_id>.data_package.yaml
three-day/
YYYY-MM-DD/
<run_id>.data_package.yaml
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
hourly/
YYYY-MM-DD/
<run_id>.data_package.yaml
tomorrow/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
today/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
weekend/
YYYY-MM-DD/
<run_id>.render.json
hourly/
YYYY-MM-DD/
<run_id>.render.json
tomorrow/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
notifications/
batches/
morning/
YYYY-MM-DD/
<batch_run_id>.distributor.json
evening/
YYYY-MM-DD/
<batch_run_id>.distributor.json
daily/
YYYY-MM-DD/
<run_id>.distributor.json
today/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/
YYYY-MM-DD/
<run_id>.distributor.json
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
hourly/
YYYY-MM-DD/
<run_id>.distributor.json
tomorrow/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
reports/
daily/
<run_id>.md
today/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
hourly/
<run_id>.md
tomorrow/
<run_id>.md
storm/
<run_id>.md
``` ```
Managed artifact filenames use the RunID, so repeated runs for the same valid Managed artifact filenames use the artifact kind and RunID, so repeated runs
period do not overwrite each other. for the same valid period do not overwrite each other. The date directory is
the valid-period start date in the effective report timezone. Generated-text
artifacts are written only for Daily, Today, Tomorrow, and Hourly reports.
## RunID And Metadata ## RunID And Metadata
@@ -256,17 +142,16 @@ top-level `notification` object with fields such as `status`, `reason`,
Distributor notification is configured with `notify.distributor` and is Distributor notification is configured with `notify.distributor` and is
disabled by default. For `generate <report>`, weatherreporter uploads the disabled by default. For `generate <report>`, weatherreporter uploads the
managed Markdown report path recorded in the report result and metadata. That managed Markdown report path recorded in the report result and metadata. That
single source file can be mapped to one or more configured bundle paths. By single source file is mapped to report-specific bundle paths. Extra copies
default, it is mapped to one dated report path. Extra copies written by `--out` written by `--out` or `--out-dir` are operator conveniences only.
or `--out-dir` are operator conveniences only.
For `run morning` and `run evening`, per-report notification is suppressed. If For `run morning` and `run evening`, per-report notification is suppressed. If
`notify.distributor.enabled` and `notify.distributor.batch.enabled` are both `notify.distributor.enabled` and `notify.distributor.batch.enabled` are both
true, the batch uploads once after all reports finish successfully. The upload true, the batch uploads once after all reports finish successfully. The upload
contains one file mapping set per included report. Each mapping uses the contains one file mapping set per included report. Each mapping uses the
managed Markdown report as the source and renders `report_path_templates` using managed Markdown report as the source and report-specific path templates for
that report's normal template values. All rendered bundle paths across the that report. All rendered bundle paths across the batch must be unique. If any
batch must be unique. If any report fails, weatherreporter records a top-level report fails, weatherreporter records a top-level
notification status of `skipped` with reason `one or more reports failed` and notification status of `skipped` with reason `one or more reports failed` and
does not call distributor. If batch notification is disabled, run commands do does not call distributor. If batch notification is disabled, run commands do
not fall back to per-report uploads. not fall back to per-report uploads.
@@ -301,9 +186,9 @@ count, but individual report items remain succeeded.
Each notification attempt writes a debug artifact under `notifications/`. Each notification attempt writes a debug artifact under `notifications/`.
Single-report artifacts live under Single-report artifacts live under
`notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json`. Batch `notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
artifacts live under artifacts live under
`notifications/batches/<batch>/<YYYY-MM-DD>/<batch_run_id>.distributor.json`, `notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
where the date directory is the batch start date in the effective report where the date directory is the batch start date in the effective report
timezone. The artifact records the rendered pipeline ID, bundle ID, timezone. The artifact records the rendered pipeline ID, bundle ID,
idempotency key, managed source paths, bundle-relative paths, bundle created idempotency key, managed source paths, bundle-relative paths, bundle created

View File

@@ -121,8 +121,12 @@ The CLI is owned by `internal/cli`.
When adding or changing a command or flag: When adding or changing a command or flag:
- update help text and parser behavior together; - update help text and parser behavior together;
- declare whether the command is an action command or an inspection/data-output
command;
- convert parsed values into app-layer request structs; - convert parsed values into app-layer request structs;
- keep domain decisions in `internal/app` or domain packages; - keep domain decisions in `internal/app` or domain packages;
- use the centralized output helpers in `internal/cli/output.go`;
- keep action-command summary conversion in `internal/cli/result.go`;
- add parser or command tests in `internal/cli`; - add parser or command tests in `internal/cli`;
- update `docs/cli.md`; - update `docs/cli.md`;
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects - update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects

View File

@@ -1,360 +0,0 @@
# Batch Distributor Upload Roadmap
## Purpose
This roadmap defines the target behavior for distributor notification from
`weatherreporter run morning` and `weatherreporter run evening`.
Batch commands should publish the generated weather reports as one coherent
distributor source bundle. The batch upload should be atomic from
weatherreporter's point of view: either every planned report is successfully
generated and the whole bundle is uploaded, or distributor is not notified.
Single-report `weatherreporter generate ...` commands should keep the existing
per-report distributor notification behavior.
## Current Problem
Per-report uploads in one batch can create multiple distributor source bundles
with the same logical destination scope. Depending on configured bundle IDs,
created timestamps, distributor merge mode, and destination state, distributor
may reject later uploads as conflicting with an earlier bundle from the same
batch.
The motivating failure was:
```text
destination source has same id and created time but different digest
```
Unique per-report RunIDs reduce one collision class, but they do not provide
batch-level publication semantics. A batch-level upload is the cleaner
long-term boundary when the downstream pipeline is intended to publish a
coherent set of weatherreporter outputs.
## Target Behavior
- `generate <report>` uploads one distributor bundle for that single generated
report when distributor notification is enabled.
- `run morning` and `run evening` generate all planned reports first.
- If collection, planning, report generation, Scriptorium, template rendering,
metadata persistence, or output-copy persistence fails for any report, the
batch distributor upload is skipped for the whole batch.
- If every planned report succeeds, weatherreporter uploads one distributor
bundle containing every managed Markdown report from the batch.
- The batch bundle maps each managed Markdown source path to one or more
bundle-relative paths rendered from the report's definition, valid period,
RunID, and configured distributor path templates.
- Batch upload failure marks the batch as failed and makes the command return a
nonzero aggregate error, but it does not retroactively mark individual report
generation as failed.
- Distributor status polling and debug artifact capture remain required for the
batch upload, just as they are for per-report uploads today.
- Optional `--out-dir` copies remain operator conveniences and are never used as
distributor upload sources.
## Locked Decisions
- Batch notification is all-or-nothing. If one report fails, skip the
distributor upload for the entire batch.
- The initial batch upload uses one distributor pipeline for the whole batch.
- Do not implement report-type-specific routing inside a single batch upload.
- Keep distributor package types confined to `internal/adapters/distributor`.
- Keep single-report notification behavior in place for `generate` commands.
- Do not implement batch-level publication by asking distributor to scan the
weatherreporter workspace.
- Do not introduce a manifest/resume system, workflow engine, plugin system, or
distributor-specific CLI flags.
## Batch Artifact Path
Batch notification should have its own debug artifact path rather than being
attached to one arbitrary report.
Target layout:
```text
workspace/
notifications/
batches/
morning/
2026-06-17/
20260617T120000.123456789Z_morning.distributor.json
evening/
2026-06-17/
20260617T230000.123456789Z_evening.distributor.json
```
The batch notification artifact should include:
- schema version;
- batch kind;
- batch run ID;
- attempted time;
- endpoint;
- pipeline ID;
- bundle ID;
- idempotency key;
- bundle created timestamp;
- included report records, each with report ID, RunID, managed source path, and
rendered bundle paths;
- accepted distributor run ID and upload status;
- latest distributor run status;
- raw distributor run report JSON when available;
- status lookup error when available;
- redacted notification error when upload or downstream run processing fails.
This makes batch notification a first-class batch artifact and keeps future
debugging or inspection behavior straightforward.
## Batch JSON Shape
Batch notification should be represented as a top-level object on `BatchResult`.
Target shape:
```json
{
"batch": "evening",
"startedAt": "2026-06-17T18:50:37.642224552-05:00",
"finishedAt": "2026-06-17T18:51:04.000000000-05:00",
"total": 3,
"succeeded": 3,
"failed": 0,
"notification": {
"status": "succeeded",
"runId": "weatherreporter.20260617T235104Z.77d8f75f",
"pipelineId": "weatherreporter",
"bundleId": "weatherreporter.home.evening",
"idempotencyKey": "weatherreporter.home.evening.20260617T235037.642224552Z_evening",
"path": "/var/lib/weatherreporter/notifications/batches/evening/2026-06-17/20260617T235037.642224552Z_evening.distributor.json",
"includedReports": [
{
"reportId": "tomorrow",
"runId": "20260617T235037.642224552Z_tomorrow",
"sourcePath": "/var/lib/weatherreporter/reports/tomorrow/20260617T235037.642224552Z_tomorrow.md",
"bundlePaths": ["tomorrow/index.md"]
},
{
"reportId": "daily",
"runId": "20260617T235037.642224552Z_daily_2026-06-23",
"sourcePath": "/var/lib/weatherreporter/reports/daily/20260617T235037.642224552Z_daily_2026-06-23.md",
"bundlePaths": ["archive/2026-06-23/daily/20260617T235037.642224552Z_daily_2026-06-23.md"]
}
]
},
"reports": []
}
```
When distributor is disabled, omit the notification object. When notification is
skipped because a report failed, include:
```json
"notification": {
"status": "skipped",
"reason": "one or more reports failed"
}
```
Do not duplicate the same batch notification status into every report item.
Per-report notification fields should remain meaningful for single-report
notification paths.
## Configuration Model
Use the existing `notify.distributor` block for both single-report and batch
notification, but add batch-specific identity templates so operators can keep
single-report and batch source identity separate.
Target config shape:
```yaml
notify:
distributor:
enabled: false
endpoint: https://distributor.example.com
token_env: DISTRIBUTOR_UPLOAD_TOKEN
timeout: 30s
failure_policy: error
pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates:
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
batch:
enabled: true
pipeline_id_template: "weatherreporter"
bundle_id_template: "weatherreporter.{location_id}.{batch}"
idempotency_key_template: "{bundle_id}.{batch_run_id}"
```
Rules:
- `notify.distributor.enabled=false` disables both single-report and batch
notification.
- `notify.distributor.batch.enabled=true` makes run commands use the new
batch-level upload behavior.
- `notify.distributor.batch.enabled=false` disables distributor notification
for run commands. It does not fall back to legacy per-report batch uploads.
- Existing report-level `report_path_templates` are reused to map each report
source into the batch bundle.
- Each report path template is rendered once per included report using that
report's normal distributor template values.
- Batch templates support at least `location_id`, `batch`, `batch_run_id`, and
`batch_started_date`.
- Batch idempotency templates may also use `bundle_id`.
- Batch-level `pipeline_id_template` does not use `report_id` because one
upload contains multiple reports.
- Batch bundle IDs are stable for a logical batch stream. The idempotency key,
not the bundle ID, identifies a specific retryable batch attempt.
## Batch Run Identity
Batch notification needs an app-level batch run ID for notification identity and
artifact naming.
Format:
```text
<timestamp>_<batch>
```
Examples:
```text
20260617T235037.642224552Z_morning
20260617T235037.642224552Z_evening
```
The timestamp is based on batch `StartedAt` in UTC with the same nanosecond
layout used by report RunIDs. This ID is separate from report RunIDs and should
not replace them.
## Bundle Construction
Batch uploads use one distributor adapter upload request with multiple file
mappings.
For each successful report:
1. Use `BatchReportResult.ReportPath` as the source path.
2. Render `notify.distributor.report_path_templates` using that report's
existing distributor template values.
3. Add one upload file mapping per rendered bundle path.
Validation should fail before upload if:
- no reports are included;
- any included report lacks a managed report path;
- any source path is empty;
- any bundle path is empty or invalid;
- two rendered bundle paths in the same batch collide.
Errors should include report ID, RunID, source path, and bundle path context
where useful. Token values must never appear in errors, JSON output, metadata,
or artifacts.
## App Workflow
Batch commands should still collect once, plan once, and generate reports
through the same report generation path used by single-report commands. The
batch workflow differs only at the notification boundary:
- per-report distributor notification is suppressed for `run morning` and
`run evening`;
- report generation continues after individual report failures, preserving
current batch behavior;
- batch notification is skipped if any report failed;
- one batch notification is attempted only when all planned reports succeeded;
- batch notification failure produces an aggregate batch error and a batch
notification debug artifact.
Suppressing per-report notification should be explicit app-layer behavior, not a
hidden side effect inside `GenerateReport`.
## Adapter Boundary
The existing distributor adapter already supports multiple file mappings through
its upload request. Batch uploads should reuse that adapter API rather than
introducing a second distributor client.
The app layer owns:
- batch report selection;
- batch upload identity rendering;
- source path selection;
- bundle path rendering;
- debug artifact persistence;
- batch JSON shape.
The adapter owns:
- distributor package type conversion;
- token lookup;
- upload client construction;
- upload request execution;
- status polling;
- upload/status error wrapping and redaction.
## State And Inspection
State should support saving batch notification artifacts without introducing a
full manifest/resume system.
Expected additions:
- batch-level artifact path calculation for distributor notification;
- save support for the batch distributor notification artifact;
- no required changes to existing report metadata artifact shape.
If linking is useful later, prefer adding the batch notification path to the
top-level batch JSON result first, then consider metadata links.
## CLI Output
Command syntax remains unchanged:
```text
weatherreporter run morning
weatherreporter run evening
```
Stderr should keep one compact line per report generation result, then add one
compact batch notification line when notification is attempted, skipped, or
failed. The same notification error should not be repeated on every report line.
Example:
```text
report=tomorrow status=succeeded output=""
report=daily status=succeeded output=""
batchNotification status=succeeded runId="weatherreporter.20260617T235104Z.77d8f75f"
batch=evening total=2 succeeded=2 failed=0
```
If report generation fails:
```text
report=daily status=failed error="..."
batchNotification status=skipped reason="one or more reports failed"
batch=evening total=2 succeeded=1 failed=1
```
## Documentation Boundary
Until implemented, this roadmap is the only place this behavior should be
described. After implementation, update the relevant implemented docs and keep
future extensions under `docs/roadmap/`.
## Deferred Work
- Batch-level durable resume or retry queues.
- Batch-level distributor status inspection command.
- Multiple distributor pipelines within one batch.
- Report-type-specific distributor routing inside batch uploads.
- Uploading data packages, metadata, render contexts, or notification artifacts
in the batch bundle.
- Distributor-specific CLI flags.
- Batch manifest or progress system.
- Changing distributor destination merge semantics from weatherreporter.

View File

@@ -1,463 +0,0 @@
# Batch Distributor Upload Implementation Roadmap
## Purpose
Implement the batch distributor upload behavior defined in
`docs/roadmap/batch-distributor.md`.
This plan is written for an LLM coding agent. Implement the stages in order.
Preserve existing public CLI syntax, report generation behavior, managed report
paths, and single-report distributor notification behavior unless a stage
explicitly changes batch notification behavior.
## Source Roadmap
Use `docs/roadmap/batch-distributor.md` as the authoritative source for:
- user intent;
- locked policy decisions;
- target batch JSON shape;
- batch notification artifact layout;
- configuration shape;
- app, state, and adapter boundaries;
- deferred work.
Do not move aspirational batch distributor behavior into non-roadmap docs until
the corresponding code is implemented.
## Locked Decisions
- `generate <report>` keeps per-report distributor notification.
- `run morning` and `run evening` use one batch-level distributor upload when
distributor notification is enabled and batch notification is enabled.
- `notify.distributor.batch.enabled=false` disables distributor notification
for run commands; it does not fall back to per-report batch uploads.
- Batch upload is all-or-nothing: if any report fails, skip the distributor
upload for the whole batch.
- A batch notification failure makes the batch command return a nonzero
aggregate error, but it does not mark individual report generation as failed.
- The initial implementation uses one distributor pipeline for the whole batch.
- The batch upload source files are managed Markdown report paths only.
`--out-dir` copies are never uploaded.
- Reuse the existing distributor adapter; do not import distributor package
types outside `internal/adapters/distributor`.
- Do not introduce batch manifest/resume behavior, distributor-specific CLI
flags, a workflow engine, plugin system, or broad CLI redesign.
## Stage 1: Batch Notify Config And Template Rendering
Goal: add batch-specific distributor notification configuration and rendering
without changing runtime behavior yet.
Implementation guidance:
- Add a nested batch config under `notify.distributor`, for example:
```go
type DistributorBatchNotifyConfig struct {
Enabled bool `yaml:"enabled"`
PipelineIDTemplate string `yaml:"pipeline_id_template"`
BundleIDTemplate string `yaml:"bundle_id_template"`
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
}
```
- Add it to `config.DistributorNotifyConfig` as `Batch`.
- Defaults:
- `batch.enabled: true`
- `batch.pipeline_id_template: "weatherreporter"`
- `batch.bundle_id_template: "weatherreporter.{location_id}.{batch}"`
- `batch.idempotency_key_template: "{bundle_id}.{batch_run_id}"`
- Preserve existing report-level distributor defaults and validation.
- When `notify.distributor.enabled=false`, batch values may remain defaulted but
must not require endpoint/token availability.
- When `notify.distributor.enabled=true` and `batch.enabled=true`, validate:
- non-empty batch pipeline template;
- non-empty batch bundle template;
- non-empty batch idempotency template;
- known template variables only;
- rendered values are non-empty for a representative validation input.
- Supported batch template variables:
- `location_id`
- `batch`
- `batch_run_id`
- `batch_started_date`
- `bundle_id` for idempotency templates only
- Add config rendering helpers, for example:
- `RenderDistributorBatchBundleID`
- `RenderDistributorBatchPipelineID`
- `RenderDistributorBatchIdempotencyKey`
- Keep report-level render helpers unchanged.
- Update `examples/config.yml` only after the config fields are implemented.
Tests:
- Defaults include enabled batch config and expected templates.
- Disabled distributor config accepts default batch settings.
- Enabled distributor config rejects malformed batch templates.
- Unknown batch template variables are rejected.
- Batch idempotency templates can reference `{bundle_id}`.
- Example config loads and includes the batch block if updated in this stage.
Run:
```sh
go test ./internal/config
```
## Stage 2: Batch Notification State Artifacts
Goal: add batch-level notification artifact paths and persistence.
Implementation guidance:
- Add a state-owned batch notification schema version, for example:
`weatherreporter.batch_distributor_notification.v1`.
- Add a `BatchDistributorNotificationArtifact` type with fields matching
`docs/roadmap/batch-distributor.md`:
- schema version;
- batch kind;
- batch run ID;
- attempted time;
- endpoint;
- pipeline ID;
- bundle ID;
- idempotency key;
- bundle created timestamp;
- included report records;
- status;
- upload result;
- run status;
- status error;
- error.
- Add included report record fields:
- report ID;
- RunID;
- source path;
- bundle paths.
- Add state path support for:
```text
notifications/batches/<batch>/<local-date>/<batch_run_id>.distributor.json
```
- Use the batch start date in the effective Weather API/report timezone for the
local date directory.
- Add a save method such as `SaveBatchDistributorNotification`.
- Use existing atomic JSON write helpers.
- Do not change existing per-report metadata JSON shape in this stage.
Tests:
- Batch notification paths use the configured workspace root.
- Morning and evening paths include batch kind, local date, and batch run ID.
- Saved artifact round-trips with included reports, upload result, run status,
raw report JSON, and error fields.
- Empty batch run ID or batch kind fails with actionable errors.
Run:
```sh
go test ./internal/state
```
## Stage 3: Batch Notification App Types And Identity
Goal: add app-owned batch notification request/result types and deterministic
batch run ID helpers.
Implementation guidance:
- Add app-level types, for example:
```go
type BatchNotificationResult struct {
Status string
Reason string
RunID string
PipelineID string
BundleID string
IdempotencyKey string
Path string
IncludedReports []BatchNotificationReport
Error string
}
```
- Add JSON tags matching the target `BatchResult.notification` shape.
- Add `Notification *BatchNotificationResult` to `BatchResult` with
`json:"notification,omitempty"`.
- Add `BatchNotificationReport` with report ID, RunID, source path, and bundle
paths.
- Add an unexported batch run ID helper:
```text
<started_at_utc_timestamp>_<batch>
```
using the same UTC nanosecond timestamp layout as report RunIDs.
- Add unexported batch distributor template values and rendering glue in
`internal/app` or call the new `internal/config` helpers directly with a
narrow value struct.
- Do not call distributor in this stage.
Tests:
- Batch run ID for morning/evening uses UTC timestamp plus batch name.
- Batch result JSON omits `notification` when nil.
- Batch result JSON includes notification fields when populated.
Run:
```sh
go test ./internal/app
```
## Stage 4: Batch Bundle Request Construction
Goal: build a validated multi-file distributor notification request from a
successful batch result.
Implementation guidance:
- Add an unexported app helper that accepts:
- config;
- batch kind;
- batch run ID;
- batch started time;
- successful batch report results;
- the matching resolved/planned reports if needed for valid-period template
values.
- Render batch `pipeline_id`, `bundle_id`, and `idempotency_key` from
`notify.distributor.batch`.
- For each included report, render existing report-level
`report_path_templates` using that report's normal distributor template
values.
- Build one app notification request with multiple file mappings.
- If the current `NotificationRequest` is too report-specific, introduce a
separate unexported batch request type and convert it to
`distributoradapter.UploadRequest` at the notifier boundary.
- Validate before upload:
- no reports included;
- missing source path;
- missing or invalid bundle path;
- duplicate rendered bundle paths across the batch;
- missing batch pipeline, bundle ID, or idempotency key.
- Error context should include report ID, RunID, source path, and bundle path
where useful.
- Do not include token values in errors.
Tests:
- Evening batch with Tomorrow plus two Daily reports renders one request with
all managed report paths.
- Every included report uses its own normal rendered report path templates.
- Duplicate bundle paths fail before upload.
- Missing report path fails before upload.
- Batch ID, bundle ID, pipeline ID, and idempotency key match configured
templates.
Run:
```sh
go test ./internal/app ./internal/config
```
## Stage 5: Suppress Per-Report Batch Notification
Goal: make run commands stop notifying once per report while preserving
single-report notification.
Implementation guidance:
- Add an explicit app-layer mechanism to suppress per-report notification when
`GenerateReport` is called from `RunBatchDetailed`.
- Prefer a private field on `ReportRequest` or a private generation helper over
changing public CLI behavior.
- Ensure `GenerateReport` used by `generate <report>` still notifies exactly as
it does today.
- Suppress per-report notification for all `run morning` and `run evening`
report generation, regardless of whether batch notification is enabled,
disabled, skipped, or fails later.
- Ensure report metadata and report artifacts remain saved normally when
per-report notification is suppressed.
- Do not remove the existing per-report notification implementation because
single-report generation still uses it.
Tests:
- `generate tomorrow` with distributor enabled calls the notifier once.
- `run evening` with distributor enabled does not call the notifier once per
generated report before the batch notification stage is wired in.
- Per-report notification artifacts are not written for batch-generated reports
when batch notification is enabled.
- Existing single-report notification artifact tests still pass.
Run:
```sh
go test ./internal/app ./internal/cli
```
## Stage 6: Batch Notification Orchestration
Goal: upload one distributor bundle after a fully successful batch and persist a
batch notification debug artifact.
Implementation guidance:
- In `RunBatchDetailed`, keep current report generation behavior:
- collect once;
- plan reports;
- continue generating later reports after individual failures;
- record per-report success/failure.
- After report generation finishes:
- if distributor is disabled, leave `BatchResult.Notification` nil;
- if distributor is enabled but `notify.distributor.batch.enabled=false`, set
batch notification status to disabled or omit the notification object, and
do not call distributor for the batch;
- if any report failed, set `BatchResult.Notification` to status `skipped`
with reason `one or more reports failed`, and do not call distributor;
- if all reports succeeded, build and send one batch notification request.
- Use the existing distributor adapter with multiple files.
- Save the batch notification artifact for attempted, succeeded, failed, and
status-error outcomes.
- Persist distributor accepted run ID, upload status, terminal run status, raw
run report JSON, status lookup error, and redacted failure error.
- Batch notification failure should:
- set top-level batch notification status to `failed`;
- increment or otherwise reflect aggregate batch failure consistently with
existing `BatchError` behavior;
- not mark individual report generation items as failed;
- return `BatchError` from `RunBatch` and an error from CLI execution.
Tests:
- All-success morning batch calls notifier exactly once with all report files.
- All-success evening batch calls notifier exactly once with all report files.
- Report failure skips notification and sets top-level notification status
`skipped`.
- Batch notification upload failure returns aggregate batch error and keeps
report item statuses succeeded.
- Batch notification run-status failure records raw status report JSON in the
debug artifact.
- Disabled distributor produces no notification object and no notifier call.
- Batch notification disabled produces no batch notifier call and no per-report
notifier calls from run commands.
Run:
```sh
go test ./internal/app ./internal/state ./internal/adapters/distributor
```
## Stage 7: CLI JSON And Stderr Output
Goal: expose one batch notification result clearly without repeating it on every
report item.
Implementation guidance:
- Extend CLI JSON output naturally through `BatchResult.Notification`.
- Update stderr batch logging:
- keep existing compact per-report lines;
- add one `batchNotification ...` line when notification is attempted,
skipped, or failed;
- do not repeat the same batch notification error on every report line.
- Keep command syntax unchanged.
- Do not add distributor-specific CLI flags.
- Ensure secret values and bearer tokens cannot appear in JSON or stderr.
Tests:
- Batch JSON includes top-level notification fields for success.
- Batch JSON includes top-level skipped notification when a report failed.
- Batch JSON omits notification when distributor is disabled.
- Stderr includes one batch notification line on success/failure/skipped.
- Stderr does not repeat batch notification errors per report.
- Existing help output remains unchanged.
Run:
```sh
go test ./internal/cli ./internal/app
go run ./cmd/weatherreporter --help
```
## Stage 8: Documentation And Examples
Goal: move implemented behavior out of roadmap-only status after the code is in
place.
Implementation guidance:
- Update `docs/config.md` with:
- `notify.distributor.batch.enabled`;
- batch pipeline, bundle ID, and idempotency templates;
- supported batch template variables;
- relationship between report path templates and batch bundle file mappings.
- Update `examples/config.yml` with the batch block and no secrets.
- Update `docs/operations.md` with:
- batch upload ordering;
- skip-all notification policy;
- batch artifact path;
- top-level batch notification JSON behavior.
- Update `docs/troubleshooting.md` with:
- skipped batch upload;
- batch upload failure;
- duplicate bundle path validation;
- distributor source conflicts.
- Update `docs/internal/app-orchestration.md` with batch notification workflow.
- Update `docs/internal/distributor-adapter.md` to clarify that multi-file
upload is supported by the adapter and batch orchestration lives in app.
- Update `docs/internal/state.md` with batch notification artifact paths and
JSON shape.
- Keep future extensions only under `docs/roadmap/`.
Tests/checks:
- Config examples load.
- Non-roadmap docs describe only implemented behavior.
Run:
```sh
go test ./internal/config
git diff --check
```
## Stage 9: Final Validation
Run the full validation set:
```sh
go test ./internal/app ./internal/config ./internal/state ./internal/cli ./internal/adapters/distributor
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Manual checks:
- `weatherreporter generate tomorrow` still uses per-report notification.
- `weatherreporter run morning` sends at most one distributor upload.
- `weatherreporter run evening` sends at most one distributor upload.
- A failed report in a batch skips the batch upload.
- Batch upload source paths are managed report paths, not `--out-dir` copies.
- Batch upload bundle paths are unique.
- No token values appear in errors, batch JSON, notification artifacts, docs, or
examples.
## Deferred Work
- Batch-level durable resume or retry queues.
- Batch-level distributor status inspection command.
- Multiple distributor pipelines within one batch.
- Report-type-specific distributor routing inside batch uploads.
- Uploading data packages, metadata, render contexts, or notification artifacts
in the batch bundle.
- Distributor-specific CLI flags.
- Batch manifest or progress system.
- Changing distributor destination merge semantics from weatherreporter.
## Open Questions
None. The roadmap decisions are sufficient for implementation:
- failed report means skip all batch upload;
- initial batch upload uses one pipeline;
- batch debug artifacts live under `notifications/batches/...`;
- batch JSON uses one top-level `notification` object;
- single-report generation keeps per-report notification.

View File

@@ -300,15 +300,15 @@ Relevant docs: [Configuration reference](config.md),
Symptom: a batch returns nonzero with duplicate bundle path context before a Symptom: a batch returns nonzero with duplicate bundle path context before a
distributor run ID is accepted. distributor run ID is accepted.
Likely cause: `notify.distributor.report_path_templates` rendered the same Likely cause: report-specific distributor path templates rendered the same
bundle-relative path for two included reports in the same batch. bundle-relative path for two included reports in the same batch.
Diagnostic: inspect the error in stdout or stderr. The validation error Diagnostic: inspect the error in stdout or stderr. The validation error
includes the duplicate bundle path plus the report IDs, RunIDs, and managed includes the duplicate bundle path plus the report IDs, RunIDs, and managed
source paths involved. source paths involved.
Safe fix: change `notify.distributor.report_path_templates` so every report in Safe fix: configure a per-report distributor path override so every report in a
a batch renders a unique path. Include values such as `{artifact_group}`, batch renders a unique path. Include values such as `{artifact_group}`,
`{valid_start_date}`, `{batch_output_name}`, or `{run_id}` when needed. `{valid_start_date}`, `{batch_output_name}`, or `{run_id}` when needed.
Relevant docs: [Configuration reference](config.md), Relevant docs: [Configuration reference](config.md),
@@ -411,7 +411,7 @@ Diagnostic: inspect stdout JSON or stderr status lines for
`notificationError` or the top-level batch notification `error`. Confirm `notificationError` or the top-level batch notification `error`. Confirm
`notify.distributor.endpoint`, `notify.distributor.endpoint`,
`notify.distributor.pipeline_id_template`, `notify.distributor.pipeline_id_template`,
`notify.distributor.report_path_templates`, and token configuration. Token report-specific distributor paths, and token configuration. Token
values are redacted from weatherreporter errors. values are redacted from weatherreporter errors.
If the upload was accepted but destination output did not change, inspect the If the upload was accepted but destination output did not change, inspect the

View File

@@ -24,8 +24,6 @@ notify:
pipeline_id_template: "weatherreporter.{report_id}" pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}" bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}" idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates:
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
batch: batch:
enabled: true enabled: true
pipeline_id_template: "weatherreporter" pipeline_id_template: "weatherreporter"
@@ -74,6 +72,10 @@ recent_change:
reports: reports:
daily: daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
@@ -86,9 +88,6 @@ reports:
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- product
- key_messages
- short_term
- long_term - long_term
- spc_convective_discussion - spc_convective_discussion
- weather_story - weather_story

View File

@@ -164,8 +164,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml", DataPackagePath: "/tmp/data_package.hourly.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json", OutputPath: "/tmp/generated_text_raw.hourly.json",
}) })
if err != nil { if err != nil {
t.Fatalf("StructuredRun() error = %v", err) t.Fatalf("StructuredRun() error = %v", err)
@@ -176,8 +176,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.hourly_generated_text", "--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/hourly.data_package.yaml", "--input", "data_package=/tmp/data_package.hourly.yaml",
"--out", "/tmp/hourly.generated_text.raw.json", "--out", "/tmp/generated_text_raw.hourly.json",
} }
if commands.name != "/usr/local/bin/scriptorium" { if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name) t.Fatalf("command name = %q, want custom binary", commands.name)
@@ -199,7 +199,7 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated { if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result) t.Fatalf("result = %#v, want captured output and truncation flags", result)
} }
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" { if result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath) t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
} }
} }
@@ -217,8 +217,8 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml", DataPackagePath: "/tmp/data_package.hourly.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json", OutputPath: "/tmp/generated_text_raw.hourly.json",
}) })
if err == nil { if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error") t.Fatal("StructuredRun() error = nil, want nonzero exit error")
@@ -229,7 +229,7 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
if result.ExitCode != 3 { if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode) t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
} }
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" { if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
t.Fatalf("result = %#v, want captured result fields", result) t.Fatalf("result = %#v, want captured result fields", result)
} }
if !strings.Contains(err.Error(), "structured output failed") { if !strings.Contains(err.Error(), "structured output failed") {

View File

@@ -268,29 +268,33 @@ func (e *NotificationError) Unwrap() error {
} }
func Generate(ctx context.Context, req GenerateRequest) error { func Generate(ctx context.Context, req GenerateRequest) error {
_, err := GenerateDetailed(ctx, req)
return err
}
func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult, error) {
now := req.Now now := req.Now
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
collection, err := collectWeather(ctx, req.Config, req.Collector) collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil { if err != nil {
return err return nil, err
} }
resolved, err := ResolveGenerate(req, now) resolved, err := ResolveGenerate(req, now)
if err != nil { if err != nil {
return err return nil, err
} }
if resolved.Definition.Generated { if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{ return GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Collection: *collection, Collection: *collection,
Notifier: req.Notifier, Notifier: req.Notifier,
}) })
return err
} }
return fmt.Errorf("generate is not implemented") return nil, fmt.Errorf("generate is not implemented")
} }
func RunBatch(ctx context.Context, req BatchRequest) error { func RunBatch(ctx context.Context, req BatchRequest) error {
@@ -654,26 +658,37 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
noNotify: req.noNotify, noNotify: req.noNotify,
}) })
if err != nil { if err != nil {
return nil, err if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), err
} }
return &ReportResult{ return renderedReportResult(reportResultRequest{
ModuleSnapshot: moduleSnapshot, moduleSnapshot: moduleSnapshot,
ModuleSnapshotPath: moduleSnapshotPath, moduleSnapshotPath: moduleSnapshotPath,
DataPackage: dataPackage, dataPackage: dataPackage,
DataPackagePath: dataPackagePath, dataPackagePath: dataPackagePath,
PreflightPath: preflightPath, preflightPath: preflightPath,
ReportPath: reportPath, reportPath: reportPath,
OutputPath: finalized.OutputPath, finalized: finalized,
NotificationPath: finalized.NotificationPath, priorSnapshot: priorSnapshot,
Metadata: finalized.Metadata, recentChanges: recentChanges,
MetadataPath: finalized.MetadataPath, renderResult: renderResult,
PriorSnapshot: priorSnapshot, runResult: runResult,
RecentChanges: recentChanges, }), nil
RenderResult: renderResult,
RunResult: runResult,
Notification: finalized.Notification,
}, nil
} }
type generatedReportRequest struct { type generatedReportRequest struct {
@@ -777,30 +792,97 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
noNotify: req.noNotify, noNotify: req.noNotify,
}) })
if err != nil { if err != nil {
return nil, err if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), err
} }
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), nil
}
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
return result.OutputPath == "" &&
result.NotificationPath == "" &&
result.MetadataPath == "" &&
result.Metadata.RunID == "" &&
result.Notification == nil
}
type reportResultRequest struct {
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
preflightPath string
reportPath string
finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
generatedTextRawPath string
generatedTextResultPath string
generatedTextPath string
renderContextPath string
}
func renderedReportResult(req reportResultRequest) *ReportResult {
return &ReportResult{ return &ReportResult{
ModuleSnapshot: req.moduleSnapshot, ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath, ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage, DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath, DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath, PreflightPath: req.preflightPath,
ReportPath: reportPath, ReportPath: req.reportPath,
OutputPath: finalized.OutputPath, OutputPath: req.finalized.OutputPath,
NotificationPath: finalized.NotificationPath, NotificationPath: req.finalized.NotificationPath,
Metadata: finalized.Metadata, Metadata: req.finalized.Metadata,
MetadataPath: finalized.MetadataPath, MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot, PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
RenderResult: req.renderResult, RenderResult: req.renderResult,
StructuredRunResult: structuredResult, RunResult: req.runResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw, StructuredRunResult: req.structuredRunResult,
GeneratedTextResultPath: generatedTextResultPath, GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextPath: generatedTextPath, GeneratedTextResultPath: req.generatedTextResultPath,
RenderContextPath: renderContextPath, GeneratedTextPath: req.generatedTextPath,
Notification: finalized.Notification, RenderContextPath: req.renderContextPath,
}, nil Notification: req.finalized.Notification,
}
} }
type finalizeRenderedReportRequest struct { type finalizeRenderedReportRequest struct {
@@ -934,7 +1016,7 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values) bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values)
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
@@ -964,9 +1046,45 @@ func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resol
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil { if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return config.DistributorTemplateValues{}, err return config.DistributorTemplateValues{}, err
} }
if resolved.Definition.ID == report.Storm {
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
}
return values, nil return values, nil
} }
func renderDistributorReportBundlePaths(cfg config.Config, resolved report.Resolved, runID string, sourcePath string, values config.DistributorTemplateValues) ([]string, error) {
templates, name, err := distributorPathTemplatesForReport(cfg, resolved.Definition)
if err != nil {
return nil, distributorReportPathError(resolved.Definition.ID, runID, sourcePath, err)
}
paths, err := config.RenderDistributorReportPaths(name, templates, values)
if err != nil {
return nil, distributorReportPathError(resolved.Definition.ID, runID, sourcePath, err)
}
return paths, nil
}
func distributorPathTemplatesForReport(cfg config.Config, definition report.Definition) ([]string, string, error) {
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
return nil, "", err
}
if templates, ok := overrides[definition.ID]; ok {
return append([]string(nil), templates...), fmt.Sprintf("reports.%s.distributor.path_templates", definition.ID), nil
}
if len(definition.DistributorPathTemplates) > 0 {
return append([]string(nil), definition.DistributorPathTemplates...), fmt.Sprintf("report.%s.distributor_path_templates", definition.ID), nil
}
return nil, "", fmt.Errorf("no distributor path templates configured")
}
func distributorReportPathError(id report.ID, runID string, sourcePath string, err error) error {
if sourcePath != "" {
return fmt.Errorf("report %q run %q source path %q: %w", id, runID, sourcePath, err)
}
return fmt.Errorf("report %q run %q: %w", id, runID, err)
}
func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error { func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error {
location, err := timeutil.LoadLocation(timezone) location, err := timeutil.LoadLocation(timezone)
if err != nil { if err != nil {

View File

@@ -168,6 +168,97 @@ func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
} }
} }
func TestGenerateDetailedReturnsReportResult(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
collection := collectionForTest(t, cfg)
collector := &recordingCollector{result: &collection}
outputPath := filepath.Join(t.TempDir(), "three-day.md")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
OutputPath: outputPath,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
}
if result == nil {
t.Fatal("GenerateDetailed() result = nil, want report result")
}
if result.Metadata.ReportID != report.ThreeDay || result.Metadata.RunID == "" {
t.Fatalf("metadata = %#v, want 3-day report metadata with run id", result.Metadata)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection", len(collector.requests))
}
}
func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir()
wantErr := errors.New("collector unavailable")
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: &recordingCollector{err: wantErr},
})
if !errors.Is(err, wantErr) {
t.Fatalf("Generate() error = %v, want underlying collector error", err)
}
}
func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
collection := collectionForTest(t, cfg)
notifier := &recordingNotifier{err: errors.New("upload rejected")}
outputPath := filepath.Join(t.TempDir(), "hourly.md")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportHourly,
OutputPath: outputPath,
Now: mustParse("2026-05-29T08:30:00-05:00"),
Collector: &recordingCollector{result: &collection},
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateDetailed() error = nil, want notification error")
}
var notificationErr *NotificationError
if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateDetailed() error = %T %v, want NotificationError", err, err)
}
if result == nil {
t.Fatal("GenerateDetailed() result = nil, want inspectable result on notification failure")
}
if result.Metadata.ReportID != report.Hourly || result.Metadata.NotificationPath != result.NotificationPath {
t.Fatalf("metadata = %#v notificationPath=%q, want hourly notification artifact link", result.Metadata, result.NotificationPath)
}
if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" {
t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath)
if result.Notification != nil {
t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification)
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests))
}
}
func TestGenerateReportWritesReportAndPreflight(t *testing.T) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
@@ -255,7 +346,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
} }
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") { if !strings.HasPrefix(filepath.Base(result.DataPackagePath), "data_package.") || !strings.HasSuffix(result.DataPackagePath, ".yaml") {
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath) t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
} }
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") ||
@@ -357,8 +448,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !ok || story["title"] != "Several Chances for Rain Through Monday" { if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"]) t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
} }
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { if !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data)) t.Fatalf("data package missing daily long-term AFD discussion:\n%s", string(data))
}
for _, omitted := range []string{"Short-term AFD narrative for generated report.", "Storms are most likely during the morning."} {
if strings.Contains(string(data), omitted) {
t.Fatalf("data package contains daily omitted AFD field %q:\n%s", omitted, string(data))
}
} }
preflight, err := os.ReadFile(result.PreflightPath) preflight, err := os.ReadFile(result.PreflightPath)
if err != nil { if err != nil {
@@ -739,7 +835,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
if req.ReportPath == outputPath { if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath) t.Fatalf("notification used output copy %q, want managed report path", outputPath)
} }
wantBundlePaths := []string{"2026-05-29/hourly/hourly.md"} wantBundlePaths := []string{"hourly/index.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
} }
@@ -778,7 +874,6 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}" cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}" cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}" cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday, Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
@@ -834,7 +929,11 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
if req.BundleID != "today.today.md.today" { if req.BundleID != "today.today.md.today" {
t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID) t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID)
} }
wantBundlePaths := []string{"2026-05-29/today/today.md"} wantBundlePaths := []string{
"daily/2026-05-29/" + result.Metadata.RunID + ".md",
"daily/2026-05-29/index.md",
"today/index.md",
}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
} }
@@ -981,7 +1080,6 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}" cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}" cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}" cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow, Report: ReportTomorrow,
}, "2026-05-29T18:00:00-05:00") }, "2026-05-29T18:00:00-05:00")
@@ -1011,7 +1109,11 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
if req.BundleID != "tomorrow.tomorrow.md.tomorrow" { if req.BundleID != "tomorrow.tomorrow.md.tomorrow" {
t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID) t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID)
} }
wantBundlePaths := []string{"2026-05-30/tomorrow/tomorrow.md"} wantBundlePaths := []string{
"daily/2026-05-30/" + result.Metadata.RunID + ".md",
"daily/2026-05-30/index.md",
"tomorrow/index.md",
}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
} }
@@ -1234,7 +1336,8 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
t.Fatalf("decode notification artifact: %v", err) t.Fatalf("decode notification artifact: %v", err)
} }
wantBundlePaths := []string{ wantBundlePaths := []string{
"2026-05-29/daily/2026-05-29-daily-" + result.Metadata.RunID + ".md", "daily/2026-05-29/" + result.Metadata.RunID + ".md",
"daily/2026-05-29/index.md",
} }
if notificationArtifact.PipelineID != "weatherreporter.daily" || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.BundleCreated.IsZero() || notificationArtifact.RunStatus == nil || !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") { if notificationArtifact.PipelineID != "weatherreporter.daily" || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.BundleCreated.IsZero() || notificationArtifact.RunStatus == nil || !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
t.Fatalf("notification artifact = %#v, want requested pipeline, status report, and created timestamp", notificationArtifact) t.Fatalf("notification artifact = %#v, want requested pipeline, status report, and created timestamp", notificationArtifact)
@@ -2214,6 +2317,254 @@ func TestResolveGenerateStorm(t *testing.T) {
} }
} }
func TestDistributorTemplateValuesDeriveStormID(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T12:00:00-05:00")
start := mustParse("2026-05-29T18:00:00-05:00")
end := mustParse("2026-05-30T06:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportStorm,
StormStart: start,
StormEnd: end,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
values, err := distributorTemplateValuesForReport(cfg, resolved, "run", "")
if err != nil {
t.Fatalf("distributorTemplateValuesForReport() error = %v", err)
}
if values.StormID != "2026-05-29T1800-2026-05-30T0600" {
t.Fatalf("StormID = %q, want storm valid-period stamp", values.StormID)
}
}
func TestDistributorTemplateValuesLeaveStormIDEmptyForOtherReports(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
t.Fatalf("load location: %v", err)
}
resolved, err := report.Resolve(report.Today, report.ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
values, err := distributorTemplateValuesForReport(cfg, resolved, "run", "")
if err != nil {
t.Fatalf("distributorTemplateValuesForReport() error = %v", err)
}
if values.StormID != "" {
t.Fatalf("StormID = %q, want empty for %s", values.StormID, resolved.Definition.ID)
}
}
func TestBuildNotificationRequestUsesReportDefaultBundlePaths(t *testing.T) {
cfg := config.Defaults()
cfg.Location.ID = "home"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}"
location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)
now := mustParse("2026-05-29T12:00:00-05:00")
registry := report.DefaultRegistry()
tests := []struct {
id report.ID
req report.ResolveRequest
want func(state.Metadata) []string
source string
}{
{
id: report.Hourly,
req: report.ResolveRequest{
Now: now,
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{"hourly/index.md"}
},
source: "/managed/hourly.md",
},
{
id: report.Daily,
req: report.ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-05-29T12:00:00-05:00"),
},
want: func(metadata state.Metadata) []string {
return []string{
"daily/2026-05-29/" + metadata.RunID + ".md",
"daily/2026-05-29/index.md",
}
},
source: "/managed/daily.md",
},
{
id: report.Today,
req: report.ResolveRequest{
Now: now,
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{
"daily/2026-05-29/" + metadata.RunID + ".md",
"daily/2026-05-29/index.md",
"today/index.md",
}
},
source: "/managed/today.md",
},
{
id: report.Tomorrow,
req: report.ResolveRequest{
Now: now,
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{
"daily/2026-05-30/" + metadata.RunID + ".md",
"daily/2026-05-30/index.md",
"tomorrow/index.md",
}
},
source: "/managed/tomorrow.md",
},
{
id: report.ThreeDay,
req: report.ResolveRequest{
Now: now,
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{
"three-day/2026-05-29/" + metadata.RunID + ".md",
"three-day/2026-05-29/index.md",
}
},
source: "/managed/three-day.md",
},
{
id: report.Weekend,
req: report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
},
want: func(metadata state.Metadata) []string {
return []string{
"weekend/2026-05-29/" + metadata.RunID + ".md",
"weekend/2026-05-29/index.md",
}
},
source: "/managed/weekend.md",
},
{
id: report.Storm,
req: report.ResolveRequest{
Now: now,
Location: location,
StormStart: mustParse("2026-05-29T18:00:00-05:00"),
StormEnd: mustParse("2026-05-30T06:00:00-05:00"),
},
want: func(metadata state.Metadata) []string {
return []string{
"storm/2026-05-29T1800-2026-05-30T0600/" + metadata.RunID + ".md",
"storm/2026-05-29T1800-2026-05-30T0600/index.md",
}
},
source: "/managed/storm.md",
},
}
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
resolved, err := registry.Resolve(tt.id, tt.req)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := notificationMetadataForTest(resolved)
req, err := buildNotificationRequest(cfg, resolved, tt.source, metadata)
if err != nil {
t.Fatalf("buildNotificationRequest() error = %v", err)
}
want := tt.want(metadata)
if strings.Join(req.BundlePaths, "\n") != strings.Join(want, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, want)
}
if req.ReportPath != tt.source {
t.Fatalf("ReportPath = %q, want %q", req.ReportPath, tt.source)
}
})
}
}
func TestBuildNotificationRequestUsesReportDistributorPathOverride(t *testing.T) {
cfg := config.Defaults()
cfg.Location.ID = "home"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}"
applyReportDistributorPathOverrides(t, &cfg, `
reports:
daily:
distributor:
path_templates:
- "custom/{report_id}/{run_id}.md"
`)
location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)
resolved, err := report.Resolve(report.Daily, report.ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
Date: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := notificationMetadataForTest(resolved)
req, err := buildNotificationRequest(cfg, resolved, "/managed/daily.md", metadata)
if err != nil {
t.Fatalf("buildNotificationRequest() error = %v", err)
}
want := []string{"custom/daily/" + metadata.RunID + ".md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(want, "\n") {
t.Fatalf("BundlePaths = %#v, want override %#v", req.BundlePaths, want)
}
}
func TestBuildNotificationRequestRequiresReportBundlePaths(t *testing.T) {
cfg := config.Defaults()
cfg.Location.ID = "home"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}"
location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)
resolved, err := report.Resolve(report.Hourly, report.ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
resolved.Definition.DistributorPathTemplates = nil
metadata := notificationMetadataForTest(resolved)
_, err = buildNotificationRequest(cfg, resolved, "/managed/hourly.md", metadata)
if err == nil {
t.Fatal("buildNotificationRequest() error = nil, want missing path templates error")
}
for _, want := range []string{`report "hourly"`, metadata.RunID, `/managed/hourly.md`, "no distributor path templates"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want %q", err.Error(), want)
}
}
}
func TestBatchRunIDUsesUTCStartAndBatchName(t *testing.T) { func TestBatchRunIDUsesUTCStartAndBatchName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -2293,12 +2644,12 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
PipelineID: "weatherreporter", PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.evening", BundleID: "weatherreporter.home.evening",
IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening", IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening",
Path: "notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json", Path: "notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json",
IncludedReports: []BatchNotificationReport{ IncludedReports: []BatchNotificationReport{
{ {
ReportID: report.Tomorrow, ReportID: report.Tomorrow,
RunID: "20260529T233000.000000000Z_tomorrow", RunID: "20260529T233000.000000000Z_tomorrow",
SourcePath: "reports/tomorrow.md", SourcePath: "reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md",
BundlePaths: []string{"tomorrow/index.md"}, BundlePaths: []string{"tomorrow/index.md"},
}, },
}, },
@@ -2318,10 +2669,10 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
`"pipelineId":"weatherreporter"`, `"pipelineId":"weatherreporter"`,
`"bundleId":"weatherreporter.home.evening"`, `"bundleId":"weatherreporter.home.evening"`,
`"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`, `"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`,
`"path":"notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json"`, `"path":"notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json"`,
`"includedReports":[`, `"includedReports":[`,
`"reportId":"tomorrow"`, `"reportId":"tomorrow"`,
`"sourcePath":"reports/tomorrow.md"`, `"sourcePath":"reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md"`,
`"bundlePaths":["tomorrow/index.md"]`, `"bundlePaths":["tomorrow/index.md"]`,
} { } {
if !strings.Contains(string(data), want) { if !strings.Contains(string(data), want) {
@@ -2333,10 +2684,6 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
func TestBuildBatchNotificationRequestIncludesEveningReports(t *testing.T) { func TestBuildBatchNotificationRequestIncludesEveningReports(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server) cfg := dailyNotificationConfig(t, server)
cfg.Notify.Distributor.ReportPathTemplates = []string{
"archive/{valid_start_date}/{artifact_group}/{run_id}.md",
"latest/{batch_output_name}",
}
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch}.{batch_started_date}" cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch}.{batch_started_date}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "weatherreporter.{location_id}.{batch}" cfg.Notify.Distributor.Batch.BundleIDTemplate = "weatherreporter.{location_id}.{batch}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}" cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}"
@@ -2368,20 +2715,26 @@ func TestBuildBatchNotificationRequestIncludesEveningReports(t *testing.T) {
if len(req.IncludedReports) != 3 { if len(req.IncludedReports) != 3 {
t.Fatalf("IncludedReports = %d, want 3", len(req.IncludedReports)) t.Fatalf("IncludedReports = %d, want 3", len(req.IncludedReports))
} }
if len(req.Files) != 6 { if len(req.Files) != 7 {
t.Fatalf("Files = %d, want two mappings per report", len(req.Files)) t.Fatalf("Files = %d, want report-specific mappings", len(req.Files))
} }
wantBundlePaths := map[string]struct{}{} wantBundlePaths := map[string]struct{}{}
for _, plannedReport := range planned { for _, plannedReport := range planned {
resolved := plannedReport.Resolved resolved := plannedReport.Resolved
runID := resolved.Metadata().RunID runID := resolved.Metadata().RunID
outputName := plannedReport.OutputCopyName validStart := resolved.ValidPeriod.Start.In(mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)).Format(timeutil.DateLayout)
if outputName == "" { switch resolved.Definition.ID {
outputName = resolved.Definition.BatchOutputName case report.Tomorrow:
wantBundlePaths["daily/"+validStart+"/"+runID+".md"] = struct{}{}
wantBundlePaths["daily/"+validStart+"/index.md"] = struct{}{}
wantBundlePaths["tomorrow/index.md"] = struct{}{}
case report.Daily:
wantBundlePaths["daily/"+validStart+"/"+runID+".md"] = struct{}{}
wantBundlePaths["daily/"+validStart+"/index.md"] = struct{}{}
default:
t.Fatalf("unexpected planned report %s", resolved.Definition.ID)
} }
wantBundlePaths[fmt.Sprintf("archive/%s/%s/%s.md", resolved.ValidPeriod.Start.In(mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)).Format(timeutil.DateLayout), resolved.Definition.ArtifactGroup, runID)] = struct{}{}
wantBundlePaths["latest/"+outputName] = struct{}{}
} }
gotBundlePaths := map[string]struct{}{} gotBundlePaths := map[string]struct{}{}
gotSourcePaths := map[string]struct{}{} gotSourcePaths := map[string]struct{}{}
@@ -2409,10 +2762,67 @@ func TestBuildBatchNotificationRequestIncludesEveningReports(t *testing.T) {
} }
} }
func TestBuildBatchNotificationRequestUsesReportOverridesAndDefaults(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server)
applyReportDistributorPathOverrides(t, &cfg, `
reports:
daily:
distributor:
path_templates:
- "custom-daily/{valid_start_date}/{run_id}.md"
`)
startedAt := mustParse("2026-05-29T18:00:00-05:00")
planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31")
runID := batchRunID(startedAt, BatchEvening)
req, err := buildBatchNotificationRequest(cfg, BatchEvening, runID, startedAt, reports, planned)
if err != nil {
t.Fatalf("buildBatchNotificationRequest() error = %v", err)
}
gotBundlePaths := map[string]struct{}{}
for _, file := range req.Files {
gotBundlePaths[file.BundlePath] = struct{}{}
}
for _, plannedReport := range planned {
reportRunID := plannedReport.Resolved.Metadata().RunID
switch plannedReport.Resolved.Definition.ID {
case report.Tomorrow:
for _, want := range []string{
"daily/2026-05-30/" + reportRunID + ".md",
"daily/2026-05-30/index.md",
"tomorrow/index.md",
} {
if _, ok := gotBundlePaths[want]; !ok {
t.Fatalf("bundle paths = %#v, missing default path %q", gotBundlePaths, want)
}
}
case report.Daily:
want := "custom-daily/2026-05-31/" + reportRunID + ".md"
if _, ok := gotBundlePaths[want]; !ok {
t.Fatalf("bundle paths = %#v, missing override path %q", gotBundlePaths, want)
}
if _, ok := gotBundlePaths["daily/2026-05-31/index.md"]; ok {
t.Fatalf("bundle paths = %#v, want daily defaults replaced by override", gotBundlePaths)
}
}
}
}
func TestBuildBatchNotificationRequestRejectsDuplicateBundlePaths(t *testing.T) { func TestBuildBatchNotificationRequestRejectsDuplicateBundlePaths(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server) cfg := dailyNotificationConfig(t, server)
cfg.Notify.Distributor.ReportPathTemplates = []string{"index.md"} applyReportDistributorPathOverrides(t, &cfg, `
reports:
tomorrow:
distributor:
path_templates:
- "index.md"
daily:
distributor:
path_templates:
- "index.md"
`)
startedAt := mustParse("2026-05-29T18:00:00-05:00") startedAt := mustParse("2026-05-29T18:00:00-05:00")
planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31") planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31")
@@ -2537,16 +2947,13 @@ func TestRunBatchMorningSendsOneBatchNotification(t *testing.T) {
if req.Batch != BatchMorning || req.RunID != "20260529T100000.000000000Z_morning" { if req.Batch != BatchMorning || req.RunID != "20260529T100000.000000000Z_morning" {
t.Fatalf("batch request identity = %s/%s, want morning run id", req.Batch, req.RunID) t.Fatalf("batch request identity = %s/%s, want morning run id", req.Batch, req.RunID)
} }
if len(req.IncludedReports) != 3 || len(req.Files) != 3 { if len(req.IncludedReports) != 3 || len(req.Files) != 8 {
t.Fatalf("batch request reports/files = %d/%d, want 3/3", len(req.IncludedReports), len(req.Files)) t.Fatalf("batch request reports/files = %d/%d, want 3/8", len(req.IncludedReports), len(req.Files))
} }
for _, file := range req.Files { for _, file := range req.Files {
if file.SourcePath == "" || file.BundlePath == "" { if file.SourcePath == "" || file.BundlePath == "" {
t.Fatalf("batch file = %#v, want source and bundle path", file) t.Fatalf("batch file = %#v, want source and bundle path", file)
} }
if !strings.Contains(file.BundlePath, file.RunID) {
t.Fatalf("bundle path %q does not include report run id %q", file.BundlePath, file.RunID)
}
} }
if result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.RunID != "batch-distributor-run" || result.Notification.Path == "" { if result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.RunID != "batch-distributor-run" || result.Notification.Path == "" {
t.Fatalf("batch notification = %#v, want succeeded result with artifact path", result.Notification) t.Fatalf("batch notification = %#v, want succeeded result with artifact path", result.Notification)
@@ -2998,6 +3405,36 @@ func dailyNotificationConfig(t *testing.T, server *httptest.Server) config.Confi
return cfg return cfg
} }
func notificationMetadataForTest(resolved report.Resolved) state.Metadata {
metadata := resolved.Metadata()
return state.Metadata{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod,
}
}
func applyReportDistributorPathOverrides(t *testing.T, cfg *config.Config, data string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
loaded, err := config.LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Reports == nil {
cfg.Reports = map[string]config.ReportConfig{}
}
for key, reportCfg := range loaded.Reports {
cfg.Reports[key] = reportCfg
}
}
func plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch BatchKind, now time.Time, futureDailyDates ...string) ([]plannedBatchReport, []BatchReportResult) { func plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch BatchKind, now time.Time, futureDailyDates ...string) ([]plannedBatchReport, []BatchReportResult) {
t.Helper() t.Helper()
collection := collectionWithFutureDailyForTest(t, cfg, futureDailyDates...) collection := collectionWithFutureDailyForTest(t, cfg, futureDailyDates...)
@@ -3005,15 +3442,22 @@ func plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch Batc
if err != nil { if err != nil {
t.Fatalf("planBatchRun() error = %v", err) t.Fatalf("planBatchRun() error = %v", err)
} }
reportDir := filepath.Join(t.TempDir(), "managed-reports") store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
results := make([]BatchReportResult, 0, len(planned)) results := make([]BatchReportResult, 0, len(planned))
for _, item := range planned { for _, item := range planned {
metadata := item.Resolved.Metadata() metadata := item.Resolved.Metadata()
paths, err := store.Paths(item.Resolved)
if err != nil {
t.Fatalf("Paths(%s) error = %v", item.Resolved.Definition.ID, err)
}
results = append(results, BatchReportResult{ results = append(results, BatchReportResult{
ReportID: item.Resolved.Definition.ID, ReportID: item.Resolved.Definition.ID,
RunID: metadata.RunID, RunID: metadata.RunID,
Status: "succeeded", Status: "succeeded",
ReportPath: filepath.Join(reportDir, string(item.Resolved.Definition.ID), metadata.RunID+".md"), ReportPath: paths.RenderedReport,
}) })
} }
return planned, results return planned, results
@@ -3140,7 +3584,6 @@ func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}" cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}" cfg.Notify.Distributor.IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
} }
func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) { func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) {
@@ -3149,6 +3592,66 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md") return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
} }
func fakeScriptoriumBinary(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "scriptorium")
script := `#!/bin/sh
set -eu
command_name="${1:-}"
shift || true
prompt=""
output=""
while [ "$#" -gt 0 ]; do
case "$1" in
--prompt)
shift
prompt="${1:-}"
;;
--out)
shift
output="${1:-}"
;;
esac
shift || true
done
case "$command_name" in
render)
printf '{"prepared":true}\n'
;;
run)
if [ -z "$output" ]; then
printf 'missing output path\n' >&2
exit 2
fi
case "$prompt" in
weather.hourly_generated_text)
cat > "$output" <<'EOF'
{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}
EOF
;;
*)
cat > "$output" <<'EOF'
# Generated Report
Prepared report body.
EOF
;;
esac
;;
*)
printf 'unknown command: %s\n' "$command_name" >&2
exit 2
;;
esac
`
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake scriptorium binary: %v", err)
}
return path
}
func validHourlyGeneratedTextJSON() string { func validHourlyGeneratedTextJSON() string {
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
} }

View File

@@ -161,9 +161,9 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
if err != nil { if err != nil {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err) return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
} }
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values) bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
if err != nil { if err != nil {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err) return batchNotificationRequest{}, err
} }
included := BatchNotificationReport{ included := BatchNotificationReport{

View File

@@ -506,6 +506,34 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
} }
} }
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("daily default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
t.Fatalf("AFD = %#v, want only long term section", afd)
}
}
func testModuleContext() ModuleContext { func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.Daily) definition := report.DefaultRegistry().MustLookup(report.Daily)

View File

@@ -263,24 +263,56 @@ func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
} }
} }
func TestHourlyDefaultModuleOptions(t *testing.T) { func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.Hourly) tests := []struct {
var found bool id report.ID
for _, item := range definition.Modules { wantSections string
if item.ID != module.AreaForecastDiscussion { }{
continue {id: report.Daily, wantSections: "long_term"},
} {id: report.Hourly, wantSections: "key_messages,short_term"},
found = true
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
}
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
}
} }
if !found {
t.Fatal("hourly default modules missing area_forecast_discussion") registry := report.DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition := registry.MustLookup(tt.id)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
}
if strings.Join(options.Sections, ",") != tt.wantSections {
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
}
}
if !found {
t.Fatal("default modules missing area_forecast_discussion")
}
})
}
for _, id := range []report.ID{report.Today, report.Tomorrow} {
t.Run(string(id), func(t *testing.T) {
definition := registry.MustLookup(id)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
if item.Options != nil {
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
}
}
if !found {
t.Fatal("default modules missing area_forecast_discussion")
}
})
} }
} }

75
internal/cli/output.go Normal file
View File

@@ -0,0 +1,75 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
)
type outputOptions struct {
Quiet bool
}
func writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error {
if opts.Quiet {
return nil
}
if writeStatus != nil && stderr != nil {
writeStatus(stderr)
}
return writeJSON(stdout, value)
}
func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
if stderr == nil || result == nil {
return
}
for _, item := range result.Reports {
notificationFields := ""
if item.NotificationStatus != "" {
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
}
if item.NotificationRunID != "" {
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
}
if item.NotificationError != "" {
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
}
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
}
if result.Notification != nil {
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
if result.Notification.Reason != "" {
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
}
if result.Notification.RunID != "" {
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
}
if result.Notification.PipelineID != "" {
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
}
if result.Notification.BundleID != "" {
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
}
if result.Notification.Path != "" {
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
}
if result.Notification.Error != "" {
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
}
_, _ = fmt.Fprintln(stderr)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}

View File

@@ -0,0 +1,59 @@
package cli
import (
"bytes"
"io"
"strings"
"testing"
)
func TestWriteActionResultWritesStatusBeforeJSON(t *testing.T) {
var output bytes.Buffer
err := writeActionResult(&output, &output, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
_, _ = w.Write([]byte("status line\n"))
})
if err != nil {
t.Fatalf("writeActionResult() error = %v", err)
}
if !strings.HasPrefix(output.String(), "status line\n") {
t.Fatalf("output = %q, want status before JSON", output.String())
}
if !strings.Contains(output.String(), `"status": "succeeded"`) {
t.Fatalf("output missing JSON result:\n%s", output.String())
}
}
func TestWriteActionResultQuietSuppressesOutput(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := writeActionResult(&stdout, &stderr, map[string]string{"status": "succeeded"}, outputOptions{Quiet: true}, func(w io.Writer) {
_, _ = w.Write([]byte("status line\n"))
})
if err != nil {
t.Fatalf("writeActionResult() error = %v", err)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("stdout/stderr = %q/%q, want no output", stdout.String(), stderr.String())
}
}
func TestWriteActionResultToleratesNilStderr(t *testing.T) {
var stdout bytes.Buffer
statusCalled := false
err := writeActionResult(&stdout, nil, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
statusCalled = true
_, _ = w.Write([]byte("status line\n"))
})
if err != nil {
t.Fatalf("writeActionResult() error = %v", err)
}
if statusCalled {
t.Fatal("status writer was called with nil stderr")
}
if !strings.Contains(stdout.String(), `"status": "succeeded"`) {
t.Fatalf("stdout missing JSON result:\n%s", stdout.String())
}
}

162
internal/cli/result.go Normal file
View File

@@ -0,0 +1,162 @@
package cli
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const (
commandGenerate = "generate"
commandRun = "run"
summaryStatusSucceeded = "succeeded"
summaryStatusFailed = "failed"
)
type generateSummary struct {
Command string `json:"command"`
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
Notification *generateNotificationSummary `json:"notification,omitempty"`
Error string `json:"error,omitempty"`
}
type generateNotificationSummary struct {
Status string `json:"status,omitempty"`
UploadStatus string `json:"uploadStatus,omitempty"`
StatusError string `json:"statusError,omitempty"`
RunID string `json:"runId,omitempty"`
PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
}
type batchSummary struct {
Command string `json:"command"`
Batch app.BatchKind `json:"batch"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
Reports []app.BatchReportResult `json:"reports"`
Error string `json:"error,omitempty"`
}
func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
summary := generateSummary{Command: commandGenerate}
if result == nil {
return summary
}
metadata := result.Metadata
summary.ReportID = metadata.ReportID
summary.ReportName = reportName(metadata.ReportID)
summary.PromptID = metadata.PromptID
summary.RunID = metadata.RunID
summary.Status = summaryStatusSucceeded
summary.GeneratedAt = metadata.GeneratedAt
summary.ValidPeriod = metadata.ValidPeriod
summary.ReportPath = result.ReportPath
summary.OutputPath = result.OutputPath
summary.MetadataPath = result.MetadataPath
summary.DataPackagePath = result.DataPackagePath
summary.PreflightPath = result.PreflightPath
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
summary.GeneratedTextPath = result.GeneratedTextPath
summary.RenderContextPath = result.RenderContextPath
summary.NotificationPath = result.NotificationPath
summary.Notification = newGenerateNotificationSummary(result.Notification)
if err != nil {
summary.Status = summaryStatusFailed
summary.Error = err.Error()
}
return summary
}
func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary {
if result == nil {
return nil
}
summary := &generateNotificationSummary{
Status: result.Status,
UploadStatus: result.UploadStatus,
StatusError: result.StatusError,
RunID: result.RunID,
PipelineID: result.PipelineID,
BundleID: result.BundleID,
IdempotencyKey: result.IdempotencyKey,
StartedAt: result.StartedAt,
FinishedAt: result.FinishedAt,
Error: result.Error,
}
if !result.AcceptedAt.IsZero() {
acceptedAt := result.AcceptedAt
summary.AcceptedAt = &acceptedAt
}
return summary
}
func newBatchSummary(result *app.BatchResult) batchSummary {
summary := batchSummary{Command: commandRun}
if result == nil {
return summary
}
summary.Batch = result.Batch
summary.Status = batchSummaryStatus(result)
summary.StartedAt = result.StartedAt
summary.FinishedAt = result.FinishedAt
summary.Total = result.Total
summary.Succeeded = result.Succeeded
summary.Failed = result.Failed
summary.Notification = result.Notification
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
if summary.Status == summaryStatusFailed {
summary.Error = app.BatchError{Result: result}.Error()
}
return summary
}
func batchSummaryStatus(result *app.BatchResult) string {
if result == nil {
return ""
}
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
return summaryStatusFailed
}
return summaryStatusSucceeded
}
func reportName(id report.ID) string {
definition, err := report.DefaultRegistry().Lookup(id)
if err != nil {
return string(id)
}
return definition.Name
}

231
internal/cli/result_test.go Normal file
View File

@@ -0,0 +1,231 @@
package cli
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
acceptedAt := generatedAt.Add(time.Minute)
startedAt := acceptedAt.Add(time.Minute)
finishedAt := startedAt.Add(time.Minute)
result := &app.ReportResult{
DataPackagePath: "/runs/hourly/data_package.yaml",
PreflightPath: "/runs/hourly/preflight.json",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
GeneratedTextPath: "/runs/hourly/generated_text.json",
RenderContextPath: "/runs/hourly/render_context.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
RunID: "20260529T133000Z_hourly",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
Notification: &app.NotificationResult{
Status: "succeeded",
UploadStatus: "accepted",
RunID: "distributor-run",
PipelineID: "weatherreporter.hourly",
BundleID: "weatherreporter.home.hourly",
IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly",
AcceptedAt: acceptedAt,
StartedAt: &startedAt,
FinishedAt: &finishedAt,
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
},
}
summary := newGenerateSummary(result, nil)
if summary.Command != "generate" || summary.Status != "succeeded" {
t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status)
}
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
}
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
}
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification)
}
data, err := json.Marshal(summary)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
}
}
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{
DataPackagePath: "/runs/three-day/data_package.yaml",
PreflightPath: "/runs/three-day/preflight.json",
ReportPath: "/runs/three-day/report.md",
OutputPath: "/copies/three-day.md",
MetadataPath: "/runs/three-day/metadata.json",
Metadata: state.Metadata{
ReportID: report.ThreeDay,
PromptID: "weather.three_day_outlook",
RunID: "20260529T133000Z_three_day",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
}
summary := newGenerateSummary(result, nil)
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
}
if summary.Notification != nil || summary.NotificationPath != "" {
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
}
data, err := json.Marshal(summary)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
if strings.Contains(string(data), omitted) {
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
}
}
}
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{
DataPackagePath: "/runs/hourly/data_package.yaml",
PreflightPath: "/runs/hourly/preflight.json",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
RunID: "20260529T133000Z_hourly",
GeneratedAt: generatedAt,
ValidPeriod: testSummaryPeriod(generatedAt),
},
}
err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`)
summary := newGenerateSummary(result, err)
if summary.Status != "failed" || summary.Error != err.Error() {
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
}
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" {
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
}
}
func TestNewBatchSummaryStatusDerivation(t *testing.T) {
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
finishedAt := startedAt.Add(2 * time.Minute)
tests := []struct {
name string
result *app.BatchResult
wantStatus string
wantError string
}{
{
name: "success",
result: &app.BatchResult{
Batch: app.BatchMorning,
StartedAt: startedAt,
FinishedAt: finishedAt,
Total: 1,
Succeeded: 1,
Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}},
},
wantStatus: "succeeded",
},
{
name: "report failure",
result: &app.BatchResult{
Batch: app.BatchMorning,
Total: 2,
Succeeded: 1,
Failed: 1,
Reports: []app.BatchReportResult{
{ReportID: report.Today, Status: "succeeded"},
{ReportID: report.Tomorrow, Status: "failed", Error: "render failed"},
},
},
wantStatus: "failed",
wantError: "batch morning failed: 1 of 2 reports failed",
},
{
name: "skipped notification",
result: &app.BatchResult{
Batch: app.BatchEvening,
Total: 2,
Succeeded: 1,
Failed: 1,
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}},
Notification: &app.BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
},
},
wantStatus: "failed",
wantError: "batch evening failed: 1 of 2 reports failed",
},
{
name: "failed notification",
result: &app.BatchResult{
Batch: app.BatchEvening,
Total: 1,
Succeeded: 1,
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}},
Notification: &app.BatchNotificationResult{
Status: "failed",
Error: "notify batch evening: upload rejected",
},
},
wantStatus: "failed",
wantError: "batch evening notification failed: notify batch evening: upload rejected",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := newBatchSummary(tt.result)
if summary.Command != "run" || summary.Status != tt.wantStatus {
t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus)
}
if summary.Error != tt.wantError {
t.Fatalf("error = %q, want %q", summary.Error, tt.wantError)
}
if len(summary.Reports) != len(tt.result.Reports) {
t.Fatalf("reports = %#v, want copied report list", summary.Reports)
}
})
}
}
func testSummaryPeriod(start time.Time) timeutil.Period {
return timeutil.Period{
Start: start,
End: start.Add(6 * time.Hour),
}
}

View File

@@ -2,7 +2,6 @@ package cli
import ( import (
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -17,15 +16,15 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage: Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
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] [--quiet] --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] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID
@@ -40,6 +39,7 @@ Options:
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy where supported by the generate command. --out PATH Write an extra Markdown report copy where supported by the generate command.
--out-dir PATH Write extra Markdown report copies for run commands. --out-dir PATH Write extra Markdown report copies for run commands.
--quiet Suppress successful generate and run output.
` `
type Runner struct { type Runner struct {
@@ -51,7 +51,6 @@ func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer)
} }
func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
_ = stderr
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
} }
@@ -62,23 +61,32 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
switch args[0] { switch args[0] {
case "generate": case "generate":
req, err := r.resolveGenerate(args[1:]) req, opts, err := r.resolveGenerateAction(args[1:])
if err != nil { if err != nil {
return err return err
} }
return app.Generate(ctx, req) result, err := app.GenerateDetailed(ctx, req)
if result != nil {
summary := newGenerateSummary(result, err)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
return encodeErr
}
}
return err
case "run": case "run":
req, err := r.resolveRun(args[1:]) req, opts, err := r.resolveRunAction(args[1:])
if err != nil { if err != nil {
return err return err
} }
result, err := app.RunBatchDetailed(ctx, req) result, err := app.RunBatchDetailed(ctx, req)
if result != nil { if result != nil {
writeRunLogs(stderr, result) summary := newBatchSummary(result)
if encodeErr := writeJSON(stdout, result); encodeErr != nil { if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
writeBatchStatus(w, result)
}); encodeErr != nil {
return encodeErr return encodeErr
} }
if result.Failed > 0 { if summary.Status == summaryStatusFailed {
return app.BatchError{Result: result} return app.BatchError{Result: result}
} }
} }
@@ -96,6 +104,7 @@ type commonOptions struct {
Timezone string Timezone string
Output string Output string
OutputDir string OutputDir string
Quiet bool
} }
type generateOptions struct { type generateOptions struct {
@@ -181,20 +190,25 @@ func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspect
} }
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
req, _, err := r.resolveGenerateAction(args)
return req, err
}
func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) {
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
} }
if len(args) == 0 { if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name")
} }
if _, err := report.IDForCommandName(args[0]); err != nil { if _, err := report.IDForCommandName(args[0]); err != nil {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0])
} }
reportKind := app.ReportKind(args[0]) reportKind := app.ReportKind(args[0])
opts, err := parseGenerateFlags(reportKind, args[1:]) opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
cfg, err := config.Load(config.LoadOptions{ cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath, Path: opts.ConfigPath,
@@ -202,11 +216,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
Timezone: opts.Timezone, Timezone: opts.Timezone,
}) })
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
req := app.GenerateRequest{ req := app.GenerateRequest{
@@ -219,11 +233,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
switch reportKind { switch reportKind {
case app.ReportDaily: case app.ReportDaily:
if opts.Date == "" { if opts.Date == "" {
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD") return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
} }
req.Date, err = timeutil.ParseLocalDate(opts.Date, location) req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
case app.ReportToday: case app.ReportToday:
if opts.Date == "" { if opts.Date == "" {
@@ -231,41 +245,46 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
} else { } else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location) req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
} }
case app.ReportStorm: case app.ReportStorm:
if opts.Start == "" { if opts.Start == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start") return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --start")
} }
if opts.End == "" { if opts.End == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end") return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --end")
} }
period, err := report.ParseStormPeriod(opts.Start, opts.End, location) period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
req.StormStart = period.Start req.StormStart = period.Start
req.StormEnd = period.End req.StormEnd = period.End
} }
return req, nil return req, opts.commonOptions, nil
} }
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
req, _, err := r.resolveRunAction(args)
return req, err
}
func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) {
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
} }
if len(args) == 0 { if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name") return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name")
} }
if _, err := report.BatchForCommandName(args[0]); err != nil { if _, err := report.BatchForCommandName(args[0]); err != nil {
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0]) return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0])
} }
batch := app.BatchKind(args[0]) batch := app.BatchKind(args[0])
opts, err := parseRunFlags(args[1:]) opts, err := parseRunFlags(args[1:])
if err != nil { if err != nil {
return app.BatchRequest{}, err return app.BatchRequest{}, commonOptions{}, err
} }
cfg, err := config.Load(config.LoadOptions{ cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath, Path: opts.ConfigPath,
@@ -273,9 +292,9 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
Timezone: opts.Timezone, Timezone: opts.Timezone,
}) })
if err != nil { if err != nil {
return app.BatchRequest{}, err return app.BatchRequest{}, commonOptions{}, err
} }
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, nil
} }
func resolveRun(args []string) (app.BatchRequest, error) { func resolveRun(args []string) (app.BatchRequest, error) {
@@ -287,6 +306,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
opts := generateOptions{} opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true) addCommonFlags(fs, &opts.commonOptions, true)
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if report == app.ReportDaily || report == app.ReportToday { if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
} }
@@ -309,6 +329,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
opts := commonOptions{} opts := commonOptions{}
addCommonFlags(fs, &opts, false) addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory") fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return commonOptions{}, err return commonOptions{}, err
} }
@@ -351,58 +372,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
return opts, nil return opts, nil
} }
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
}
for _, item := range result.Reports {
notificationFields := ""
if item.NotificationStatus != "" {
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
}
if item.NotificationRunID != "" {
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
}
if item.NotificationError != "" {
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
}
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
}
if result.Notification != nil {
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
if result.Notification.Reason != "" {
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
}
if result.Notification.RunID != "" {
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
}
if result.Notification.PipelineID != "" {
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
}
if result.Notification.BundleID != "" {
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
}
if result.Notification.Path != "" {
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
}
if result.Notification.Error != "" {
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
}
_, _ = fmt.Fprintln(stderr)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}
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")

View File

@@ -32,6 +32,12 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(output.stdout, "weatherreporter generate hourly") { if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout) t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
} }
if !strings.Contains(output.stdout, "generate today") || !strings.Contains(output.stdout, "[--quiet]") {
t.Fatalf("help output missing quiet generate usage:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "run morning") || !strings.Contains(output.stdout, "--quiet Suppress successful generate and run output.") {
t.Fatalf("help output missing quiet run option:\n%s", output.stdout)
}
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-") removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
if strings.Contains(output.stdout, removedGenerateCommand) { if strings.Contains(output.stdout, removedGenerateCommand) {
t.Fatalf("help output includes retired generate command:\n%s", output.stdout) t.Fatalf("help output includes retired generate command:\n%s", output.stdout)
@@ -80,7 +86,7 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Daily Report") assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: storm") assertFileContains(t, dataPackagePath, "id: storm")
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report") assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
} }
@@ -99,10 +105,10 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Saturday's Weather") assertFileContains(t, outPath, "# Saturday's Weather")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: tomorrow") assertFileContains(t, dataPackagePath, "id: tomorrow")
assertFileContains(t, dataPackagePath, "tomorrow_planning:") assertFileContains(t, dataPackagePath, "tomorrow_planning:")
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md") reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") { if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want tomorrow report", reportPath) t.Fatalf("managed report = %q, want tomorrow report", reportPath)
} }
@@ -119,8 +125,8 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md") reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") { if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want only tomorrow report", reportPath) t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
} }
@@ -131,7 +137,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
outPath := fixture.path("three-day.md") outPath := fixture.path("three-day.md")
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"generate", "three-day", "generate", "three-day",
"--config", fixture.configPath, "--config", fixture.configPath,
"--out", outPath, "--out", outPath,
@@ -140,9 +146,23 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Daily Report") assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: three_day") assertFileContains(t, dataPackagePath, "id: three_day")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
summary := decodeGenerateSummary(t, output.stdout)
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.ThreeDay {
t.Fatalf("generate summary = %#v, want successful 3-day summary", summary)
}
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
t.Fatalf("summary paths = %#v, want managed artifact paths", summary)
}
if summary.OutputPath != outPath {
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
}
if summary.GeneratedTextRawPath != "" || summary.GeneratedTextResultPath != "" || summary.GeneratedTextPath != "" || summary.RenderContextPath != "" {
t.Fatalf("generated-text paths = %#v, want omitted for markdown report", summary)
}
} }
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) { func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
@@ -159,7 +179,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Daily Report") assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: weekend") assertFileContains(t, dataPackagePath, "id: weekend")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
} }
@@ -175,11 +195,11 @@ func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
} }
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
@@ -197,9 +217,9 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
t.Fatalf("Run() error = %q, want aggregate failure", err.Error()) t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
} }
var summary app.BatchResult summary := decodeBatchSummary(t, output.stdout)
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil { if summary.Command != "run" || summary.Status != "failed" {
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout) t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status)
} }
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 { if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed) t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
@@ -207,8 +227,8 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") { if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
} }
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
} }
func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) { func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
@@ -223,7 +243,7 @@ func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
PipelineID: "weatherreporter", PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.morning", BundleID: "weatherreporter.home.morning",
IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning", IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning",
Path: "/tmp/batch.distributor.json", Path: "/tmp/distributor.batch.json",
IncludedReports: []app.BatchNotificationReport{ IncludedReports: []app.BatchNotificationReport{
{ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}}, {ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}},
}, },
@@ -247,7 +267,7 @@ func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
if err := writeJSON(&stdout, result); err != nil { if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err) t.Fatalf("writeJSON() error = %v", err)
} }
writeRunLogs(&stderr, result) writeBatchStatus(&stderr, result)
var decoded app.BatchResult var decoded app.BatchResult
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
@@ -294,7 +314,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
if err := writeJSON(&stdout, result); err != nil { if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err) t.Fatalf("writeJSON() error = %v", err)
} }
writeRunLogs(&stderr, result) writeBatchStatus(&stderr, result)
for _, output := range []string{stdout.String(), stderr.String()} { for _, output := range []string{stdout.String(), stderr.String()} {
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") { if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
@@ -306,7 +326,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
} }
} }
func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) { func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) {
result := &app.BatchResult{ result := &app.BatchResult{
Batch: app.BatchMorning, Batch: app.BatchMorning,
Total: 2, Total: 2,
@@ -327,7 +347,7 @@ func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
if err := writeJSON(&stdout, result); err != nil { if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err) t.Fatalf("writeJSON() error = %v", err)
} }
writeRunLogs(&stderr, result) writeBatchStatus(&stderr, result)
var decoded app.BatchResult var decoded app.BatchResult
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
@@ -344,7 +364,7 @@ func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
} }
} }
func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport(t *testing.T) { func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
result := &app.BatchResult{ result := &app.BatchResult{
Batch: app.BatchEvening, Batch: app.BatchEvening,
Total: 1, Total: 1,
@@ -360,7 +380,7 @@ func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
} }
var stderr bytes.Buffer var stderr bytes.Buffer
writeRunLogs(&stderr, result) writeBatchStatus(&stderr, result)
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
@@ -387,9 +407,9 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
var summary app.BatchResult summary := decodeBatchSummary(t, output.stdout)
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil { if summary.Command != "run" || summary.Status != "succeeded" {
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout) t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
} }
if summary.Total != 1 || summary.Failed != 0 { if summary.Total != 1 || summary.Failed != 0 {
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed) t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
@@ -402,6 +422,24 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
} }
} }
func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"run", "evening",
"--config", fixture.configPath,
"--quiet",
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if output.stdout != "" || output.stderr != "" {
t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr)
}
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
}
func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) { func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
var uploadCount int var uploadCount int
@@ -437,9 +475,9 @@ func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
var summary app.BatchResult summary := decodeBatchSummary(t, stdout.String())
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil { if summary.Command != "run" || summary.Status != "succeeded" {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String()) t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
} }
if len(summary.Reports) != 1 { if len(summary.Reports) != 1 {
t.Fatalf("reports = %#v, want one report", summary.Reports) t.Fatalf("reports = %#v, want one report", summary.Reports)
@@ -489,9 +527,9 @@ func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T)
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
var summary app.BatchResult summary := decodeBatchSummary(t, stdout.String())
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil { if summary.Command != "run" || summary.Status != "succeeded" {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String()) t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
} }
if len(summary.Reports) != 1 { if len(summary.Reports) != 1 {
t.Fatalf("summary reports = %#v, want one report", summary.Reports) t.Fatalf("summary reports = %#v, want one report", summary.Reports)
@@ -517,11 +555,11 @@ func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "data_package.*.yaml")
} }
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
@@ -541,14 +579,14 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
report, err := os.ReadFile(outPath) reportData, err := os.ReadFile(outPath)
if err != nil { if err != nil {
t.Fatalf("read report: %v", err) t.Fatalf("read report: %v", err)
} }
if !strings.Contains(string(report), "# Friday's Weather") { if !strings.Contains(string(reportData), "# Friday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(reportData))
} }
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
data, err := os.ReadFile(dataPackagePath) data, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
@@ -563,7 +601,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
!strings.Contains(string(data), "timezone: UTC") { !strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data)) t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
} }
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json") preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "render.*.json")
preflight, err := os.ReadFile(preflightPath) preflight, err := os.ReadFile(preflightPath)
if err != nil { if err != nil {
t.Fatalf("read preflight: %v", err) t.Fatalf("read preflight: %v", err)
@@ -571,11 +609,11 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(preflight), `ok`) { if !strings.Contains(string(preflight), `ok`) {
t.Fatalf("preflight missing fake render output:\n%s", string(preflight)) t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
} }
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "*.md") _ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md")
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json")
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.render_context.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "render_context.*.json")
metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json") metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "metadata.*.json")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`) assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`) assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
@@ -598,7 +636,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
report, err := os.ReadFile(outPath) reportData, err := os.ReadFile(outPath)
if err != nil { if err != nil {
t.Fatalf("read report: %v", err) t.Fatalf("read report: %v", err)
} }
@@ -607,11 +645,11 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
"Today starts with showers before improving.", "Today starts with showers before improving.",
"Morning showers should taper as drier air arrives.", "Morning showers should taper as drier air arrives.",
} { } {
if !strings.Contains(string(report), want) { if !strings.Contains(string(reportData), want) {
t.Fatalf("today report output missing %q:\n%s", want, string(report)) t.Fatalf("today report output missing %q:\n%s", want, string(reportData))
} }
} }
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
dataPackage, err := os.ReadFile(dataPackagePath) dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
@@ -621,15 +659,29 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
!strings.Contains(string(dataPackage), "today_planning:") { !strings.Contains(string(dataPackage), "today_planning:") {
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage)) t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
} }
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text_raw.*.json")
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "render_context.*.json")
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "*.md") managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "2026-05-29", "report.*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`) assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`) assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
assertFileContains(t, managedReportPath, "# Today's Weather") assertFileContains(t, managedReportPath, "# Today's Weather")
summary := decodeGenerateSummary(t, stdout.String())
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today {
t.Fatalf("generate summary = %#v, want successful Today summary", summary)
}
if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary)
}
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
}
if summary.OutputPath != outPath {
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
}
} }
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
@@ -647,7 +699,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
report, err := os.ReadFile(outPath) reportData, err := os.ReadFile(outPath)
if err != nil { if err != nil {
t.Fatalf("read report: %v", err) t.Fatalf("read report: %v", err)
} }
@@ -657,11 +709,11 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
"A cold front is moving into the region.", "A cold front is moving into the region.",
"A front will keep the region unsettled.", "A front will keep the region unsettled.",
} { } {
if !strings.Contains(string(report), want) { if !strings.Contains(string(reportData), want) {
t.Fatalf("report output missing %q:\n%s", want, string(report)) t.Fatalf("report output missing %q:\n%s", want, string(reportData))
} }
} }
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "data_package.*.yaml")
dataPackage, err := os.ReadFile(dataPackagePath) dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
@@ -671,10 +723,10 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
!strings.Contains(string(dataPackage), "hourly_forecast:") { !strings.Contains(string(dataPackage), "hourly_forecast:") {
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage)) t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
} }
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text_raw.*.json")
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json")
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "*.md") managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`) assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"Report": {`) assertFileContains(t, renderContextPath, `"Report": {`)
@@ -685,6 +737,76 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
assertFileContains(t, managedReportPath, "# Hourly Report") assertFileContains(t, managedReportPath, "# Hourly Report")
} }
func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
outPath := fixture.path("today.md")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "today",
"--config", fixture.configPath,
"--date", "2026-05-29",
"--out", outPath,
"--quiet",
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if output.stdout != "" || output.stderr != "" {
t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr)
}
assertFileContains(t, outPath, "# Today's Weather")
}
func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want required date error")
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want no partial JSON", stdout.String())
}
}
func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
server := dailyServer(t)
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected distributor request with unset token: %s", r.URL.Path)
}))
t.Cleanup(distributorServer.Close)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "three-day",
"--config", configPath,
)
if err == nil {
t.Fatal("Run() error = nil, want notification failure")
}
summary := decodeGenerateSummary(t, output.stdout)
if summary.Command != "generate" || summary.Status != "failed" || summary.Error == "" {
t.Fatalf("summary = %#v, want failed generate summary", summary)
}
if !strings.Contains(summary.Error, "token environment variable") {
t.Fatalf("summary error = %q, want token environment context", summary.Error)
}
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.NotificationPath == "" {
t.Fatalf("summary paths = %#v, want inspectable report, metadata, and notification paths", summary)
}
if strings.Contains(output.stdout, "CLI_DISTRIBUTOR_TOKEN_VALUE") || strings.Contains(output.stderr, "CLI_DISTRIBUTOR_TOKEN_VALUE") {
t.Fatalf("output contains distributor token value\nstdout=%s\nstderr=%s", output.stdout, output.stderr)
}
}
func TestRunInspectTodayArtifacts(t *testing.T) { func TestRunInspectTodayArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -699,8 +821,8 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run(generate) error = %v", err) t.Fatalf("Run(generate) error = %v", err)
} }
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml") runID := runIDFromDataPackagePath(t, dataPackagePath)
stdout.Reset() stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr) err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
@@ -744,8 +866,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Run(generate) error = %v", err) t.Fatalf("Run(generate) error = %v", err)
} }
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml") runID := runIDFromDataPackagePath(t, dataPackagePath)
stdout.Reset() stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr) err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
@@ -791,6 +913,22 @@ func TestRunInspectMissingMetadata(t *testing.T) {
} }
} }
func TestRunInspectRejectsQuiet(t *testing.T) {
tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run(inspect reports --quiet) error = nil, want unexpected flag error")
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("error = %q, want unexpected quiet flag", err.Error())
}
}
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
@@ -1179,6 +1317,24 @@ func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput,
}, err }, err
} }
func decodeGenerateSummary(t *testing.T, text string) generateSummary {
t.Helper()
var summary generateSummary
if err := json.Unmarshal([]byte(text), &summary); err != nil {
t.Fatalf("decode generate summary: %v\n%s", err, text)
}
return summary
}
func decodeBatchSummary(t *testing.T, text string) batchSummary {
t.Helper()
var summary batchSummary
if err := json.Unmarshal([]byte(text), &summary); err != nil {
t.Fatalf("decode batch summary: %v\n%s", err, text)
}
return summary
}
func dailyServer(t *testing.T) *httptest.Server { func dailyServer(t *testing.T) *httptest.Server {
t.Helper() t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1286,6 +1442,16 @@ func noArtifacts(t *testing.T, root string, parts ...string) {
} }
} }
func runIDFromDataPackagePath(t *testing.T, path string) string {
t.Helper()
base := filepath.Base(path)
runID := strings.TrimSuffix(strings.TrimPrefix(base, "data_package."), ".yaml")
if runID == base || runID == "" {
t.Fatalf("data package path = %q, want data_package.<run_id>.yaml", path)
}
return runID
}
func firstLineWithPrefix(text string, prefix string) string { func firstLineWithPrefix(text string, prefix string) string {
for _, line := range strings.Split(text, "\n") { for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, prefix) { if strings.HasPrefix(line, prefix) {

View File

@@ -66,7 +66,6 @@ type DistributorNotifyConfig struct {
PipelineIDTemplate string `yaml:"pipeline_id_template"` PipelineIDTemplate string `yaml:"pipeline_id_template"`
BundleIDTemplate string `yaml:"bundle_id_template"` BundleIDTemplate string `yaml:"bundle_id_template"`
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"` IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
ReportPathTemplates []string `yaml:"report_path_templates"`
Batch DistributorBatchNotifyConfig `yaml:"batch"` Batch DistributorBatchNotifyConfig `yaml:"batch"`
} }
@@ -113,10 +112,16 @@ type RecentChangeConfig struct {
} }
type ReportConfig struct { type ReportConfig struct {
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
Distributor ReportDistributorConfig `yaml:"distributor"`
deterministicModulesSet bool deterministicModulesSet bool
} }
type ReportDistributorConfig struct {
PathTemplates []string `yaml:"path_templates"`
pathTemplatesSet bool
}
type ModuleConfigItem struct { type ModuleConfigItem struct {
ID module.ID `yaml:"id"` ID module.ID `yaml:"id"`
Options any `yaml:"options,omitempty"` Options any `yaml:"options,omitempty"`
@@ -135,6 +140,10 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
return err return err
} }
c.deterministicModulesSet = true c.deterministicModulesSet = true
case "distributor":
if err := node.Decode(&c.Distributor); err != nil {
return err
}
default: default:
return fmt.Errorf("unknown report entry field %q", key) return fmt.Errorf("unknown report entry field %q", key)
} }
@@ -142,6 +151,112 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
return nil return nil
} }
func (c *DistributorNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("notify distributor entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "enabled":
if err := node.Decode(&c.Enabled); err != nil {
return err
}
case "endpoint":
if err := node.Decode(&c.Endpoint); err != nil {
return err
}
case "token_env":
if err := node.Decode(&c.TokenEnv); err != nil {
return err
}
case "timeout":
if err := node.Decode(&c.Timeout); err != nil {
return err
}
case "failure_policy":
if err := node.Decode(&c.FailurePolicy); err != nil {
return err
}
case "pipeline_id_template":
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
return err
}
case "bundle_id_template":
if err := node.Decode(&c.BundleIDTemplate); err != nil {
return err
}
case "idempotency_key_template":
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
return err
}
case "batch":
if err := node.Decode(&c.Batch); err != nil {
return err
}
default:
return fmt.Errorf("unknown notify distributor field %q", key)
}
}
return nil
}
func (c *DistributorBatchNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("notify distributor batch entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "enabled":
if err := node.Decode(&c.Enabled); err != nil {
return err
}
case "pipeline_id_template":
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
return err
}
case "bundle_id_template":
if err := node.Decode(&c.BundleIDTemplate); err != nil {
return err
}
case "idempotency_key_template":
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
return err
}
default:
return fmt.Errorf("unknown notify distributor batch field %q", key)
}
}
return nil
}
func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("report distributor entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "path_templates":
if err := node.Decode(&c.PathTemplates); err != nil {
return err
}
c.pathTemplatesSet = true
default:
return fmt.Errorf("unknown report distributor field %q", key)
}
}
return nil
}
func (c ReportDistributorConfig) PathTemplatesSet() bool {
return c.pathTemplatesSet
}
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error { func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind { switch value.Kind {
case yaml.ScalarNode: case yaml.ScalarNode:

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gopkg.in/yaml.v3"
) )
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
@@ -57,12 +58,6 @@ func TestDefaults(t *testing.T) {
if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" { if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" {
t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate) t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate)
} }
wantReportPaths := []string{
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
}
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") {
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths)
}
if !cfg.Notify.Distributor.Batch.Enabled { if !cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true") t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
} }
@@ -101,9 +96,6 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" { if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate) t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
} }
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
}
if !cfg.Notify.Distributor.Batch.Enabled { if !cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true") t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
} }
@@ -313,6 +305,102 @@ reports:
} }
} }
func TestLoadReportDistributorPathOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
today:
deterministic_modules:
- metadata
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
daily := cfg.Reports["daily"].Distributor
if !daily.PathTemplatesSet() {
t.Fatal("daily distributor path_templates set = false, want true")
}
want := []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
}
if !reflect.DeepEqual(daily.PathTemplates, want) {
t.Fatalf("daily path templates = %#v, want %#v", daily.PathTemplates, want)
}
if cfg.Reports["today"].Distributor.PathTemplatesSet() {
t.Fatal("today distributor path_templates set = true, want false")
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.Daily], want) {
t.Fatalf("daily distributor override = %#v, want %#v", overrides[report.Daily], want)
}
if _, ok := overrides[report.Today]; ok {
t.Fatalf("today distributor override = %#v, want omitted override absent", overrides[report.Today])
}
}
func TestReportDistributorPathTemplatesSetTracksExplicitEmptyList(t *testing.T) {
var cfg Config
if err := yaml.Unmarshal([]byte(`
reports:
daily:
distributor:
path_templates: []
today:
distributor: {}
`), &cfg); err != nil {
t.Fatalf("yaml.Unmarshal() error = %v", err)
}
if !cfg.Reports["daily"].Distributor.PathTemplatesSet() {
t.Fatal("daily distributor path_templates set = false, want true")
}
if len(cfg.Reports["daily"].Distributor.PathTemplates) != 0 {
t.Fatalf("daily path templates = %#v, want empty explicit list", cfg.Reports["daily"].Distributor.PathTemplates)
}
if cfg.Reports["today"].Distributor.PathTemplatesSet() {
t.Fatal("today distributor path_templates set = true, want false")
}
}
func TestLoadReportDistributorPathOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
weekend_outlook:
distributor:
path_templates:
- "weekend/{valid_start_date}/index.md"
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) {
t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay])
}
if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) {
t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend])
}
}
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) { func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults() cfg := Defaults()
rawOptions := map[string]any{ rawOptions := map[string]any{
@@ -565,6 +653,196 @@ reports:
} }
} }
func TestReportDistributorPathOverrideValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "UnknownReportField",
yaml: `
reports:
daily:
distributor_paths:
- latest.md
`,
wantErr: `unknown report entry field "distributor_paths"`,
},
{
name: "UnknownDistributorField",
yaml: `
reports:
daily:
distributor:
paths:
- latest.md
`,
wantErr: `unknown report distributor field "paths"`,
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
three_day:
distributor:
path_templates:
- "three-day/latest.md"
`,
wantErr: "duplicates report override",
},
{
name: "UnknownTemplateVariable",
yaml: `
reports:
daily:
distributor:
path_templates:
- "{unknown}.md"
`,
wantErr: `reports.daily.distributor.path_templates[0] contains unknown template variable "unknown"`,
},
{
name: "AbsolutePath",
yaml: `
reports:
daily:
distributor:
path_templates:
- "/daily.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must render a relative path",
},
{
name: "ParentSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/../index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render . or .. path segments",
},
{
name: "Manifest",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/manifest.json"
`,
wantErr: `reports.daily.distributor.path_templates[0] must not render reserved path segment "manifest.json"`,
},
{
name: "DuplicateRenderedPath",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/index.md"
- "daily/index.md"
`,
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
},
{
name: "NonStormStormIDEmptyPathSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/{storm_id}/index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render empty path segments",
},
{
name: "EmptyOverrideList",
yaml: `
reports:
daily:
distributor:
path_templates: []
`,
wantErr: "reports.daily.distributor.path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, tt.yaml))
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/storm-{storm_id}.md"
storm:
distributor:
path_templates:
- "storm/{storm_id}/index.md"
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
}
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
yaml := `
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/index.md"
`
reports := map[string]ReportConfig{
"daily": {
Distributor: ReportDistributorConfig{
PathTemplates: []string{"daily/{valid_start_date}/index.md"},
pathTemplatesSet: true,
},
},
}
cfg, err := LoadFile(writeConfig(t, yaml))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
loaded, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("loaded ReportDistributorPathOverrides() error = %v", err)
}
cfg = Defaults()
cfg.Reports = reports
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
constructed, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("constructed ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(loaded, constructed) {
t.Fatalf("loaded overrides = %#v, constructed = %#v", loaded, constructed)
}
}
func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) { func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -806,6 +1084,77 @@ func TestDisabledDistributorNotifyAcceptsOmittedFields(t *testing.T) {
} }
} }
func TestDistributorNotifyRejectsRemovedGlobalReportPaths(t *testing.T) {
removedField := "report_path" + "_templates"
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
`+removedField+`:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want removed global path field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor field "`+removedField+`"`) {
t.Fatalf("error = %q, want removed global path field rejection", err.Error())
}
}
func TestDistributorNotifyRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
paths:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown distributor field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor field "paths"`) {
t.Fatalf("error = %q, want unknown field rejection", err.Error())
}
}
func TestDistributorBatchNotifyRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
batch:
paths:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown distributor batch field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor batch field "paths"`) {
t.Fatalf("error = %q, want unknown batch field rejection", err.Error())
}
}
func TestDistributorBatchNotifyPartialConfigPreservesDefaults(t *testing.T) {
cfg, err := LoadFile(writeConfig(t, `
notify:
distributor:
batch:
enabled: false
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Batch.Enabled = true, want false")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Batch.PipelineIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
}
}
func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) { func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
cfg := Defaults() cfg := Defaults()
cfg.Notify.Distributor.Enabled = false cfg.Notify.Distributor.Enabled = false
@@ -895,32 +1244,11 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
wantErr: "notify.distributor.idempotency_key_template", wantErr: "notify.distributor.idempotency_key_template",
}, },
{ {
name: "ReportPathTemplatesEmpty", name: "BatchTemplate",
mutate: func(cfg *Config) { mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = nil cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
}, },
wantErr: "notify.distributor.report_path_templates", wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "ReportPathTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = []string{"{unknown}"}
},
wantErr: "notify.distributor.report_path_templates",
},
{
name: "ReportPathTemplateInvalidPath",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = []string{"/{batch_output_name}"}
},
wantErr: "notify.distributor.report_path_templates",
},
{
name: "ReportPathTemplateDuplicatePath",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = []string{"latest.md", "latest.md"}
},
wantErr: "notify.distributor.report_path_templates",
}, },
} }
@@ -1058,35 +1386,38 @@ func TestDistributorTemplateRendering(t *testing.T) {
ValidEndTime: "0600", ValidEndTime: "0600",
ValidStartStamp: "2026-06-07T1800", ValidStartStamp: "2026-06-07T1800",
ValidEndStamp: "2026-06-08T0600", ValidEndStamp: "2026-06-08T0600",
StormID: "2026-06-07T1800-2026-06-08T0600",
BundleID: "weatherreporter.home.daily", BundleID: "weatherreporter.home.daily",
} }
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}", values) bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{storm_id}", values)
if err != nil { if err != nil {
t.Fatalf("RenderDistributorBundleID() error = %v", err) t.Fatalf("RenderDistributorBundleID() error = %v", err)
} }
if bundleID != "weatherreporter.home.daily" { if bundleID != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
t.Fatalf("bundleID = %q, want rendered value", bundleID) t.Fatalf("bundleID = %q, want rendered value", bundleID)
} }
values.BundleID = bundleID
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{bundle_id}", values) pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{storm_id}.{bundle_id}", values)
if err != nil { if err != nil {
t.Fatalf("RenderDistributorPipelineID() error = %v", err) t.Fatalf("RenderDistributorPipelineID() error = %v", err)
} }
if pipelineID != "weatherreporter.daily.weatherreporter.home.daily" { if pipelineID != "weatherreporter.daily.2026-06-07T1800-2026-06-08T0600.weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID) t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
} }
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values) idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values)
if err != nil { if err != nil {
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err) t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
} }
if idempotencyKey != "weatherreporter.home.daily.20260607T120000Z" { if idempotencyKey != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600.2026-06-07T1800-2026-06-08T0600.20260607T120000Z" {
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey) t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
} }
reportPaths, err := RenderDistributorReportPaths([]string{ reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md", "{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
"storm/{storm_id}/index.md",
"{valid_start_date}/{artifact_group}/latest.md", "{valid_start_date}/{artifact_group}/latest.md",
}, values) }, values)
if err != nil { if err != nil {
@@ -1094,6 +1425,7 @@ func TestDistributorTemplateRendering(t *testing.T) {
} }
wantPaths := []string{ wantPaths := []string{
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md", "2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
"storm/2026-06-07T1800-2026-06-08T0600/index.md",
"2026-06-07/daily/latest.md", "2026-06-07/daily/latest.md",
} }
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") { if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
@@ -1101,6 +1433,50 @@ func TestDistributorTemplateRendering(t *testing.T) {
} }
} }
func TestDistributorReportPathRenderingUsesCallerName(t *testing.T) {
values := DistributorTemplateValues{
BatchOutputName: "report.md",
}
tests := []struct {
name string
templates []string
wantErr string
}{
{
name: "UnknownVariable",
templates: []string{"{unknown}.md"},
wantErr: `report.daily.distributor_path_templates[0] contains unknown template variable "unknown"`,
},
{
name: "InvalidPath",
templates: []string{"/{batch_output_name}"},
wantErr: "report.daily.distributor_path_templates[0] must render a relative path",
},
{
name: "DuplicatePath",
templates: []string{"latest.md", "latest.md"},
wantErr: `report.daily.distributor_path_templates renders duplicate path "latest.md"`,
},
{
name: "Empty",
templates: nil,
wantErr: "report.daily.distributor_path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", tt.templates, values)
if err == nil {
t.Fatal("RenderDistributorReportPaths() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDistributorBatchTemplateRendering(t *testing.T) { func TestDistributorBatchTemplateRendering(t *testing.T) {
values := DistributorBatchTemplateValues{ values := DistributorBatchTemplateValues{
LocationID: "home", LocationID: "home",
@@ -1201,7 +1577,7 @@ func TestDistributorReportPathValidation(t *testing.T) {
{name: "ParentSegment", path: "reports/../daily.md", ok: false}, {name: "ParentSegment", path: "reports/../daily.md", ok: false},
{name: "EmptySegment", path: "reports//daily.md", ok: false}, {name: "EmptySegment", path: "reports//daily.md", ok: false},
{name: "Manifest", path: "reports/manifest.json", ok: false}, {name: "Manifest", path: "reports/manifest.json", ok: false},
{name: "DistributorMetadata", path: "reports/.distributor.json", ok: false}, {name: "DistributorMetadata", path: "reports/" + distributorSidecarBasename(), ok: false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1228,17 +1604,20 @@ func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
{name: "ParentSegment", batchOutputName: "../daily.md"}, {name: "ParentSegment", batchOutputName: "../daily.md"},
{name: "EmptySegment", batchOutputName: "reports//daily.md"}, {name: "EmptySegment", batchOutputName: "reports//daily.md"},
{name: "Manifest", batchOutputName: "manifest.json"}, {name: "Manifest", batchOutputName: "manifest.json"},
{name: "DistributorMetadata", batchOutputName: ".distributor.json"}, {name: "DistributorMetadata", batchOutputName: distributorSidecarBasename()},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorReportPaths([]string{"{batch_output_name}"}, DistributorTemplateValues{ _, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", []string{"{batch_output_name}"}, DistributorTemplateValues{
BatchOutputName: tt.batchOutputName, BatchOutputName: tt.batchOutputName,
}) })
if err == nil { if err == nil {
t.Fatal("RenderDistributorReportPaths() error = nil, want error") t.Fatal("RenderDistributorReportPaths() error = nil, want error")
} }
if !strings.Contains(err.Error(), "report.daily.distributor_path_templates[0]") {
t.Fatalf("error = %q, want caller path name", err.Error())
}
}) })
} }
} }

View File

@@ -31,9 +31,6 @@ func Defaults() Config {
PipelineIDTemplate: "", PipelineIDTemplate: "",
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}", BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
IdempotencyKeyTemplate: "{bundle_id}.{run_id}", IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
ReportPathTemplates: []string{
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
},
Batch: DistributorBatchNotifyConfig{ Batch: DistributorBatchNotifyConfig{
Enabled: true, Enabled: true,
PipelineIDTemplate: "weatherreporter", PipelineIDTemplate: "weatherreporter",

View File

@@ -18,6 +18,7 @@ type DistributorTemplateValues struct {
ValidEndTime string ValidEndTime string
ValidStartStamp string ValidStartStamp string
ValidEndStamp string ValidEndStamp string
StormID string
BundleID string BundleID string
} }
@@ -41,6 +42,7 @@ var distributorTemplateVariables = map[string]struct{}{
"valid_end_time": {}, "valid_end_time": {},
"valid_start_stamp": {}, "valid_start_stamp": {},
"valid_end_stamp": {}, "valid_end_stamp": {},
"storm_id": {},
} }
var distributorIdempotencyTemplateVariables = map[string]struct{}{ var distributorIdempotencyTemplateVariables = map[string]struct{}{
@@ -55,6 +57,7 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{
"valid_end_time": {}, "valid_end_time": {},
"valid_start_stamp": {}, "valid_start_stamp": {},
"valid_end_stamp": {}, "valid_end_stamp": {},
"storm_id": {},
"bundle_id": {}, "bundle_id": {},
} }
@@ -129,23 +132,23 @@ func RenderDistributorBatchIdempotencyKey(template string, values DistributorBat
return rendered, nil return rendered, nil
} }
func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) { func RenderDistributorReportPaths(name string, templates []string, values DistributorTemplateValues) ([]string, error) {
if len(templates) == 0 { if len(templates) == 0 {
return nil, fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry") return nil, fmt.Errorf("%s must contain at least one entry", name)
} }
paths := make([]string, 0, len(templates)) paths := make([]string, 0, len(templates))
seen := make(map[string]struct{}, len(templates)) seen := make(map[string]struct{}, len(templates))
for i, template := range templates { for i, template := range templates {
name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i) itemName := fmt.Sprintf("%s[%d]", name, i)
rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables) rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := ValidateDistributorReportPath(name, rendered); err != nil { if err := ValidateDistributorReportPath(itemName, rendered); err != nil {
return nil, err return nil, err
} }
if _, ok := seen[rendered]; ok { if _, ok := seen[rendered]; ok {
return nil, fmt.Errorf("notify.distributor.report_path_templates renders duplicate path %q", rendered) return nil, fmt.Errorf("%s renders duplicate path %q", name, rendered)
} }
seen[rendered] = struct{}{} seen[rendered] = struct{}{}
paths = append(paths, rendered) paths = append(paths, rendered)
@@ -243,6 +246,8 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues)
return values.ValidStartStamp return values.ValidStartStamp
case "valid_end_stamp": case "valid_end_stamp":
return values.ValidEndStamp return values.ValidEndStamp
case "storm_id":
return values.StormID
case "bundle_id": case "bundle_id":
return values.BundleID return values.BundleID
default: default:
@@ -286,7 +291,7 @@ func ValidateDistributorReportPath(name, path string) error {
if segment == "." || segment == ".." { if segment == "." || segment == ".." {
return fmt.Errorf("%s must not render . or .. path segments", name) return fmt.Errorf("%s must not render . or .. path segments", name)
} }
if segment == "manifest.json" || segment == ".distributor.json" { if segment == "manifest.json" || segment == distributorSidecarBasename() {
return fmt.Errorf("%s must not render reserved path segment %q", name, segment) return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
} }
} }
@@ -294,6 +299,10 @@ func ValidateDistributorReportPath(name, path string) error {
return nil return nil
} }
func distributorSidecarBasename() string {
return "." + "distributor.json"
}
func isDistributorAbsolutePath(path string) bool { func isDistributorAbsolutePath(path string) bool {
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") { if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
return true return true

View File

@@ -17,6 +17,10 @@ func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, er
}) })
} }
func (cfg Config) ReportDistributorPathOverrides() (map[report.ID][]string, error) {
return traverseReportDistributorPathOverrides(cfg)
}
func normalizeReportModules(cfg *Config) error { func normalizeReportModules(cfg *Config) error {
if cfg.Reports == nil { if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{} cfg.Reports = map[string]ReportConfig{}
@@ -35,6 +39,11 @@ func validateReportModules(cfg Config) error {
return err return err
} }
func validateReportDistributorPathOverrides(cfg Config) error {
_, err := traverseReportDistributorPathOverrides(cfg)
return err
}
type reportModuleTraversalOptions struct { type reportModuleTraversalOptions struct {
normalizeOptions bool normalizeOptions bool
updateConfig bool updateConfig bool
@@ -82,6 +91,67 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[
return overrides, nil return overrides, nil
} }
func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, error) {
overrides := map[report.ID][]string{}
if cfg.Reports == nil {
return overrides, nil
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.Distributor.pathTemplatesSet {
continue
}
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
return nil, err
}
overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...)
}
return overrides, nil
}
func validateReportDistributorPathTemplates(reportKey string, reportID report.ID, templates []string) error {
name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey)
_, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValuesForReport(reportID))
return err
}
func sampleDistributorTemplateValues() DistributorTemplateValues {
return sampleDistributorTemplateValuesForReport(report.Storm)
}
func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues {
stormID := ""
if reportID == report.Storm {
stormID = "2026-05-29T0000-2026-05-30T0000"
}
return DistributorTemplateValues{
LocationID: "location",
ReportID: "report",
RunID: "run",
ArtifactGroup: "artifact",
BatchOutputName: "report.md",
ValidStartDate: "2026-05-29",
ValidEndDate: "2026-05-30",
ValidStartTime: "0000",
ValidEndTime: "0000",
ValidStartStamp: "2026-05-29T0000",
ValidEndStamp: "2026-05-30T0000",
StormID: stormID,
}
}
func moduleItemsFromConfig(registry briefing.ModuleRegistry, reportKey string, items []ModuleConfigItem, normalizeOptions bool) ([]module.ConfigItem, []ModuleConfigItem, error) { func moduleItemsFromConfig(registry briefing.ModuleRegistry, reportKey string, items []ModuleConfigItem, normalizeOptions bool) ([]module.ConfigItem, []ModuleConfigItem, error) {
out := make([]module.ConfigItem, 0, len(items)) out := make([]module.ConfigItem, 0, len(items))
normalizedItems := append([]ModuleConfigItem(nil), items...) normalizedItems := append([]ModuleConfigItem(nil), items...)

View File

@@ -12,6 +12,9 @@ func Validate(cfg Config) error {
if err := validateReportModules(cfg); err != nil { if err := validateReportModules(cfg); err != nil {
return err return err
} }
if err := validateReportDistributorPathOverrides(cfg); err != nil {
return err
}
if cfg.WeatherAPI.BaseURL != "" { if cfg.WeatherAPI.BaseURL != "" {
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
@@ -121,22 +124,7 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil { if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil {
return err return err
} }
if len(cfg.ReportPathTemplates) == 0 { values := sampleDistributorTemplateValues()
return fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry when enabled")
}
values := DistributorTemplateValues{
LocationID: "location",
ReportID: "report",
RunID: "run",
ArtifactGroup: "artifact",
BatchOutputName: "report.md",
ValidStartDate: "2026-05-29",
ValidEndDate: "2026-05-30",
ValidStartTime: "0000",
ValidEndTime: "0000",
ValidStartStamp: "2026-05-29T0000",
ValidEndStamp: "2026-05-30T0000",
}
bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values) bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values)
if err != nil { if err != nil {
return err return err
@@ -145,9 +133,6 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil { if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
return err return err
} }
if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil {
return err
}
if err := validateDistributorBatchNotify(cfg.Batch); err != nil { if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
return err return err
} }

View File

@@ -18,16 +18,20 @@ func dailyDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily", ArtifactGroup: "daily",
BatchOutputName: "daily.md", BatchOutputName: "daily.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Daily}, "daily/{valid_start_date}/{run_id}.md",
Modules: dailyModules(), "daily/{valid_start_date}/index.md",
resolve: resolveDaily, },
runIDDisambiguator: validStartDateRunIDDisambiguator, Generated: true,
CompatiblePriorIDs: []ID{Daily},
Modules: dailyModules(),
resolve: resolveDaily,
runIDDisambiguator: validStartDateRunIDDisambiguator,
} }
} }
func dailyModules() []module.ConfigItem { func dailyModules() []module.ConfigItem {
return moduleItems( items := moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
module.NarrativeForecast, module.NarrativeForecast,
@@ -36,13 +40,16 @@ func dailyModules() []module.ConfigItem {
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks, module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, )
items = append(items, areaForecastDiscussionItem("long_term"))
items = append(items, moduleItems(
module.SPCConvectiveDiscussion, module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning, module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
) )...)
return items
} }
func resolveDaily(req ResolveRequest) (timeutil.Period, error) { func resolveDaily(req ResolveRequest) (timeutil.Period, error) {

View File

@@ -46,22 +46,23 @@ const (
) )
type Definition struct { type Definition struct {
ID ID ID ID
Name string Name string
PromptID string PromptID string
GenerationMode GenerationMode GenerationMode GenerationMode
TemplateID string TemplateID string
GeneratedTextSchemaID string GeneratedTextSchemaID string
ComparisonStrategy ComparisonStrategy ComparisonStrategy ComparisonStrategy
ArtifactGroup string ArtifactGroup string
BatchOutputName string BatchOutputName string
Generated bool DistributorPathTemplates []string
CompatiblePriorIDs []ID Generated bool
Modules []module.ConfigItem CompatiblePriorIDs []ID
Morning bool Modules []module.ConfigItem
Evening bool Morning bool
resolve func(ResolveRequest) (timeutil.Period, error) Evening bool
runIDDisambiguator func(Resolved) string resolve func(ResolveRequest) (timeutil.Period, error)
runIDDisambiguator func(Resolved) string
} }
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) { func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {

View File

@@ -20,10 +20,13 @@ func hourlyDefinition() Definition {
ComparisonStrategy: CompareRollingWindow, ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "hourly", ArtifactGroup: "hourly",
BatchOutputName: "hourly.md", BatchOutputName: "hourly.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Hourly}, "hourly/index.md",
Modules: hourlyModules(), },
resolve: resolveHourly, Generated: true,
CompatiblePriorIDs: []ID{Hourly},
Modules: hourlyModules(),
resolve: resolveHourly,
} }
} }
@@ -35,12 +38,7 @@ func hourlyModules() []module.ConfigItem {
{ID: module.PrecipTiming}, {ID: module.PrecipTiming},
{ID: module.AlertDigest}, {ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks}, {ID: module.SPCConvectiveOutlooks},
{ areaForecastDiscussionItem("key_messages", "short_term"),
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: []string{"key_messages", "short_term"},
},
},
{ID: module.SPCConvectiveDiscussion}, {ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory}, {ID: module.WeatherStory},
} }

View File

@@ -520,6 +520,88 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
} }
} }
func TestGeneratedRegistryDefinitionsDeclareDistributorPathDefaults(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if !definition.Generated {
continue
}
if len(definition.DistributorPathTemplates) == 0 {
t.Fatalf("%s DistributorPathTemplates is empty", definition.ID)
}
}
}
func TestRegistryDefinitionsDeclareDefaultDistributorPathTemplates(t *testing.T) {
tests := []struct {
id ID
want []string
}{
{
id: Hourly,
want: []string{
"hourly/index.md",
},
},
{
id: Daily,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
},
},
{
id: Today,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
"today/index.md",
},
},
{
id: Tomorrow,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
"tomorrow/index.md",
},
},
{
id: ThreeDay,
want: []string{
"three-day/{valid_start_date}/{run_id}.md",
"three-day/{valid_start_date}/index.md",
},
},
{
id: Weekend,
want: []string{
"weekend/{valid_start_date}/{run_id}.md",
"weekend/{valid_start_date}/index.md",
},
},
{
id: Storm,
want: []string{
"storm/{storm_id}/{run_id}.md",
"storm/{storm_id}/index.md",
},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !reflect.DeepEqual(definition.DistributorPathTemplates, tt.want) {
t.Fatalf("DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, tt.want)
}
})
}
}
func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
tests := []struct { tests := []struct {
id ID id ID
@@ -682,6 +764,9 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) { if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) {
t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs()) t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs())
} }
if !reflect.DeepEqual(definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates) {
t.Fatalf("overridden DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates)
}
} }
func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) { func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {

View File

@@ -30,8 +30,7 @@ func DefaultRegistry() Registry {
func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) { func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) {
next := Registry{definitions: map[ID]Definition{}} next := Registry{definitions: map[ID]Definition{}}
for id, definition := range r.definitions { for id, definition := range r.definitions {
definition.Modules = append([]module.ConfigItem(nil), definition.Modules...) next.definitions[id] = cloneDefinition(definition)
next.definitions[id] = definition
} }
for id, items := range overrides { for id, items := range overrides {
definition, ok := next.definitions[id] definition, ok := next.definitions[id]
@@ -44,6 +43,12 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
return next, nil return next, nil
} }
func cloneDefinition(definition Definition) Definition {
definition.Modules = cloneModuleItems(definition.Modules)
definition.DistributorPathTemplates = append([]string(nil), definition.DistributorPathTemplates...)
return definition
}
func moduleItems(ids ...module.ID) []module.ConfigItem { func moduleItems(ids ...module.ID) []module.ConfigItem {
items := make([]module.ConfigItem, 0, len(ids)) items := make([]module.ConfigItem, 0, len(ids))
for _, id := range ids { for _, id := range ids {
@@ -52,6 +57,15 @@ func moduleItems(ids ...module.ID) []module.ConfigItem {
return items return items
} }
func areaForecastDiscussionItem(sections ...string) module.ConfigItem {
return module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: append([]string(nil), sections...),
},
}
}
func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem { func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem {
cloned := make([]module.ConfigItem, len(items)) cloned := make([]module.ConfigItem, len(items))
copy(cloned, items) copy(cloned, items)

View File

@@ -17,6 +17,10 @@ func stormDefinition() Definition {
ComparisonStrategy: CompareExplicitWindow, ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm", ArtifactGroup: "storm",
BatchOutputName: "storm.md", BatchOutputName: "storm.md",
DistributorPathTemplates: []string{
"storm/{storm_id}/{run_id}.md",
"storm/{storm_id}/index.md",
},
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{Storm}, CompatiblePriorIDs: []ID{Storm},
Modules: stormModules(), Modules: stormModules(),

View File

@@ -16,6 +16,10 @@ func threeDayDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day", ArtifactGroup: "three-day",
BatchOutputName: "three-day.md", BatchOutputName: "three-day.md",
DistributorPathTemplates: []string{
"three-day/{valid_start_date}/{run_id}.md",
"three-day/{valid_start_date}/index.md",
},
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{ThreeDay}, CompatiblePriorIDs: []ID{ThreeDay},
Modules: threeDayModules(), Modules: threeDayModules(),

View File

@@ -16,11 +16,16 @@ func todayDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "today", ArtifactGroup: "today",
BatchOutputName: "today.md", BatchOutputName: "today.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Today}, "daily/{valid_start_date}/{run_id}.md",
Modules: todayModules(), "daily/{valid_start_date}/index.md",
Morning: true, "today/index.md",
resolve: resolveToday, },
Generated: true,
CompatiblePriorIDs: []ID{Today},
Modules: todayModules(),
Morning: true,
resolve: resolveToday,
} }
} }

View File

@@ -16,11 +16,16 @@ func tomorrowDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "tomorrow", ArtifactGroup: "tomorrow",
BatchOutputName: "tomorrow.md", BatchOutputName: "tomorrow.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Tomorrow}, "daily/{valid_start_date}/{run_id}.md",
Modules: tomorrowModules(), "daily/{valid_start_date}/index.md",
Evening: true, "tomorrow/index.md",
resolve: resolveTomorrow, },
Generated: true,
CompatiblePriorIDs: []ID{Tomorrow},
Modules: tomorrowModules(),
Evening: true,
resolve: resolveTomorrow,
} }
} }

View File

@@ -17,6 +17,10 @@ func weekendDefinition() Definition {
ComparisonStrategy: CompareWeekendWindow, ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend", ArtifactGroup: "weekend",
BatchOutputName: "weekend.md", BatchOutputName: "weekend.md",
DistributorPathTemplates: []string{
"weekend/{valid_start_date}/{run_id}.md",
"weekend/{valid_start_date}/index.md",
},
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{Weekend}, CompatiblePriorIDs: []ID{Weekend},
Modules: weekendModules(), Modules: weekendModules(),

View File

@@ -83,26 +83,25 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
return ArtifactPaths{}, fmt.Errorf("state store is required") return ArtifactPaths{}, fmt.Errorf("state store is required")
} }
metadata := resolved.Metadata() metadata := resolved.Metadata()
if metadata.RunID == "" { if err := validatePathSegment("run id", metadata.RunID); err != nil {
return ArtifactPaths{}, fmt.Errorf("run id is required") return ArtifactPaths{}, err
} }
group := resolved.Definition.ArtifactGroup group := resolved.Definition.ArtifactGroup
if group == "" { if group == "" {
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID) return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID
return ArtifactPaths{ return ArtifactPaths{
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"), ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"), DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"),
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"), Preflight: s.join(s.preflightDir, group, validDate, "render."+metadata.RunID+".json"),
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"), Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"), RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"),
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"), GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"),
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"), GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, "generated_text_result."+metadata.RunID+".json"),
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"), GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"),
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"), RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"),
}, nil }, nil
} }
@@ -149,7 +148,7 @@ func (s *FilesystemStore) BatchDistributorNotificationPath(ref BatchDistributorN
return "", err return "", err
} }
localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02") localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02")
return s.join(s.notificationsDir, "batches", ref.Batch, localDate, ref.BatchRunID+".distributor.json"), nil return s.join(s.notificationsDir, "batches", ref.Batch, localDate, "distributor."+ref.BatchRunID+".json"), nil
} }
func (s *FilesystemStore) SaveBatchDistributorNotification(_ context.Context, ref BatchDistributorNotificationRef, artifact BatchDistributorNotificationArtifact) (string, error) { func (s *FilesystemStore) SaveBatchDistributorNotification(_ context.Context, ref BatchDistributorNotificationRef, artifact BatchDistributorNotificationArtifact) (string, error) {
@@ -272,7 +271,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err) return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
} }
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") { if entry.IsDir() || !isMetadataFilename(entry.Name()) {
continue continue
} }
path := filepath.Join(dir, entry.Name()) path := filepath.Join(dir, entry.Name())
@@ -323,7 +322,7 @@ func (s *FilesystemStore) ListReports(_ context.Context, limit int) ([]ReportRec
if err != nil { if err != nil {
return fmt.Errorf("inspect %q: %w", path, err) return fmt.Errorf("inspect %q: %w", path, err)
} }
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") { if entry.IsDir() || !isMetadataFilename(entry.Name()) {
return nil return nil
} }
record, err := s.reportRecord(path) record, err := s.reportRecord(path)
@@ -517,6 +516,14 @@ 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 { func readJSON(path string, target any) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {

View File

@@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.modules.json"), filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.metadata.json"), filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.data_package.yaml"), filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.render.json"), filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.distributor.json"), filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_2026-05-29.md"), filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"),
} { } {
if !strings.Contains(pathsString(paths), want) { if !strings.Contains(pathsString(paths), want) {
t.Fatalf("paths = %#v, want component %q", paths, want) t.Fatalf("paths = %#v, want component %q", paths, want)
@@ -69,6 +69,20 @@ func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
} }
} }
func TestPathsRejectRunIDPathSeparators(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
resolved.Definition.ID = report.ID("daily/bad")
_, err := store.Paths(resolved)
if err == nil {
t.Fatal("Paths() error = nil, want invalid run id error")
}
if !strings.Contains(err.Error(), "run id must not contain path separators") {
t.Fatalf("error = %q, want run id path separator context", err.Error())
}
}
func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) { func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago") location := mustLoadStateLocation(t, "America/Chicago")
@@ -83,7 +97,7 @@ func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testi
if err != nil { if err != nil {
t.Fatalf("BatchDistributorNotificationPath() error = %v", err) t.Fatalf("BatchDistributorNotificationPath() error = %v", err)
} }
want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "20260618T033000.123456789Z_evening.distributor.json") want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "distributor.20260618T033000.123456789Z_evening.json")
if !strings.Contains(path, want) { if !strings.Contains(path, want) {
t.Fatalf("path = %q, want component %q", path, want) t.Fatalf("path = %q, want component %q", path, want)
} }
@@ -118,13 +132,13 @@ func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
{ {
ReportID: report.Today, ReportID: report.Today,
RunID: "20260617T120000.000000000Z_today", RunID: "20260617T120000.000000000Z_today",
SourcePath: "/workspace/reports/today/20260617T120000.000000000Z_today.md", SourcePath: "/workspace/reports/today/2026-06-17/report.20260617T120000.000000000Z_today.md",
BundlePaths: []string{"2026-06-17/today/report.md"}, BundlePaths: []string{"2026-06-17/today/report.md"},
}, },
{ {
ReportID: report.Daily, ReportID: report.Daily,
RunID: "20260617T120000.000000000Z_daily_2026-06-19", RunID: "20260617T120000.000000000Z_daily_2026-06-19",
SourcePath: "/workspace/reports/daily/20260617T120000.000000000Z_daily_2026-06-19.md", SourcePath: "/workspace/reports/daily/2026-06-19/report.20260617T120000.000000000Z_daily_2026-06-19.md",
BundlePaths: []string{"2026-06-19/daily/report.md"}, BundlePaths: []string{"2026-06-19/daily/report.md"},
}, },
}, },
@@ -148,7 +162,7 @@ func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveBatchDistributorNotification() error = %v", err) t.Fatalf("SaveBatchDistributorNotification() error = %v", err)
} }
wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "20260617T120000.000000000Z_morning.distributor.json") wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "distributor.20260617T120000.000000000Z_morning.json")
if !strings.Contains(path, wantPath) { if !strings.Contains(path, wantPath) {
t.Fatalf("path = %q, want component %q", path, wantPath) t.Fatalf("path = %q, want component %q", path, wantPath)
} }
@@ -302,15 +316,23 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
} }
wants := map[string]string{ wants := map[string]string{
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"), "ModuleSnapshot": filepath.Join("snapshots", tt.group, tt.validDate, "modules."+tt.runID+".json"),
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"), "Metadata": filepath.Join("snapshots", tt.group, tt.validDate, "metadata."+tt.runID+".json"),
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"), "DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, "data_package."+tt.runID+".yaml"),
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"), "Preflight": filepath.Join("preflight", tt.group, tt.validDate, "render."+tt.runID+".json"),
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"), "Notification": filepath.Join("notifications", tt.group, tt.validDate, "distributor."+tt.runID+".json"),
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"), "RenderedReport": filepath.Join("reports", tt.group, tt.validDate, "report."+tt.runID+".md"),
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_raw."+tt.runID+".json"),
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_result."+tt.runID+".json"),
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text."+tt.runID+".json"),
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, "render_context."+tt.runID+".json"),
} }
got := map[string]string{ got := map[string]string{
"ModuleSnapshot": paths.ModuleSnapshot,
"Metadata": paths.Metadata,
"DataPackage": paths.DataPackage, "DataPackage": paths.DataPackage,
"Preflight": paths.Preflight,
"Notification": paths.Notification,
"RenderedReport": paths.RenderedReport, "RenderedReport": paths.RenderedReport,
"GeneratedTextRaw": paths.GeneratedTextRaw, "GeneratedTextRaw": paths.GeneratedTextRaw,
"GeneratedTextResult": paths.GeneratedTextResult, "GeneratedTextResult": paths.GeneratedTextResult,
@@ -610,7 +632,7 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
} }
otherDir := filepath.Join(t.TempDir(), "other-artifacts") otherDir := filepath.Join(t.TempDir(), "other-artifacts")
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json") derivedMetadataPath := filepath.Join(otherDir, "metadata."+resolved.Metadata().RunID+".json")
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
ModuleSnapshot: paths.ModuleSnapshot, ModuleSnapshot: paths.ModuleSnapshot,
@@ -634,11 +656,79 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
} }
} }
func TestListReportsDiscoversNewMetadataFilename(t *testing.T) {
store := newTestStore(t)
older := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
newer := resolveTodayAt(t, "2026-05-29T08:00:00-05:00")
olderPaths := savePriorMetadata(t, store, older, stateBriefingMetadata(older))
newerPaths := savePriorMetadata(t, store, newer, stateBriefingMetadata(newer))
records, err := store.ListReports(context.Background(), 0)
if err != nil {
t.Fatalf("ListReports() error = %v", err)
}
if len(records) != 2 {
t.Fatalf("ListReports() len = %d, want 2: %#v", len(records), records)
}
if records[0].RunID != newer.Metadata().RunID || records[0].MetadataPath != newerPaths.Metadata {
t.Fatalf("first record = %#v, want newer metadata path %q", records[0], newerPaths.Metadata)
}
if records[1].RunID != older.Metadata().RunID || records[1].MetadataPath != olderPaths.Metadata {
t.Fatalf("second record = %#v, want older metadata path %q", records[1], olderPaths.Metadata)
}
metadata, metadataPath, err := store.LoadMetadataByRunID(context.Background(), older.Metadata().RunID)
if err != nil {
t.Fatalf("LoadMetadataByRunID() error = %v", err)
}
if metadata.RunID != older.Metadata().RunID || metadataPath != olderPaths.Metadata {
t.Fatalf("loaded metadata = %#v path %q, want run %q path %q", metadata, metadataPath, older.Metadata().RunID, olderPaths.Metadata)
}
if want := "metadata." + older.Metadata().RunID + ".json"; filepath.Base(metadataPath) != want {
t.Fatalf("metadata filename = %q, want %q", filepath.Base(metadataPath), want)
}
}
func TestListReportsIgnoresNonMetadataJSON(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
paths := savePriorMetadata(t, store, resolved, stateBriefingMetadata(resolved))
for _, path := range []string{
paths.ModuleSnapshot,
paths.GeneratedTextRaw,
paths.GeneratedTextResult,
paths.GeneratedText,
paths.RenderContext,
filepath.Join(filepath.Dir(paths.Metadata), resolved.Metadata().RunID+"."+"metadata.json"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create non-metadata artifact directory: %v", err)
}
if err := os.WriteFile(path, []byte(`{not json`), 0o600); err != nil {
t.Fatalf("write non-metadata artifact %q: %v", path, err)
}
}
records, err := store.ListReports(context.Background(), 0)
if err != nil {
t.Fatalf("ListReports() error = %v", err)
}
if len(records) != 1 {
t.Fatalf("ListReports() len = %d, want only metadata record: %#v", len(records), records)
}
if records[0].MetadataPath != paths.Metadata {
t.Fatalf("MetadataPath = %q, want %q", records[0].MetadataPath, paths.Metadata)
}
}
func TestFindPriorSnapshot(t *testing.T) { func TestFindPriorSnapshot(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first)) paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
if want := "metadata." + first.Metadata().RunID + ".json"; filepath.Base(paths.Metadata) != want {
t.Fatalf("metadata filename = %q, want %q", filepath.Base(paths.Metadata), want)
}
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {