43 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
455cc67d4c Use neutral endpoint in example config 2026-06-17 21:14:20 +00:00
dd3133ee2a Validate batch distributor uploads 2026-06-17 21:12:55 +00:00
b3637cddd6 Document batch distributor uploads 2026-06-17 21:11:28 +00:00
662db5e511 Report batch notifications in CLI output 2026-06-17 21:03:14 +00:00
2ef91cf1b1 Upload batch distributor notifications 2026-06-17 21:00:03 +00:00
1d2f176977 Suppress per-report notifications during batch runs 2026-06-17 20:52:27 +00:00
2b3bcdd4f1 Build batch distributor upload requests 2026-06-17 20:48:38 +00:00
a82f03feb8 Add batch notification app identity types 2026-06-17 20:44:34 +00:00
f1d4e38414 Add batch distributor notification state artifacts 2026-06-17 20:39:22 +00:00
32060bd370 Add batch distributor notification config 2026-06-17 20:36:02 +00:00
133f83f4ce Create a roadmap and implementation plan for batch distributor uploads 2026-06-17 15:31:36 -05:00
42f0e16b02 Fix to ensure unique document IDs when batch reports are generated 2026-06-17 14:39:53 -05:00
a2f0a2fc36 Refresh documentation for current collection behavior 2026-06-17 16:15:23 +00:00
9f552cff6b Validate batch collection migration 2026-06-17 16:12:17 +00:00
b913194fb4 Update batch collection documentation 2026-06-17 16:11:09 +00:00
3eccafad6b Remove static batch report resolution 2026-06-17 16:07:40 +00:00
a9d87bdbaa Reuse collected batch data 2026-06-17 16:04:14 +00:00
6f9255105d Add data-aware batch planning 2026-06-17 15:59:13 +00:00
c04e3c5599 Add daily coverage planning helper 2026-06-17 15:53:12 +00:00
f15315f1b9 Require collected data for report generation 2026-06-17 15:50:04 +00:00
0ef6cd567e Add app collection seam 2026-06-17 15:46:01 +00:00
b308ff4d6b Add canonical weather collection package 2026-06-17 15:42:00 +00:00
58 changed files with 6295 additions and 1802 deletions

View File

@@ -9,7 +9,7 @@ batches, and inspects stored artifacts.
weatherreporter generate today --out ./today.md weatherreporter generate today --out ./today.md
``` ```
This loads configuration, fetches weather data, writes managed workspace This loads configuration, collects weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs structured artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, validates generated text, renders the embedded Today `scriptorium run`, validates generated text, renders the embedded Today
template, and writes an extra Markdown copy to `./today.md`. If distributor template, and writes an extra Markdown copy to `./today.md`. If distributor
@@ -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,34 +37,128 @@ 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
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a `--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
command error and stops before weather data is fetched. `generate hourly` command error and stops before weather data is collected. `generate hourly`
covers the next six hours in the effective report timezone and does not accept covers the next six hours in the effective report timezone and does not accept
date or event window flags. `generate storm` requires explicit event-window date or event window flags. `generate storm` requires explicit event-window
bounds with `--start` and `--end`. bounds with `--start` and `--end`.
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
except on Sunday. `run evening` generates the Tomorrow Report. Batch for each later future local civil day with complete hourly forecast coverage.
runs continue independent reports after a failure, print a JSON summary to `run evening` generates Tomorrow Report and the same eligible future Daily
stdout, write compact status lines to stderr, and return nonzero when any report reports. Future Daily expansion starts with the day after tomorrow and skips
failed. `--out-dir` writes extra Markdown copies for the operator; distributor days that do not have every hourly forecast period for the local civil day.
notification uses each managed report path, not the extra copies. When Batch commands collect weather data once before planning; a collection failure
notification is enabled, batch summaries and status lines include notification stops the batch before any report is generated. Batch runs continue independent
status, accepted distributor run ID, or notification error fields for each reports after a later report failure, print a JSON summary to stdout, write
attempted report. compact status lines to stderr, and return nonzero when any report failed.
`--out-dir` writes extra Markdown copies for the operator; distributor
notification uses managed report paths, not the extra copies. Today and
Tomorrow use their report default copy names, and dynamic Daily copies use
`daily-YYYY-MM-DD.md`. When distributor and batch notification are enabled, a
fully successful batch uploads one distributor bundle after report generation
finishes. The JSON summary exposes that upload as a top-level `notification`
object, and stderr includes one `batchNotification` status line. If any planned
report fails, the batch notification is skipped for the whole batch.
Hourly Report is explicit only; it is not included in `run morning` or `run Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
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 fetch 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
@@ -74,6 +168,7 @@ They do not fetch 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`.
@@ -97,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

@@ -29,8 +29,8 @@ weather_api:
base_url: https://weather.api.example.com/ base_url: https://weather.api.example.com/
``` ```
`weather_api.base_url` is required for commands that fetch weather data. Other `weather_api.base_url` is required for commands that collect weather data.
fields fall back to defaults. Other fields fall back to defaults.
## Production-Oriented Config ## Production-Oriented Config
@@ -41,7 +41,7 @@ config test suite.
### `weather_api` ### `weather_api`
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows. - `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
- `timeout`: HTTP timeout duration. Default: `10s`. - `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`. - `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`. - `units`: Weather API units query value. Default: `us`.
@@ -78,10 +78,13 @@ missing directories, and unreadable files fail config loading.
### `notify` ### `notify`
`notify.distributor` controls distributor notification after successful report `notify.distributor` controls distributor uploads after successful report
generation. It is disabled by default and does not add CLI flags. When enabled, rendering. It is disabled by default and does not add CLI flags. When enabled,
weatherreporter uploads one distributor bundle per generated report after `generate <report>` uploads one distributor bundle for the generated report
report rendering succeeds and final metadata is saved. after final metadata is saved. `run morning` and `run evening` use
`notify.distributor.batch`: when batch notification is enabled and every
planned report succeeds, weatherreporter uploads one distributor bundle that
contains all managed Markdown reports from that batch.
- `enabled`: whether distributor notification config is active. Default: - `enabled`: whether distributor notification config is active. Default:
`false`. `false`.
@@ -92,33 +95,63 @@ report rendering succeeds and final metadata is saved.
- `timeout`: distributor operation timeout. Must be greater than zero when - `timeout`: distributor operation timeout. Must be greater than zero when
enabled. Default: `30s`. enabled. Default: `30s`.
- `failure_policy`: must be `error` when enabled. Default: `error`. - `failure_policy`: must be `error` when enabled. Default: `error`.
- `pipeline_id_template`: template for the distributor pipeline ID. Required - `pipeline_id_template`: template for single-report distributor pipeline IDs.
when enabled. Default: empty. Required when enabled. Default: empty.
- `bundle_id_template`: template for distributor bundle IDs. Default: - `bundle_id_template`: template for single-report distributor bundle IDs.
`weatherreporter.{location_id}.{report_id}`. Default: `weatherreporter.{location_id}.{report_id}`.
- `idempotency_key_template`: template for distributor idempotency keys. - `idempotency_key_template`: template for single-report distributor
Default: `{bundle_id}.{run_id}`. idempotency keys. Default: `{bundle_id}.{run_id}`.
- `report_path_templates`: ordered list of templates for Markdown report paths - `batch.enabled`: whether batch distributor notification config is active
inside the distributor bundle. Each rendered path maps to the same managed when distributor notification is enabled. Default: `true`.
Markdown report source. Default: - `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
```yaml Required when distributor notification and batch notification are enabled.
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md" Default: `weatherreporter`.
``` - `batch.bundle_id_template`: template for batch distributor bundle IDs.
Required when distributor notification and batch notification are enabled.
Default: `weatherreporter.{location_id}.{batch}`.
- `batch.idempotency_key_template`: template for batch distributor idempotency
keys. Required when distributor notification and batch notification are
enabled. Default: `{bundle_id}.{batch_run_id}`.
Supported template variables are `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
report stream. The rendered idempotency key is the per-run retry identity. report stream. The rendered idempotency key is the per-run retry identity.
Batch templates support `location_id`, `batch`, `batch_run_id`, and
`batch_started_date`. Batch idempotency templates may also use `bundle_id`.
`batch_started_date` is the batch start date in the effective report timezone.
Batch bundle IDs identify a logical batch stream; batch idempotency keys
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`. 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.
Managed Markdown report paths are the only upload source files; copies written
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
@@ -152,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`
@@ -194,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
@@ -209,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
@@ -250,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

@@ -5,11 +5,12 @@ This document describes the workflow coordinator in `internal/app`.
## Purpose ## Purpose
`internal/app` coordinates the top-level use cases after CLI parsing and config `internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data, loading are complete. It resolves report definitions, collects weather data
builds collected and derived facts, builds module snapshots and prompt-input through `internal/collect`, builds collected and derived facts, builds module
artifacts, invokes Scriptorium through the adapter boundary, optionally snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
notifies distributor through an app-owned notifier boundary, persists managed boundary, optionally notifies distributor through an app-owned notifier
state, runs batches, and reads existing artifacts for inspection. boundary, persists managed state, runs batches, and reads existing artifacts
for inspection.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,12 +18,12 @@ Inputs:
- `GenerateRequest` for one report command - `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands - `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows - `FetchBundleRequest` for explicit bundle collection and save workflows
- `ReportRequest` for single-report generation - `ReportRequest` for single-report generation
- resolved report definitions from `internal/report` - resolved report definitions from `internal/report`
- weather data bundles from `internal/adapters/weatherapi` - collection results from `internal/collect`
- prior snapshots loaded from `internal/state` - prior snapshots loaded from `internal/state`
- optional renderer, notifier, and state-store fakes for tests - optional collector, renderer, notifier, and state-store fakes for tests
Outputs: Outputs:
@@ -31,8 +32,8 @@ Outputs:
result details, generated-text artifact paths when applicable, and result details, generated-text artifact paths when applicable, and
notification result when attempted notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and - batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted one top-level batch notification result when attempted or skipped
- saved Weather API bundle JSON for fetch workflows - saved Weather API bundle JSON for explicit bundle collection workflows
- inspection JSON values for reports, metadata, module snapshots, data - inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance packages, prior snapshots, and source provenance
@@ -45,15 +46,18 @@ construct Scriptorium argv.
Report selection and report identity policy come from `internal/report`. Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`. Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor upstream collection stays in `internal/collect`. Scriptorium subprocess
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
persisted metadata stay in `internal/state`. stays in `internal/adapters/distributor`. Filesystem layout and persisted
metadata stay in `internal/state`.
## Data Flow Terms ## Data Flow Terms
- `CollectedFacts` are normalized source facts fetched once from Weather API - `collect.Result` is the app-facing upstream collection result. It carries the
and made available to derivation and module builders. normalized `weatherdata.Bundle` used by report generation.
- `CollectedFacts` are normalized source facts derived from a collected Weather
API bundle and made available to derivation and module builders.
- `DerivedFacts` are deterministic calculations over collected facts, the - `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows. resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected - `module.Output` values are ordered deterministic stanzas built from collected
@@ -70,40 +74,45 @@ persisted metadata stay in `internal/state`.
- `workspace.*` for filesystem state - `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization - `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds - `recent_change.*` for structured Recent Changes thresholds
- `notify.distributor.*` for optional notification after report generation - `notify.distributor.*` for optional single-report and batch notification
after report generation
Output copy flags are command request fields. They are not configuration Output copy flags are command request fields. They are not configuration
defaults. defaults.
## Generation Workflow ## Generation Workflow
Single-report generation shares this setup: Single-report commands validate the report command, collect once through
`internal/collect`, resolve the requested report, and pass the resolved report
plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
callers.
1. Resolve the command report to a `report.Resolved` value. `GenerateReport` then uses this setup:
2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`. 1. Create or use a filesystem store.
4. Fetch a Weather API bundle. 2. Locate any prior compatible snapshot through `internal/state`.
5. Build collected and derived facts once. 3. Build collected and derived facts from the supplied collection.
6. Execute configured modules and save the module snapshot. 4. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current module snapshots. 5. Compute Recent Changes from structured prior and current module snapshots.
8. Build and save the YAML Scriptorium `data_package`. 6. Build and save the YAML Scriptorium `data_package`.
9. Run Scriptorium render preflight. 7. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available. 8. Save preflight JSON when a render result is available.
11. Save metadata for inspection. 9. Save metadata for inspection.
For `scriptorium_markdown` reports, generation then: For `scriptorium_markdown` reports, generation then:
12. Runs Scriptorium report generation to the managed report path. 10. Runs Scriptorium report generation to the managed report path.
For `generated_text_template` reports, generation then: For `generated_text_template` reports, generation then:
12. Looks up the generated-text catalog entry for the report schema/template 10. Looks up the generated-text catalog entry for the report schema/template
IDs. IDs.
13. Runs structured Scriptorium generation to the raw generated-text JSON path. 11. Runs structured Scriptorium generation to the raw generated-text JSON path.
14. Saves the structured Scriptorium run result. 12. Saves the structured Scriptorium run result.
15. Validates and saves normalized generated text. 13. Validates and saves normalized generated text.
16. Builds and saves a typed render context. 14. Builds and saves a typed render context.
17. Renders Markdown from the embedded template to the managed report path. 15. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization: After either mode has produced a managed Markdown report, shared finalization:
@@ -119,7 +128,7 @@ After either mode has produced a managed Markdown report, shared finalization:
If render preflight returns both a result and an error, preflight JSON and If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after Weather API, metadata remain inspectable. Notification is not attempted after collection,
module snapshot, prompt input, render, Scriptorium run, or metadata-save module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures. failures.
Generated-text report failures are returned with report ID, RunID, and the Generated-text report failures are returned with report ID, RunID, and the
@@ -134,17 +143,38 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except `run morning` collects once, plans Today Report, Tomorrow Report, and eligible
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated future Daily Reports from the collected hourly forecast, then passes the same
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled collection into each report generation. `run evening` uses the same collection
batches. Batch output copy names come from report definitions. Batch generation and planning rules, but starts with Tomorrow Report. Future Daily reports start
continues independent reports after a failure, records each result, writes with the day after tomorrow and require complete hourly forecast coverage for
compact status lines to stderr, emits a JSON summary to stdout, and returns an the target local civil day. Dynamic Daily `--out-dir` copies use
aggregate error when any report failed. When notification is enabled, each `daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
successfully generated report is notified independently. Notification failure A collection failure stops the batch before planning or report generation.
marks that report failed, records notification fields in the batch result, and After planning succeeds, batch generation continues independent reports after a
does not stop later reports. `--out-dir` copies are never used as notification failure, records each result, writes compact status lines to stderr, emits a
source files. JSON summary to stdout, and returns an aggregate error when any report failed.
Batch report generation suppresses per-report distributor notification. After
all planned reports finish, app orchestration evaluates batch notification:
1. If distributor notification is disabled, the batch notification result is
omitted.
2. If batch notification is disabled, the batch notification result is omitted
and there is no per-report fallback upload.
3. If any planned report failed, the batch notification result is `skipped`
with reason `one or more reports failed`, and distributor is not called.
4. If every report succeeded, app orchestration renders batch pipeline, bundle
ID, and idempotency key templates, renders report-specific distributor
paths for each included report, validates every managed source path and
bundle path, checks duplicate bundle paths across the batch, calls the
notifier once with a multi-file request, and saves a batch notification
debug artifact.
Batch notification failure records a top-level failed notification, increments
the aggregate batch failure count, and returns an aggregate batch error without
marking individual report items failed. `--out-dir` copies are never used as
notification source files.
## Inspection Workflow ## Inspection Workflow
@@ -155,15 +185,19 @@ inspection view.
## Failure Behavior ## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data. - Resolve errors stop the requested workflow before collection.
- Weather API and module execution errors stop that report before Scriptorium - Collection and module execution errors stop that report before Scriptorium
runs. runs.
- Prompt input validation fails before render preflight. - Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context. - Render and run errors preserve Scriptorium stderr and exit-code context.
- 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.
- Notification errors are wrapped with report ID, RunID, and managed report path - Single-report notification errors are wrapped with report ID, RunID, and
context and are recorded separately in batch results. 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
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.
- Batch failures are recorded per report and surfaced through an aggregate - Batch failures are recorded per report and surfaced through an aggregate
batch error. batch error.
@@ -173,12 +207,17 @@ inspection view.
Inspect: Inspect:
- `internal/app/app_test.go` - `internal/app/app_test.go`
- `internal/app/batch_plan_test.go`
- `internal/collect/collect_test.go`
- `internal/cli/root_test.go` - `internal/cli/root_test.go`
- `internal/state/filesystem_test.go` - `internal/state/filesystem_test.go`
## Invariants ## Invariants
- Report behavior is resolved through `internal/report`. - Report behavior is resolved through `internal/report`.
- Generate and run commands collect once before report generation.
- Batch planning is app-owned because future Daily membership depends on
collected hourly forecast coverage.
- Generated reports use the same app request and result types regardless of - Generated reports use the same app request and result types regardless of
report ID. report ID.
- Render preflight precedes Scriptorium report generation. - Render preflight precedes Scriptorium report generation.
@@ -186,5 +225,9 @@ Inspect:
a raw data package. a raw data package.
- Recent Changes are computed from structured module snapshots. - Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured - Single-report distributor notification maps the managed Markdown report path
bundle paths; extra output copies are not upload sources. to configured bundle paths.
- Batch distributor notification maps each included managed Markdown report
path to bundle paths rendered for that report and uploads once for the
batch.
- Extra output copies are not upload sources.

View File

@@ -77,7 +77,7 @@ remain in report metadata, source provenance, and integration artifacts.
- This package selects and shapes already-collected weather facts for prompts. - This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option - It validates module composition against report compatibility and option
types. types.
- It does not fetch weather data, compare prior snapshots, write module - It does not collect weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata. metadata.
@@ -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`.

58
docs/internal/collect.md Normal file
View File

@@ -0,0 +1,58 @@
# Collection Internals
This document describes the app-facing upstream collection boundary in
`internal/collect`.
## Purpose
`internal/collect` is the canonical package used by app workflows to collect
upstream Weather API data. It constructs the Weather API adapter, fetches a
normalized bundle, and returns that bundle without applying report selection or
batch policy.
## Contract
Inputs:
- `collect.Request`, containing the effective `config.Config`
- `context.Context` for cancellation
Output:
- `collect.Result`, containing `*weatherdata.Bundle`
`Run` returns an actionable error when Weather API adapter construction or
bundle fetch fails. The package does not derive `facts.CollectedFacts`, build
modules, resolve report periods, select reports, write state, invoke
Scriptorium, or notify distributor.
## App Usage
`internal/app` owns a narrow `Collector` interface for orchestration tests. The
default implementation calls `collect.Run`.
Single-report generation collects once, resolves the requested report, and
passes the explicit collection into report generation. Batch generation
collects once before planning and passes the same collection into each planned
report. If collection returns no bundle, app orchestration returns an error
before report generation.
## Boundaries
Weather API HTTP details stay in `internal/adapters/weatherapi`. The collection
package returns normalized `weatherdata` only. It must not know about report
IDs, prompt IDs, batch names, Daily eligibility, module composition, Recent
Changes, state paths, or Scriptorium arguments.
## Tests
Inspect:
- `internal/collect/collect_test.go`
- `internal/app/app_test.go`
## Invariants
- App-facing Weather API collection goes through `internal/collect`.
- Collection returns normalized source data, not report facts or prompt input.
- Report and batch policy belongs outside `internal/collect`.

View File

@@ -6,10 +6,10 @@ This document describes the distributor upload adapter in
## Purpose ## Purpose
The adapter submits generated weatherreporter Markdown reports to a configured The adapter submits generated weatherreporter Markdown reports to a configured
distributor HTTP upload endpoint. It isolates distributor package types, distributor HTTP upload endpoint. It supports one or more file mappings per
token-env lookup, upload client construction, source-bundle file mapping, upload request. It isolates distributor package types, token-env lookup, upload
timeout handling, status polling, and upload error wrapping from app client construction, source-bundle file mapping, timeout handling, status
orchestration. polling, and upload error wrapping from app orchestration.
## Inputs And Outputs ## Inputs And Outputs
@@ -21,7 +21,7 @@ Inputs:
- pipeline ID - pipeline ID
- bundle ID - bundle ID
- idempotency key - idempotency key
- source Markdown report path and bundle-relative path mappings - source Markdown report paths and bundle-relative path mappings
- bundle created timestamp - bundle created timestamp
- context for cancellation - context for cancellation
@@ -40,8 +40,9 @@ Outputs:
The app layer passes weatherreporter-owned request values to the adapter. The The app layer passes weatherreporter-owned request values to the adapter. The
adapter does not choose report types, render templates, select output copies, adapter does not choose report types, render templates, select output copies,
configure destinations, wait for downstream publication, transform Markdown, or decide whether an upload represents one report or a batch, configure
persist notification state. destinations, wait for downstream publication, transform Markdown, or persist
notification state.
Full upstream distributor package and HTTP contract details stay under Full upstream distributor package and HTTP contract details stay under
`docs/integrations/distributor/`. `docs/integrations/distributor/`.
@@ -54,13 +55,22 @@ The adapter is built from `notify.distributor` config:
- `token_env` - `token_env`
- `timeout` - `timeout`
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths The app layer renders single-report pipeline ID, bundle ID, idempotency key,
from: 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
idempotency key from `notify.distributor.batch.*`, resolves report-specific
path templates once per included report, and passes the resulting multi-file
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.
@@ -70,9 +80,9 @@ after config loading and `secrets.directory` processing.
The adapter calls distributor `UploadFiles` with one or more file mappings: The adapter calls distributor `UploadFiles` with one or more file mappings:
- pipeline ID: the rendered distributor workflow selector - pipeline ID: the rendered distributor workflow selector
- source path: the managed Markdown report path selected by app orchestration - source paths: managed Markdown report paths selected by app orchestration
- bundle paths: rendered bundle-relative report paths - bundle paths: rendered bundle-relative report paths for each source
- created: the report generation timestamp - created: the report or batch generation timestamp
The adapter creates a distributor upload client with the configured endpoint, The adapter creates a distributor upload client with the configured endpoint,
bearer token, and timeout-backed HTTP client. It also wraps the upload context bearer token, and timeout-backed HTTP client. It also wraps the upload context
@@ -115,7 +125,8 @@ distributor service.
## Invariants ## Invariants
- Distributor package types do not leak outside the adapter. - Distributor package types do not leak outside the adapter.
- Only the managed Markdown report is uploaded. - Only managed Markdown report paths selected by app orchestration are
uploaded.
- The adapter never scans the workspace. - The adapter never scans the workspace.
- Token values are not included in errors, CLI output, metadata, docs, or - Token values are not included in errors, CLI output, metadata, docs, or
examples. examples.

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
@@ -250,7 +251,7 @@ meet the severity threshold.
- This package owns module identifiers, config item envelopes, output - This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup. envelopes, snapshot validation, and typed stanza lookup.
- It does not define report IDs, execute builders, fetch weather data, derive - It does not define report IDs, execute builders, collect weather data, derive
forecast facts, write state, or invoke Scriptorium. forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior ## State Or Manifest Behavior

View File

@@ -128,7 +128,7 @@ Current categories are:
- This package owns prompt package schema, YAML marshaling, YAML loading, and - This package owns prompt package schema, YAML marshaling, YAML loading, and
validation. validation.
- It does not fetch weather data, derive forecast summaries, execute modules, - It does not collect weather data, derive forecast summaries, execute modules,
choose module prompt export shapes, find prior snapshots, compare changes, choose module prompt export shapes, find prior snapshots, compare changes,
choose artifact paths, or invoke Scriptorium. choose artifact paths, or invoke Scriptorium.

View File

@@ -1,7 +1,7 @@
# Report Registry Internals # Report Registry Internals
This document describes report identity, valid-period resolution, batch This document describes report identity, valid-period resolution, output
membership, output naming, artifact grouping, and comparison declarations in naming, artifact grouping, batch command names, and comparison declarations in
`internal/report`. `internal/report`.
## Purpose ## Purpose
@@ -24,7 +24,6 @@ Each report definition declares:
- batch output copy filename - batch output copy filename
- generated-report eligibility - generated-report eligibility
- prior-report compatibility list - prior-report compatibility list
- morning or evening batch membership
- default ordered module composition - default ordered module composition
Report-owned helpers map public command names and config keys to report IDs. Report-owned helpers map public command names and config keys to report IDs.
@@ -69,8 +68,7 @@ All report definitions are eligible for generation.
not a configuration field. not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight - 3-Day Outlook covers the interval from generation time through local midnight
three days later. three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for - Weekend Outlook covers the upcoming weekend window.
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller. - Storm Report covers an explicit event window supplied by the caller.
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
@@ -80,8 +78,9 @@ must be after start time.
## Boundaries ## Boundaries
`internal/report` defines report metadata, public report names, batch command `internal/report` defines report metadata, public report names, batch command
names, and time coverage. It does not fetch weather data, build module values, names, output naming, and time coverage. It does not collect weather data, plan
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium. batch membership, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
The CLI parses flags and command structure, then uses report-owned helpers for The CLI parses flags and command structure, then uses report-owned helpers for
report and batch command names. Config loading uses report-owned helpers for report and batch command names. Config loading uses report-owned helpers for
@@ -94,11 +93,16 @@ output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`. `hourly`, or descriptive names such as `three_day_outlook`.
## Batch Membership ## Batch Commands
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook `internal/report` owns the public batch command names `morning` and `evening`
except on Sunday. Evening batches include Tomorrow Report. Daily Report and and validates them through `BatchForCommandName`. Data-dependent batch
Hourly Report are not part of a scheduled batch. membership is owned by `internal/app`, because it depends on collected hourly
forecast coverage.
Report definitions still declare default batch output copy filenames. App
batch planning uses those filenames for fixed report entries and supplies
date-qualified names for dynamic Daily entries.
## State And App Usage ## State And App Usage
@@ -135,3 +139,5 @@ Inspect:
- Artifact grouping, batch output filenames, generated-report eligibility, - Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy default module composition, comparison compatibility, and comparison strategy
are declared by report definition. are declared by report definition.
- App-owned batch planning uses report definitions but does not live in the
report registry.

View File

@@ -43,7 +43,7 @@ Generated-text prompt sources are maintained under
## Boundaries ## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not fetch weather data, build module outputs, template execution. It does not collect weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions, validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor. write artifacts, invoke Scriptorium, or notify distributor.

View File

@@ -34,7 +34,7 @@ Outputs:
`internal/adapters/scriptorium` owns Scriptorium command construction and `internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input, subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata. collect weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render, Markdown run, and The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned structured generated-text run operations. State persistence uses state-owned

View File

@@ -31,6 +31,7 @@ Outputs:
- render context JSON path - render context JSON path
- managed Markdown report path - managed Markdown report path
- metadata JSON path - metadata JSON path
- distributor notification debug artifact paths
- prior comparable snapshot metadata - prior comparable snapshot metadata
- loaded module snapshot, data package, generated text, generated-text run - loaded module snapshot, data package, generated text, generated-text run
result, or render context result, or render context
@@ -61,20 +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
reports/<artifact_group>/<run_id>.md notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
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
@@ -85,6 +88,10 @@ Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory. walks metadata files under the snapshots directory.
Batch notification artifacts are stored under the notifications tree rather
than report metadata because they describe a batch-level upload. The date
directory is the batch start date in the effective report timezone.
## Prior Lookup ## Prior Lookup
Prior snapshot lookup reads stored metadata through the shared lookup path and Prior snapshot lookup reads stored metadata through the shared lookup path and
@@ -118,6 +125,27 @@ atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors. run status, and redacted errors.
Single-report notification artifacts use schema version
`weatherreporter.distributor_notification.v1` and record one managed source
path plus that source's bundle paths. Batch notification artifacts use schema
version `weatherreporter.batch_distributor_notification.v1` and record:
- `batch`
- `batchRunId`
- `attemptedAt`
- `endpoint`
- `pipelineId`
- `bundleId`
- `idempotencyKey`
- `bundleCreated`
- `includedReports`, each with `reportId`, `runId`, `sourcePath`, and
`bundlePaths`
- `status`
- `upload`
- `runStatus`
- `statusError`
- `error`
Inspection helpers read existing metadata, module snapshot, data package, Inspection helpers read existing metadata, module snapshot, data package,
generated text, generated-text run result, and render context files. Missing generated text, generated-text run result, and render context files. Missing
metadata directories return no inspection records or no prior snapshot rather metadata directories return no inspection records or no prior snapshot rather
@@ -145,4 +173,6 @@ Inspect:
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module - Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata. snapshots and metadata.
- Batch notification artifacts live under `notifications/batches` and are not
linked from report metadata.
- Prior lookup is based on structured metadata, not rendered report text. - Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -18,11 +18,11 @@ weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
``` ```
Generation commands resolve a report period, fetch a Weather API bundle, build Generation commands resolve a report period, collect a Weather API bundle,
a rich JSON module snapshot, build a curated YAML prompt input data package, run build a rich JSON module snapshot, build a curated YAML prompt input data
`scriptorium render`, and write managed artifacts under the configured package, run `scriptorium render`, and write managed artifacts under the
workspace. Markdown-path reports then run `scriptorium run` directly to the configured workspace. Markdown-path reports then run `scriptorium run` directly
managed Markdown report path. to the managed Markdown report path.
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly` `generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run` use the generated-text-template workflow. They run structured `scriptorium run`
@@ -31,13 +31,15 @@ and render the managed Markdown report from embedded templates. `generate
daily` requires `--date YYYY-MM-DD` for the selected local civil day. daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate `generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not part of scheduled morning or evening effective report timezone and is not included in `run morning` or
batches. `run evening`.
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:
@@ -46,16 +48,26 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
except on Sunday. `run evening` generates the Tomorrow Report. Batch for each later future local civil day with complete hourly forecast coverage.
commands print a JSON summary to stdout, write compact per-report status lines `run evening` generates Tomorrow Report and the same eligible future Daily
to stderr, continue independent reports after one report fails, and return reports. Future Daily expansion starts with the day after tomorrow. A Daily
nonzero when any report failed. When notification is configured, the summary and report is eligible only when the collected hourly forecast contains every
status lines include notification status, accepted distributor run ID, or hourly period for that local civil day; partial days are skipped. Batch commands
notification error fields for each attempted report. `--out-dir PATH` writes collect weather data once before planning, and a collection failure stops the
extra Markdown copies using report default filenames such as `today.md`, batch before any report is generated.
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
distributor upload sources. After planning succeeds, batch commands print a JSON summary to stdout, write
compact per-report status lines to stderr, continue independent reports after
one report fails, and return nonzero when any report failed. Batch commands do
not upload each report independently. When distributor notification and batch
notification are enabled, weatherreporter uploads one distributor bundle only
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
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
distributor upload sources. Use `--quiet` to suppress successful batch summary
and status output; failures still return nonzero.
## Filesystem Layout ## Filesystem Layout
@@ -63,146 +75,47 @@ 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/
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
RunIDs are based on generation time plus report ID: RunIDs are based on generation time plus report ID. Reports that can be
generated more than once in a single command may append a report-specific
disambiguator. Daily appends the local valid date so multiple dynamic Daily
reports in one batch have distinct managed artifacts:
```text ```text
20260529T100000.123456789Z_daily 20260529T100000.123456789Z_daily_2026-05-31
20260529T100000.123456789Z_today 20260529T100000.123456789Z_today
``` ```
Batch notification RunIDs use the batch start timestamp plus the batch command
name:
```text
20260529T100000.123456789Z_morning
20260529T220000.123456789Z_evening
```
Each generated report writes metadata that links: Each generated report writes metadata that links:
- RunID, report ID, variant, and prompt ID - RunID, report ID, variant, and prompt ID
@@ -216,19 +129,32 @@ Each generated report writes metadata that links:
generated-text-template reports generated-text-template reports
- distributor notification debug artifact path, when notification is attempted - distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification Batch summaries include report status, error text when applicable, valid
outcome when attempted, valid period, and known artifact paths for each period, and known artifact paths for each attempted report. Single-report
attempted report. Notification fields are `notificationStatus`, notification fields on report items are empty for batch commands. When a batch
`notificationRunId`, and `notificationError`. notification is attempted, skipped, or fails, the summary includes one
top-level `notification` object with fields such as `status`, `reason`,
`runId`, `pipelineId`, `bundleId`, `idempotencyKey`, `path`,
`includedReports`, and `error`.
## Distributor Notification ## Distributor Notification
Distributor notification is configured with `notify.distributor` and is Distributor notification is configured with `notify.distributor` and is
disabled by default. When enabled, weatherreporter uploads the managed Markdown disabled by default. For `generate <report>`, weatherreporter uploads the
report path recorded in the report result and metadata. That single source file managed Markdown report path recorded in the report result and metadata. That
can be mapped to one or more configured bundle paths. By default, it is mapped single source file is mapped to report-specific bundle paths. Extra copies
to one dated report path. Extra copies written by `--out` or `--out-dir` are written by `--out` or `--out-dir` are operator conveniences only.
operator conveniences only.
For `run morning` and `run evening`, per-report notification is suppressed. If
`notify.distributor.enabled` and `notify.distributor.batch.enabled` are both
true, the batch uploads once after all reports finish successfully. The upload
contains one file mapping set per included report. Each mapping uses the
managed Markdown report as the source and report-specific path templates for
that report. All rendered bundle paths across the batch must be unique. If any
report fails, weatherreporter records a top-level
notification status of `skipped` with reason `one or more reports failed` and
does not call distributor. If batch notification is disabled, run commands do
not fall back to per-report uploads.
The rendered pipeline ID selects the configured distributor `http_upload` The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The default bundle ID is a stable logical source identity derived from workflow. The default bundle ID is a stable logical source identity derived from
@@ -238,31 +164,40 @@ producer name, location ID, and report ID:
weatherreporter.{location_id}.{report_id} weatherreporter.{location_id}.{report_id}
``` ```
The default idempotency key appends RunID to the rendered bundle ID so each The default single-report idempotency key appends RunID to the rendered bundle
report generation has a distinct retry identity. The default bundle path uses ID so each report generation has a distinct retry identity. The default bundle
the valid-period start date, artifact group, and RunID. Distributor owns path uses the valid-period start date, artifact group, and RunID. Batch bundle
destination merge, retention, and derived snapshot behavior such as `latest`. IDs default to `weatherreporter.{location_id}.{batch}`, and batch idempotency
For Daily, the default report ID and artifact group values are both `daily`, keys default to `{bundle_id}.{batch_run_id}`. Distributor owns destination
and the default output filename value is `daily.md`. merge, retention, and derived snapshot behavior such as `latest`. For Daily,
For Today, the default report ID and artifact group values are both `today`, the default report ID and artifact group values are both `daily`, and the
and the batch output filename value is `today.md`. default output filename value is `daily.md`. For Today, the default report ID
and artifact group values are both `today`, and the batch output filename value
is `today.md`.
Notification happens after final metadata save for generated reports. Weather Single-report notification happens after final metadata save for generated
API, module snapshot, data-package, render preflight, Scriptorium run, reports. Batch notification happens after all planned reports have finished and
generated-text validation, template rendering, and metadata-save failures do only when all report generations succeeded. Collection, module snapshot,
not trigger notification. A notification failure fails that report. data-package, render preflight, Scriptorium run, generated-text validation,
In a batch, other reports continue, the failed report includes notification template rendering, and metadata-save failures do not trigger notification. A
fields in the JSON summary, and the batch returns nonzero. single-report notification failure fails that report. A batch notification
failure makes the batch return nonzero and increments the aggregate failure
count, but individual report items remain succeeded.
Each notification attempt writes a debug artifact under `notifications/`. The Each notification attempt writes a debug artifact under `notifications/`.
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed Single-report artifacts live under
source path, bundle-relative paths, bundle created timestamp, accepted upload `notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
response, and the latest distributor run status response when available. artifacts live under
Weatherreporter polls status until distributor reports `succeeded` or `failed`, `notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
or until the configured notification timeout expires. The run status includes where the date directory is the batch start date in the effective report
the distributor status, error text, and raw run report JSON, which can show timezone. The artifact records the rendered pipeline ID, bundle ID,
actions such as `replace_older`, `skip_same`, `skip_destination_newer`, or idempotency key, managed source paths, bundle-relative paths, bundle created
`failed`. Token values are not written. timestamp, accepted upload response, and the latest distributor run status
response when available. Weatherreporter polls status until distributor reports
`succeeded` or `failed`, or until the configured notification timeout expires.
The run status includes the distributor status, error text, and raw run report
JSON, which can show actions such as `replace_older`, `skip_same`,
`skip_destination_newer`, or `failed`. Token values are not written.
Weatherreporter is responsible for selecting the managed Markdown report, Weatherreporter is responsible for selecting the managed Markdown report,
constructing a source bundle, and submitting it to the configured distributor constructing a source bundle, and submitting it to the configured distributor
@@ -274,7 +209,7 @@ bundle, so existing uploaded dated report paths can remain available.
## Inspection ## Inspection
Inspection commands read existing workspace artifacts and emit JSON to stdout. Inspection commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or run `scriptorium`. They do not collect weather data or run `scriptorium`.
```text ```text
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10
@@ -322,10 +257,13 @@ A failed generation run may still leave useful artifacts:
available intermediate artifacts, such as the structured run result, raw available intermediate artifacts, such as the structured run result, raw
generated-text JSON, validated generated text, and render context. Metadata generated-text JSON, validated generated text, and render context. Metadata
links those paths when it can be safely written. links those paths when it can be safely written.
- If distributor notification fails, report artifacts and final metadata remain - If single-report distributor notification fails, report artifacts and final
available, but the report or batch command returns nonzero. metadata remain available, but the report command returns nonzero.
- If batch distributor notification fails, report artifacts and final metadata
remain available, the top-level batch notification links the debug artifact,
and the batch command returns nonzero.
- For batch commands, inspect the stdout JSON summary first, then inspect the - For batch commands, inspect the stdout JSON summary first, then inspect the
artifact paths for each failed report. artifact paths for each failed report or the top-level notification path.
For a bad report, start with: For a bad report, start with:

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,248 +0,0 @@
# Batch Collection Roadmap
## Purpose
This roadmap defines the planned change to make `weatherreporter run morning`
and `weatherreporter run evening` use one canonical upstream collection path
and data-aware batch planning.
The feature has two related goals:
- move upstream Weather API collection into a single `internal/collect` package
used by all generation workflows;
- update scheduled batches so they generate Today, Tomorrow, and future Daily
reports according to available full-day hourly forecast coverage.
This document lives under `docs/roadmap/` because the behavior described here
is not yet implemented.
## User Intent
Batch commands should produce a practical publication set while avoiding
partial Daily reports.
Morning publication should generate:
- Today report for the current civil day;
- Tomorrow report for the next civil day;
- one Daily report for each later future civil day where the upstream hourly
forecast fully covers the entire target day.
Evening publication should generate:
- Tomorrow report for the next civil day;
- one Daily report for each later future civil day where the upstream hourly
forecast fully covers the entire target day.
Neither batch should generate Daily reports for partially covered days. That
keeps the Daily report format simple and avoids requiring templates or modules
to explain incomplete forecast coverage.
The morning batch should no longer include the legacy 3-Day Outlook or Weekend
Outlook. Those reports remain individually generated report types unless
separately removed.
## Locked Decisions
- Add a new canonical upstream collection package: `internal/collect`.
- Keep Weather API HTTP details in `internal/adapters/weatherapi`; `collect`
orchestrates collection and returns normalized upstream data.
- Use `internal/collect` for all report generation workflows, including
`generate <report>`, `run morning`, and `run evening`.
- Fetch upstream weather data once per command invocation.
- Pass the collected result into report generation; report generation should
not fetch directly from Weather API.
- Keep batch planning in `internal/app`; do not make `internal/collect` aware of
morning, evening, report IDs, or batch membership.
- Centralize batch-specific app planning in a single file, expected to be
`internal/app/batch_plan.go`, unless implementation shows a clearer local
name.
- `run morning` should generate `today`, `tomorrow`, then future `daily`
reports in ascending local date order.
- `run evening` should generate `tomorrow`, then future `daily` reports in
ascending local date order.
- Future Daily eligibility is based on full hourly forecast coverage, not
narrative forecast availability.
- Daily expansion starts with the day after tomorrow for both morning and
evening batches.
- Remove `three-day` and `weekend` from `run morning`.
- Dynamic Daily reports in a batch should use date-qualified `--out-dir` copy
names, such as `daily-YYYY-MM-DD.md`.
- Keep public CLI syntax unchanged: `weatherreporter run morning` and
`weatherreporter run evening` remain the commands.
## Current Repository Shape
Current code resolves batches statically:
- `internal/cli/root.go` parses `run morning` or `run evening` and builds
`app.BatchRequest`.
- `internal/app.RunBatchDetailed` calls `ResolveBatch`.
- `internal/app.ResolveBatch` delegates to `report.Registry.BatchReports`.
- `internal/report.Registry.BatchReports` currently resolves the morning batch
to Today, 3-Day, and Weekend except on Sunday.
- `internal/report.Registry.BatchReports` currently resolves the evening batch
to Tomorrow.
- `internal/app.GenerateReport` fetches the Weather API bundle for each report
through `FetchBundle`.
- `FetchBundle` directly constructs the Weather API adapter and calls
`FetchBundle`.
- Daily report resolution already supports explicit target dates through
`report.ResolveRequest.Date`.
This means current batch resolution cannot decide future Daily membership from
available upstream data without either fetching during planning or attempting
report generation for candidate dates. It also means multiple reports in one
batch can fetch different upstream snapshots.
## Target Architecture
The target command flow is:
1. CLI parses the command and loads config.
2. App orchestration calls `internal/collect` once.
3. `internal/collect` calls the Weather API adapter and returns a collection
result containing the normalized `weatherdata.Bundle`.
4. App batch planning uses the collected bundle plus report registry metadata
to resolve the requested reports.
5. App report generation receives the same collected bundle for every report in
the command.
6. Facts, modules, prompt input, generated text, templates, state, and
notifications operate as they do today, but consume already-collected data.
The intended dependency direction is:
```text
internal/adapters/weatherapi -> internal/collect -> internal/app -> internal/facts -> internal/briefing/internal modules
```
Report definitions still live in `internal/report`. Report definitions should
not know how upstream data is fetched or how batch membership is expanded from
available data.
## `internal/collect` Contract
The initial package should be intentionally narrow. A suitable first contract
is:
```go
package collect
type Request struct {
Config config.Config
}
type Result struct {
Bundle *weatherdata.Bundle
}
```
Expected behavior:
- `collect.Run(ctx, Request)` constructs and uses the Weather API adapter.
- It returns the normalized Weather API bundle.
- It wraps collection errors with operation context.
- It does not derive report facts.
- It does not resolve reports or batches.
- It does not write state artifacts.
- It does not invoke Scriptorium or distributor.
Future source families, such as radar, observations history, or additional
review/report inputs, should be added to this package as upstream collection
responsibilities. The package should remain source collection, not report
policy.
## Batch Target Behavior
### Morning
`run morning` should resolve reports in this order:
1. `today`
2. `tomorrow`
3. `daily` for each eligible future date beginning with the day after tomorrow
### Evening
`run evening` should resolve reports in this order:
1. `tomorrow`
2. `daily` for each eligible future date beginning with the day after tomorrow
### Future Daily Eligibility
Eligibility for each future Daily report:
- compute the target civil day in `weather_api.timezone`;
- examine the collected hourly forecast periods;
- require hourly periods whose local `startTime` values cover every required
hourly start inside the civil day;
- for ordinary days, required starts are `00:00` through `23:00` local time;
- on daylight-saving transitions, generate required starts by stepping through
the actual local civil day from `day.Start` to `day.End`, so 23-hour and
25-hour days are handled consistently;
- require each selected period to have a valid start and end;
- stop scanning once no later complete civil day can be found within the
available hourly forecast range.
The planner should use exact local hourly start matching for eligibility, not
mere overlap. This keeps the contract clear: a day is eligible only when the
hourly source contains a full set of hourly rows for that day.
## Report Generation Contract
After the collection refactor, `GenerateReport` should no longer fetch upstream
data itself. The preferred final shape is:
- `Generate` collects once, resolves the single report, then calls
`GenerateReport` with the collected result.
- `RunBatchDetailed` collects once, plans the batch, then calls
`GenerateReport` for each resolved report with the same collected result.
- `GenerateReport` requires collected data and returns an actionable error if
called without it.
A transitional optional collected-data field may be used during implementation,
but the final state should have one explicit collection path and no hidden
Weather API fetch inside report generation.
## Output And State Policy
Existing managed workspace artifact paths should remain stable. Multiple Daily
reports can appear in one batch, so `--out-dir` copies must not use the same
`daily.md` filename for every dynamic Daily report. Dynamic Daily batch copies
should use `daily-YYYY-MM-DD.md`. Today and Tomorrow should keep their existing
batch copy names.
Distributor upload paths and managed report paths should continue to be derived
from report definitions and resolved report metadata, not from optional
`--out-dir` copies.
## Implementation Planning
The staged implementation plan belongs in `docs/roadmap/implementation.md`.
That document should be treated as the executable plan for coding agents.
After implementation, update implemented docs only:
- `docs/cli.md`
- `docs/operations.md`
- `docs/internal/app-orchestration.md`
- `docs/internal/report-registry.md`
- relevant troubleshooting entries if new failure modes are surfaced
Do not document future collection sources outside roadmap docs.
## Refactors To Avoid
- Do not create a generic workflow engine.
- Do not move report definitions out of `internal/report`.
- Do not make `internal/collect` aware of report IDs, prompt IDs, or batch
names.
- Do not make modules fetch upstream data.
- Do not add config knobs for partial Daily coverage in this pass.
- Do not remove 3-Day or Weekend report definitions unless separately planned.
- Do not change public CLI syntax for `run morning` or `run evening`.
## Open Questions
None block the roadmap.

View File

@@ -73,8 +73,8 @@ represented in `CollectedFacts`.
Future module work should preserve these boundaries: Future module work should preserve these boundaries:
- collect upstream facts once per report run - keep upstream collection in app orchestration
- keep upstream fetching out of modules - keep upstream collection out of modules
- keep broad reusable calculations in `DerivedFacts` - keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders - keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior - use typed options for configurable module behavior

View File

@@ -1,443 +0,0 @@
# Batch Collection Implementation Roadmap
## Purpose
This is the staged implementation plan for `docs/roadmap/batch.md`. Implement
the stages in order. The feature has two goals:
- route all upstream Weather API collection through a canonical
`internal/collect` package;
- update `run morning` and `run evening` so they use one collected snapshot and
dynamically add future Daily reports only for dates with complete hourly
coverage.
This is an implementation roadmap only. Background, user intent, and target
policy live in `docs/roadmap/batch.md`.
## Global Constraints
- Preserve public CLI syntax.
- Preserve managed workspace artifact paths.
- Preserve distributor upload behavior and paths.
- Keep Weather API HTTP details inside `internal/adapters/weatherapi`.
- Keep source collection in `internal/collect`.
- Keep batch planning in `internal/app`.
- Keep report definitions in `internal/report`.
- Do not make `internal/collect` aware of report IDs, prompt IDs, or batch
names.
- Do not add config fields for partial Daily coverage.
- Do not create a workflow engine, scheduler package, or plugin system.
- Do not remove 3-Day or Weekend report definitions in this change.
## Stage 1: Add Canonical Collection Package
Goal: introduce `internal/collect` as the only app-facing upstream collection
API while preserving current behavior.
Implementation:
- Add `internal/collect`.
- Define:
```go
type Request struct {
Config config.Config
}
type Result struct {
Bundle *weatherdata.Bundle
}
```
- Add `Run(ctx context.Context, req Request) (*Result, error)`.
- `Run` should construct the Weather API adapter with `weatherapi.New` and call
`client.FetchBundle(ctx)`.
- Return an actionable error when adapter construction or fetch fails.
- Keep the returned data normalized as `*weatherdata.Bundle`; do not derive
`facts.CollectedFacts` or `facts.DerivedFacts` in this package.
- Update `internal/app.FetchBundle` and `FetchAndSaveBundle`, if retained, to
call `collect.Run` rather than constructing `weatherapi.New` directly.
Tests:
- Add `internal/collect` tests using a local Weather API test server or a small
adapter seam if needed.
- Keep existing `FetchAndSaveBundle` app tests passing.
- Ensure collection errors include collection/fetch context without leaking
secrets.
Acceptance:
- `internal/collect` owns app-facing Weather API collection.
- `internal/app.FetchBundle` no longer constructs `weatherapi.New` directly.
- No report or batch behavior changes yet.
Validation:
```sh
go test ./internal/collect ./internal/app ./internal/adapters/weatherapi
git diff --check
```
## Stage 2: Add App Collection Seam
Goal: make collection testable from app orchestration without requiring live
Weather API calls.
Implementation:
- Add a narrow app-owned collector interface, for example:
```go
type Collector interface {
Run(context.Context, collect.Request) (*collect.Result, error)
}
```
- Add a default adapter that calls `collect.Run`.
- Add `Collector` fields to `GenerateRequest` and `BatchRequest`.
- Default to the real collector when the request does not provide one.
- Do not expose collector options through CLI.
Tests:
- Add app tests proving `Generate` uses the provided collector.
- Add app tests proving collection failure stops generation before report
execution.
- Add batch tests proving `RunBatchDetailed` can use a fake collector in later
stages.
Acceptance:
- App orchestration can be tested with a fake collector.
- CLI behavior is unchanged.
- External adapter types do not leak into app request or result structs.
Validation:
```sh
go test ./internal/app ./internal/cli
git diff --check
```
## Stage 3: Make Report Generation Require Collected Data
Goal: remove hidden Weather API fetching from `GenerateReport`.
Implementation:
- Add a collection field to `ReportRequest`, for example:
```go
Collection collect.Result
```
- Update `Generate` to:
- collect once;
- resolve the requested report;
- pass the collection into `GenerateReport`.
- Update `GenerateReport` to use `req.Collection.Bundle`.
- If `req.Collection.Bundle` is nil, return an actionable error such as
`collected weather bundle is required`.
- Remove the direct `FetchBundle` call from `GenerateReport`.
- Keep `BuildReportFacts` unchanged: it should still receive a
`*weatherdata.Bundle`.
- Update all app tests that call `GenerateReport` directly to pass a collected
test bundle through `ReportRequest.Collection`.
Tests:
- Add or update tests proving single-report generation collects once.
- Add or update tests proving `GenerateReport` fails before state writes when
no collected bundle is provided.
- Keep generated-text, notification, state, and output-copy tests passing with
the explicit collection field.
Acceptance:
- `GenerateReport` has no direct Weather API fetch path.
- Single-report commands still produce the same artifacts.
- `GenerateReport` has an explicit data dependency.
Validation:
```sh
go test ./internal/app ./internal/cli
rg -n "weatherapi\\.New|\\.FetchBundle\\(" internal
git diff --check
```
Expected grep matches at this stage should be limited to
`internal/collect`, `internal/adapters/weatherapi`, and their tests, plus any
transitional app fetch helper tests that explicitly verify `FetchBundle`.
## Stage 4: Add Hourly Full-Day Coverage Helper
Goal: isolate the Daily eligibility rule before changing batch behavior.
Implementation:
- Add helper logic in `internal/app/batch_plan.go`.
- The helper should inspect `weatherdata.ForecastRun` hourly periods and return
eligible future Daily dates.
- Inputs should include:
- hourly forecast run;
- `now`;
- loaded report timezone/location.
- The helper should:
- start candidate Daily expansion at the day after tomorrow;
- compute local civil days with `timeutil.CivilDay`;
- build required hourly start instants by stepping from civil-day start to
civil-day end in one-hour increments;
- require every required start instant to exist as a valid hourly period
`StartTime`;
- require matching periods to have valid end times after start times;
- scan candidate dates through the maximum local date represented by hourly
period start times;
- return dates in ascending local date order;
- return no dates when hourly data is missing.
- Use exact start-time matching, not overlap-only matching.
Tests:
- Full ordinary local day with starts `00:00` through `23:00` is eligible.
- Missing one required hour makes the date ineligible.
- Partial final day is skipped.
- Today and tomorrow are never returned by expansion.
- Multiple eligible future days are returned in order.
- Non-hourly or invalid periods are ignored.
- DST transition days use actual civil-day hourly instants.
- Missing hourly forecast returns no dynamic Daily dates.
Acceptance:
- Daily eligibility is tested independently from batch execution.
- The helper does not resolve reports, call Weather API, write state, or invoke
Scriptorium.
Validation:
```sh
go test ./internal/app
git diff --check
```
## Stage 5: Add Shared Batch Planner
Goal: make morning and evening batch membership app-owned and data-aware.
Implementation:
- Continue using `report.BatchForCommandName` for CLI validation.
- Add an internal planned report type in `internal/app`, for example:
```go
type plannedBatchReport struct {
Resolved report.Resolved
OutputCopyName string
}
```
- Implement app-owned batch planning in `internal/app/batch_plan.go`.
- Morning plan:
- resolve `report.Today`;
- resolve `report.Tomorrow`;
- resolve `report.Daily` for each eligible future date from Stage 4.
- Evening plan:
- resolve `report.Tomorrow`;
- resolve `report.Daily` for each eligible future date from Stage 4.
- Resolve dynamic Daily reports by setting `ResolveRequest.Date` to the target
local date.
- Dynamic Daily planned reports should set `OutputCopyName` to
`daily-YYYY-MM-DD.md`.
- Today and Tomorrow should use their report definition batch output names.
- Remove `three-day` and `weekend` from morning planning.
- Stop using `report.Registry.BatchReports` from app batch execution. Either
remove that method if it becomes unused, or leave it unused only if tests or
docs still need it temporarily. Do not allow app batch execution to use it.
Tests:
- Morning order is Today, Tomorrow, then Daily dates.
- Evening order is Tomorrow, then Daily dates.
- Dynamic Daily dates start day after tomorrow.
- 3-Day and Weekend are absent from morning.
- Dynamic Daily resolves valid periods for the requested dates.
- Planned dynamic Daily output copy names are date-qualified.
- Unknown batch names still fail through existing CLI/report validation.
Acceptance:
- Batch membership is owned by `internal/app`.
- Batch membership is based on one collected bundle.
- `internal/collect` has no report or batch policy.
Validation:
```sh
go test ./internal/app ./internal/report ./internal/cli
git diff --check
```
## Stage 6: Use One Collection Result Per Batch
Goal: make both batch commands collect once and reuse that collection for every
report.
Implementation:
- Update `RunBatchDetailed` to:
- resolve the effective time;
- collect once using the request collector/default collector;
- plan the batch from that collection;
- pass the same `collect.Result` into every `GenerateReport` call.
- If collection fails, return before generating any report.
- Update batch report result construction to use `plannedBatchReport`.
- Update `batchOutputPath` or replace it with planned output-copy path logic:
- if `OutputDir` is empty, return empty output path;
- if `planned.OutputCopyName` is non-empty, use that;
- otherwise use `resolved.Definition.BatchOutputName`;
- join with `OutputDir`.
- Preserve per-report failure behavior after planning: later reports continue
after a report failure.
- Preserve notification failure behavior.
Tests:
- Counted or fake collector proves one collection call for morning.
- Counted or fake collector proves one collection call for evening.
- Collection failure prevents any renderer calls.
- Report failure still allows later planned reports to run.
- Notification failure still marks only that report failed and continues.
- `--out-dir` output paths use:
- `today.md` for Today;
- `tomorrow.md` for Tomorrow;
- `daily-YYYY-MM-DD.md` for dynamic Daily.
Acceptance:
- Both batch commands use one collection result per invocation.
- No report in a batch performs its own upstream fetch.
- Batch JSON and stderr behavior remain coherent.
Validation:
```sh
go test ./internal/app ./internal/cli ./internal/state
rg -n "weatherapi\\.New|\\.FetchBundle\\(" internal
git diff --check
```
Expected grep matches should be limited to `internal/collect`,
`internal/adapters/weatherapi`, and their tests.
## Stage 7: Remove Or Retire Legacy Static Batch Resolution
Goal: prevent future code from accidentally using stale batch membership.
Implementation:
- Inspect usages of `report.Registry.BatchReports`.
- If no longer needed, delete `Registry.BatchReports` and related tests.
- If keeping a reduced helper is necessary, document in code comments that app
batch planning is authoritative and ensure no production path calls the old
static membership helper.
- Update report-registry tests that currently assert old morning/evening
membership.
Tests:
- No production code path calls static registry batch membership.
- Report registry tests continue to cover report definitions, generated flags,
batch output names, and valid-period resolution as appropriate.
Acceptance:
- There is no stale static morning/evening membership path in production code.
- Future agents cannot accidentally reintroduce old Today/3-Day/Weekend
morning membership by calling a legacy helper.
Validation:
```sh
go test ./internal/report ./internal/app
rg -n "BatchReports\\(" internal
git diff --check
```
Expected `BatchReports` grep result should be empty or limited to tests or
comments that explicitly document it as non-production.
## Stage 8: Documentation Updates
Goal: update implemented documentation after the code behavior changes.
Implementation:
- Update `docs/cli.md`:
- morning batch now generates Today, Tomorrow, and eligible future Daily
reports;
- evening batch now generates Tomorrow and eligible future Daily reports;
- Daily eligibility is full hourly coverage.
- Update `docs/operations.md` with the same operator-facing behavior and
collection-failure behavior.
- Update `docs/internal/app-orchestration.md`:
- `internal/collect` boundary;
- one collection per command;
- app-owned batch planning;
- explicit collected data passed to report generation.
- Update `docs/internal/report-registry.md`:
- report definitions remain canonical for report metadata;
- batch membership is app-owned when data-dependent.
- Add a new implemented internal component doc for `internal/collect`, for
example `docs/internal/collect.md`.
- Do not describe future source families as implemented behavior.
Tests/checks:
- Grep docs for stale old batch language: Today/3-Day/Weekend morning,
Weekend except Sunday, Daily not part of scheduled batches.
- Confirm docs do not imply `internal/collect` owns report or batch policy.
Acceptance:
- Non-roadmap docs describe only implemented behavior.
- Operator docs and internal docs agree about batch membership.
Validation:
```sh
rg -n "3-Day|Weekend|scheduled batch|not part of scheduled|except on Sunday" docs README.md
git diff --check
```
## Stage 9: Final Validation
Goal: verify the completed migration and behavior end to end.
Run:
```sh
go test ./...
go run ./cmd/weatherreporter --help
rg -n "weatherapi\\.New|\\.FetchBundle\\(" internal
rg -n "BatchReports\\(" internal
git diff --check
```
Manual review:
- `weatherapi.New` and `.FetchBundle(` matches are limited to
`internal/collect`, `internal/adapters/weatherapi`, and their tests.
- `BatchReports(` matches are absent from production paths, or explicitly
documented as non-production if retained.
- `run morning` output summary can include multiple Daily reports without
duplicate `--out-dir` copy paths.
- `run evening` uses the same collection and dynamic Daily expansion logic as
morning.
- Managed workspace artifact paths are unchanged.
- Distributor upload paths are unchanged.
## Open Questions
None.

View File

@@ -6,7 +6,7 @@ fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
## `weather_api.base_url is required` ## `weather_api.base_url is required`
Symptom: a generation command fails before fetching weather data. Symptom: a generation command fails before collecting weather data.
Likely cause: no Weather API base URL is configured. Likely cause: no Weather API base URL is configured.
@@ -235,16 +235,108 @@ Relevant docs: [Report Templates](templates.md),
Symptom: `run morning` or `run evening` returns nonzero. Symptom: `run morning` or `run evening` returns nonzero.
Likely cause: at least one report in the batch failed. Likely cause: weather collection failed before planning, or at least one
planned report failed after planning succeeded, or every report succeeded but
the top-level batch distributor notification failed.
Diagnostic: inspect stdout for the JSON summary and stderr for compact status Diagnostic: if stdout contains a JSON summary, inspect each failed report item
lines. and the top-level `notification` object. Stderr includes one
`batchNotification` line when batch notification is attempted, skipped, or
fails. If no summary was emitted, inspect the command error; configuration,
Weather API collection, or batch validation failed before any report artifacts
were created.
Safe fix: use the failed report's artifact paths from the summary, then inspect Safe fix: for collection failures, fix the configuration or upstream Weather
metadata, sources, module snapshot, and data package for that RunID. API availability and rerun the batch. For report failures, use the failed
report's artifact paths from the summary, then inspect metadata, sources,
module snapshot, and data package for that RunID. For a batch notification
failure, inspect the notification artifact path from the top-level
`notification.path`.
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md). Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
## Batch Upload Skipped
Symptom: a batch JSON summary contains
`"notification":{"status":"skipped","reason":"one or more reports failed"}`.
Likely cause: at least one planned report failed, so weatherreporter did not
call distributor for the batch.
Diagnostic: inspect the failed report items in the batch JSON summary and the
matching stderr report lines. A skipped batch notification has no distributor
run ID and no notification artifact path.
Safe fix: fix the report-generation failure first, then rerun the batch. The
batch upload is all-or-nothing.
Relevant docs: [Operations guide](operations.md).
## Batch Upload Fails
Symptom: every report item in a batch summary is succeeded, but the batch
returns nonzero and the top-level notification has `status: "failed"`.
Likely cause: the distributor upload was rejected, the distributor service was
unavailable, status polling reached a terminal distributor failure, or
weatherreporter rejected the batch file mapping before upload.
Diagnostic: inspect `notification.error`, `notification.pipelineId`,
`notification.bundleId`, `notification.idempotencyKey`, and
`notification.path` in stdout. Then inspect the notification artifact; it
records included report source paths, bundle paths, upload status, distributor
run status, status lookup error, and raw status report JSON when available.
Safe fix: fix the endpoint, token, distributor pipeline, batch identity
templates, or report path templates indicated by the error, then rerun the
batch. Individual report artifacts from the failed batch notification remain
available and do not need to be regenerated for diagnosis.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Duplicate Batch Bundle Path
Symptom: a batch returns nonzero with duplicate bundle path context before a
distributor run ID is accepted.
Likely cause: report-specific distributor path templates rendered the same
bundle-relative path for two included reports in the same batch.
Diagnostic: inspect the error in stdout or stderr. The validation error
includes the duplicate bundle path plus the report IDs, RunIDs, and managed
source paths involved.
Safe fix: configure a per-report distributor path override so every report in a
batch renders a unique path. Include values such as `{artifact_group}`,
`{valid_start_date}`, `{batch_output_name}`, or `{run_id}` when needed.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Distributor Source Conflict
Symptom: distributor accepts or rejects an upload with conflict context for a
source, destination, digest, or idempotency key.
Likely cause: the rendered bundle ID or idempotency key does not match the
intended producer identity. A bundle ID identifies the logical source stream;
an idempotency key identifies a retry of the same upload request.
Diagnostic: inspect the report notification artifact linked from metadata or
the batch notification artifact linked from the top-level notification path.
Compare the rendered pipeline ID, bundle ID, idempotency key, included source
paths, and bundle paths with `notify.distributor.*` templates and distributor
pipeline state.
Safe fix: keep bundle ID templates stable for the source stream that should be
updated, and keep idempotency keys stable only for retries of the same generated
content. Do not reuse one idempotency key for different report or batch
content.
Relevant docs: [Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Invalid Secrets Directory ## Invalid Secrets Directory
Symptom: config loading fails with `read secrets directory`, `secret file`, or Symptom: config loading fails with `read secrets directory`, `secret file`, or
@@ -291,12 +383,14 @@ within the same distributor token and pipeline. By default the bundle ID is a
stable report-stream identity and the idempotency key appends RunID. stable report-stream identity and the idempotency key appends RunID.
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle, Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
and idempotency context. Compare the configured templates with the report RunID and idempotency context. For batch commands, use the top-level notification
and report path. object rather than per-report notification fields. Compare the configured
templates with the report RunID or batch RunID and report path.
Also inspect the notification artifact linked from metadata. It records the Also inspect the notification artifact linked from metadata or from the
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run top-level batch notification path. It records the rendered pipeline ID, bundle
status, status error, and raw run report JSON when available. ID, idempotency key, upload result, distributor run status, status error, and
raw run report JSON when available.
Safe fix: keep idempotency templates stable for retries of the same generated Safe fix: keep idempotency templates stable for retries of the same generated
report, but do not reuse the same rendered key for different generated report report, but do not reuse the same rendered key for different generated report
@@ -314,9 +408,10 @@ Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
ID, idempotency key, source file, or one of the rendered bundle paths. ID, idempotency key, source file, or one of the rendered bundle paths.
Diagnostic: inspect stdout JSON or stderr status lines for Diagnostic: inspect stdout JSON or stderr status lines for
`notificationError`. Confirm `notify.distributor.endpoint`, `notificationError` or the top-level batch notification `error`. Confirm
`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
@@ -341,8 +436,8 @@ Likely cause: the configured distributor endpoint is unreachable, slow, or
temporarily unavailable. temporarily unavailable.
Diagnostic: check network access from the weatherreporter host to Diagnostic: check network access from the weatherreporter host to
`notify.distributor.endpoint`. For batch runs, inspect which reports have `notify.distributor.endpoint`. For batch runs, inspect the top-level
`notificationStatus: "failed"`. notification object and the artifact linked by `notification.path`.
Safe fix: restore distributor service availability and rerun the affected Safe fix: restore distributor service availability and rerun the affected
report or batch. Stable idempotency keys make retrying the same generated report report or batch. Stable idempotency keys make retrying the same generated report

View File

@@ -1,5 +1,5 @@
weather_api: weather_api:
base_url: https://weather.api.rakestrawhome.com/ base_url: https://weather.api.example.com/
timeout: 15s timeout: 15s
precision: 1 precision: 1
units: us units: us
@@ -24,8 +24,11 @@ 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: batch:
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md" enabled: true
pipeline_id_template: "weatherreporter"
bundle_id_template: "weatherreporter.{location_id}.{batch}"
idempotency_key_template: "{bundle_id}.{batch_run_id}"
missing_source: missing_source:
default: warn default: warn
@@ -69,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
@@ -81,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

@@ -10,9 +10,9 @@ import (
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
@@ -53,6 +53,7 @@ type GenerateRequest struct {
Date time.Time Date time.Time
StormStart time.Time StormStart time.Time
StormEnd time.Time StormEnd time.Time
Collector Collector
Notifier Notifier Notifier Notifier
} }
@@ -61,6 +62,7 @@ type BatchRequest struct {
Batch BatchKind Batch BatchKind
Now time.Time Now time.Time
OutputDir string OutputDir string
Collector Collector
Renderer Renderer Renderer Renderer
Store state.Store Store state.Store
Notifier Notifier Notifier Notifier
@@ -85,9 +87,11 @@ type ReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
OutputPath string OutputPath string
Collection collect.Result
Renderer Renderer Renderer Renderer
Store state.Store Store state.Store
Notifier Notifier Notifier Notifier
noNotify bool
} }
type ReportResult struct { type ReportResult struct {
@@ -114,13 +118,33 @@ type ReportResult struct {
} }
type BatchResult struct { type BatchResult struct {
Batch BatchKind `json:"batch"` Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"` FinishedAt time.Time `json:"finishedAt"`
Total int `json:"total"` Total int `json:"total"`
Succeeded int `json:"succeeded"` Succeeded int `json:"succeeded"`
Failed int `json:"failed"` Failed int `json:"failed"`
Reports []BatchReportResult `json:"reports"` Notification *BatchNotificationResult `json:"notification,omitempty"`
Reports []BatchReportResult `json:"reports"`
}
type BatchNotificationResult struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
RunID string `json:"runId,omitempty"`
PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Path string `json:"path,omitempty"`
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
Error string `json:"error,omitempty"`
}
type BatchNotificationReport struct {
ReportID report.ID `json:"reportId"`
RunID string `json:"runId"`
SourcePath string `json:"sourcePath"`
BundlePaths []string `json:"bundlePaths"`
} }
type BatchReportResult struct { type BatchReportResult struct {
@@ -152,15 +176,48 @@ func (e BatchError) Error() string {
if e.Result == nil { if e.Result == nil {
return "batch failed" return "batch failed"
} }
if batchNotificationFailed(e.Result) && batchReportFailures(e.Result) == 0 {
if e.Result.Notification.Error != "" {
return fmt.Sprintf("batch %s notification failed: %s", e.Result.Batch, e.Result.Notification.Error)
}
return fmt.Sprintf("batch %s notification failed", e.Result.Batch)
}
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total) return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
} }
func batchNotificationFailed(result *BatchResult) bool {
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
}
func batchReportFailures(result *BatchResult) int {
if result == nil {
return 0
}
failures := 0
for _, item := range result.Reports {
if item.Status == "failed" {
failures++
}
}
return failures
}
type Renderer interface { type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
} }
type Collector interface {
Run(context.Context, collect.Request) (*collect.Result, error)
}
type defaultCollector struct{}
func (defaultCollector) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
return collect.Run(ctx, req)
}
type Notifier interface { type Notifier interface {
Notify(context.Context, NotificationRequest) (*NotificationResult, error) Notify(context.Context, NotificationRequest) (*NotificationResult, error)
} }
@@ -211,24 +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)
if err != nil {
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,
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 {
@@ -247,7 +313,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
resolvedReports, err := ResolveBatch(req, now) if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
plannedReports, err := planBatchRun(req, now, *collection)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -262,27 +335,31 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
} }
startedAt := now startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, planned := range plannedReports {
resolved := planned.Resolved
if !resolved.Definition.Generated { if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
for _, resolved := range resolvedReports { for _, planned := range plannedReports {
item := batchReportResult(resolved) resolved := planned.Resolved
item := batchReportResult(planned)
if paths, err := store.Paths(resolved); err == nil { if paths, err := store.Paths(resolved); err == nil {
item.DataPackagePath = paths.DataPackage item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata item.MetadataPath = paths.Metadata
} }
outputPath := batchOutputPath(req.OutputDir, resolved.Definition) outputPath := plannedBatchOutputPath(req.OutputDir, planned)
reportResult, err := GenerateReport(ctx, ReportRequest{ reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Collection: *collection,
Renderer: req.Renderer, Renderer: req.Renderer,
Store: store, Store: store,
Notifier: req.Notifier, Notifier: req.Notifier,
noNotify: true,
}) })
if err != nil { if err != nil {
item.Status = "failed" item.Status = "failed"
@@ -315,13 +392,21 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
result.Reports = append(result.Reports, item) result.Reports = append(result.Reports, item)
} }
result.Total = len(result.Reports) result.Total = len(result.Reports)
batchNotification, err := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, store, req.Notifier)
if batchNotification != nil {
result.Notification = batchNotification
}
if err != nil {
result.Failed++
}
result.FinishedAt = time.Now() result.FinishedAt = time.Now()
return result, nil return result, nil
} }
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
func batchReportResult(resolved report.Resolved) BatchReportResult { func batchReportResult(planned plannedBatchReport) BatchReportResult {
resolved := planned.Resolved
metadata := resolved.Metadata() metadata := resolved.Metadata()
return BatchReportResult{ return BatchReportResult{
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
@@ -333,11 +418,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
if outputDir == "" || definition.BatchOutputName == "" { if outputDir == "" {
return "" return ""
} }
return filepath.Join(outputDir, definition.BatchOutputName) outputCopyName := planned.OutputCopyName
if outputCopyName == "" {
outputCopyName = planned.Resolved.Definition.BatchOutputName
}
if outputCopyName == "" {
return ""
}
return filepath.Join(outputDir, outputCopyName)
} }
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -362,25 +454,6 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
}) })
} }
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := report.BatchForCommandName(string(req.Batch))
if err != nil {
return nil, err
}
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
return registry.BatchReports(batch, report.ResolveRequest{
Now: now,
Location: location,
})
}
func reportRegistry(cfg config.Config) (report.Registry, error) { func reportRegistry(cfg config.Config) (report.Registry, error) {
overrides, err := cfg.ReportModuleOverrides() overrides, err := cfg.ReportModuleOverrides()
if err != nil { if err != nil {
@@ -394,15 +467,28 @@ func reportRegistry(cfg config.Config) (report.Registry, error) {
} }
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
client, err := weatherapi.New(req.Config) result, err := collectWeather(ctx, req.Config, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
bundle, err := client.FetchBundle(ctx) return result.Bundle, nil
}
func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) {
if collector == nil {
collector = defaultCollector{}
}
result, err := collector.Run(ctx, collect.Request{Config: cfg})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return bundle, nil if result == nil {
return nil, fmt.Errorf("collect weather bundle: collector returned nil result")
}
if result.Bundle == nil {
return nil, fmt.Errorf("collect weather bundle: collector returned nil bundle")
}
return result, nil
} }
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
@@ -413,13 +499,18 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil { if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
return nil, err return nil, fmt.Errorf("save bundle: %w", err)
} }
return bundle, nil return bundle, nil
} }
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle
if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
store := req.Store store := req.Store
if store == nil { if store == nil {
defaultStore, err := defaultStore(req.Config) defaultStore, err := defaultStore(req.Config)
@@ -437,10 +528,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, err return nil, err
} }
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{ reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
Config: req.Config, Config: req.Config,
Resolved: req.Resolved, Resolved: req.Resolved,
@@ -568,28 +655,40 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Notifier: req.Notifier, Notifier: req.Notifier,
GenerationErr: runErr, GenerationErr: runErr,
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 {
@@ -690,32 +789,100 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
ManagedReportPath: reportPath, ManagedReportPath: reportPath,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Notifier: req.Notifier, Notifier: req.Notifier,
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 {
@@ -727,6 +894,7 @@ type finalizeRenderedReportRequest struct {
OutputPath string OutputPath string
Notifier Notifier Notifier Notifier
GenerationErr error GenerationErr error
noNotify bool
} }
type finalizeRenderedReportResult struct { type finalizeRenderedReportResult struct {
@@ -769,6 +937,9 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
if req.GenerationErr != nil { if req.GenerationErr != nil {
return result, req.GenerationErr return result, req.GenerationErr
} }
if req.noNotify {
return result, nil
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store) notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" { if notificationPath != "" {
@@ -828,14 +999,8 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
} }
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) { func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
values := config.DistributorTemplateValues{ values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
LocationID: cfg.Location.ID, if err != nil {
ReportID: string(resolved.Definition.ID),
RunID: metadata.RunID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: resolved.Definition.BatchOutputName,
}
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values) bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
@@ -851,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
} }
@@ -867,6 +1032,59 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
}, nil }, nil
} }
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) {
values := config.DistributorTemplateValues{
LocationID: cfg.Location.ID,
ReportID: string(resolved.Definition.ID),
RunID: runID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: batchOutputName,
}
if values.BatchOutputName == "" {
values.BatchOutputName = resolved.Definition.BatchOutputName
}
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return config.DistributorTemplateValues{}, err
}
if resolved.Definition.ID == report.Storm {
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
}
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 {
@@ -974,6 +1192,38 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
return notification, nil return notification, nil
} }
func (n distributorNotifier) NotifyBatch(ctx context.Context, req batchNotificationRequest) (*NotificationResult, error) {
result, err := n.client.Upload(ctx, batchDistributorUploadRequest(req))
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
if err != nil {
return notification, err
}
return notification, nil
}
func notificationResultFromUpload(pipelineID string, bundleID string, idempotencyKey string, result distributoradapter.UploadResult) *NotificationResult {
notification := &NotificationResult{
PipelineID: pipelineID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
RunID: result.RunID,
Status: result.Status,
UploadStatus: result.UploadStatus,
StatusError: result.StatusError,
}
if result.RunStatus != nil {
if result.RunStatus.PipelineID != "" {
notification.PipelineID = result.RunStatus.PipelineID
}
notification.AcceptedAt = result.RunStatus.AcceptedAt
notification.StartedAt = result.RunStatus.StartedAt
notification.FinishedAt = result.RunStatus.FinishedAt
notification.Report = append([]byte(nil), result.RunStatus.Report...)
notification.Error = result.RunStatus.Error
}
return notification
}
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile { func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths)) files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
for _, bundlePath := range bundlePaths { for _, bundlePath := range bundlePaths {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,379 @@
package app
import (
"context"
"fmt"
"time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const runIDTimestampLayout = "20060102T150405.000000000Z"
type batchNotificationIdentity struct {
PipelineID string
BundleID string
IdempotencyKey string
}
type batchNotificationRequest struct {
Batch BatchKind
RunID string
PipelineID string
BundleID string
IdempotencyKey string
Files []batchNotificationFile
IncludedReports []BatchNotificationReport
CreatedAt time.Time
}
type batchNotificationFile struct {
ReportID report.ID
RunID string
SourcePath string
BundlePath string
}
type batchNotifier interface {
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
}
func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
}
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) {
if !cfg.Notify.Distributor.Enabled {
return nil, nil
}
if !cfg.Notify.Distributor.Batch.Enabled {
return nil, nil
}
if result == nil {
return nil, fmt.Errorf("batch result is required")
}
if result.Failed > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
}, nil
}
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err)
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
}
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err)
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(req, path, err), err
}
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
wrappedErr := notifyErr
if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
}
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr)
if saveErr != nil {
return nil, saveErr
}
batchResult := batchNotificationResult(req, notification, path)
if wrappedErr != nil {
batchResult.Status = "failed"
batchResult.Error = wrappedErr.Error()
return batchResult, wrappedErr
}
return batchResult, nil
}
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
if notifier != nil {
if batchNotifier, ok := notifier.(batchNotifier); ok {
return batchNotifier, nil
}
return nil, fmt.Errorf("batch distributor notifier is required")
}
return distributorNotifier{
client: distributoradapter.New(cfg.Notify.Distributor),
}, nil
}
func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID string, startedAt time.Time, reports []BatchReportResult, planned []plannedBatchReport) (batchNotificationRequest, error) {
if len(reports) == 0 {
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one report")
}
identity, err := renderBatchNotificationIdentity(cfg, batch, runID, startedAt)
if err != nil {
return batchNotificationRequest{}, err
}
if identity.PipelineID == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification pipeline id is required")
}
if identity.BundleID == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification bundle id is required")
}
if identity.IdempotencyKey == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification idempotency key is required for bundle %q", identity.BundleID)
}
plannedByRunID, err := plannedReportsByRunID(planned)
if err != nil {
return batchNotificationRequest{}, err
}
req := batchNotificationRequest{
Batch: batch,
RunID: runID,
PipelineID: identity.PipelineID,
BundleID: identity.BundleID,
IdempotencyKey: identity.IdempotencyKey,
CreatedAt: startedAt,
}
seenBundlePaths := map[string]batchNotificationFile{}
for _, item := range reports {
plannedReport, ok := plannedByRunID[item.RunID]
if !ok {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q has no matching planned report", item.ReportID, item.RunID)
}
if item.ReportID != plannedReport.Resolved.Definition.ID {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
}
if item.ReportPath == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID)
}
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName)
if err != nil {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
}
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
if err != nil {
return batchNotificationRequest{}, err
}
included := BatchNotificationReport{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.ReportPath,
BundlePaths: append([]string(nil), bundlePaths...),
}
for _, bundlePath := range bundlePaths {
file := batchNotificationFile{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.ReportPath,
BundlePath: bundlePath,
}
if previous, ok := seenBundlePaths[bundlePath]; ok {
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath)
}
seenBundlePaths[bundlePath] = file
req.Files = append(req.Files, file)
}
req.IncludedReports = append(req.IncludedReports, included)
}
if len(req.Files) == 0 {
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one file mapping")
}
return req, nil
}
func plannedReportsByRunID(planned []plannedBatchReport) (map[string]plannedBatchReport, error) {
byRunID := make(map[string]plannedBatchReport, len(planned))
for _, item := range planned {
runID := item.Resolved.Metadata().RunID
if runID == "" {
return nil, fmt.Errorf("planned report %q has empty run id", item.Resolved.Definition.ID)
}
if previous, ok := byRunID[runID]; ok {
return nil, fmt.Errorf("planned reports %q and %q share run id %q", previous.Resolved.Definition.ID, item.Resolved.Definition.ID, runID)
}
byRunID[runID] = item
}
return byRunID, nil
}
func batchDistributorUploadRequest(req batchNotificationRequest) distributoradapter.UploadRequest {
files := make([]distributoradapter.UploadFile, 0, len(req.Files))
for _, file := range req.Files {
files = append(files, distributoradapter.UploadFile{
SourcePath: file.SourcePath,
BundlePath: file.BundlePath,
})
}
return distributoradapter.UploadRequest{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Files: files,
CreatedAt: req.CreatedAt,
}
}
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult, path string) *BatchNotificationResult {
notification := &BatchNotificationResult{
Status: "unknown",
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Path: path,
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
}
if result != nil {
notification.Status = result.Status
notification.RunID = result.RunID
if result.PipelineID != "" {
notification.PipelineID = result.PipelineID
}
if result.BundleID != "" {
notification.BundleID = result.BundleID
}
if result.IdempotencyKey != "" {
notification.IdempotencyKey = result.IdempotencyKey
}
if result.Error != "" {
notification.Error = result.Error
}
}
if notification.Status == "" {
notification.Status = "unknown"
}
return notification
}
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult {
notification := batchNotificationResult(req, nil, path)
notification.Status = "failed"
if err != nil {
notification.Error = err.Error()
}
return notification
}
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
if store == nil {
return "", fmt.Errorf("state store is required")
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return "", fmt.Errorf("load batch notification timezone: %w", err)
}
artifact := state.BatchDistributorNotificationArtifact{
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
Batch: string(batch),
BatchRunID: runID,
AttemptedAt: time.Now(),
Endpoint: cfg.Notify.Distributor.Endpoint,
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
BundleCreated: req.CreatedAt,
Reports: batchNotificationReportArtifacts(req.IncludedReports),
Status: "attempted",
}
if result != nil {
artifact.Status = result.Status
artifact.Upload = &state.DistributorUploadResult{
RunID: result.RunID,
Status: result.UploadStatus,
}
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
artifact.RunStatus = &state.DistributorRunStatus{
RunID: result.RunID,
PipelineID: result.PipelineID,
Status: result.Status,
AcceptedAt: result.AcceptedAt,
StartedAt: result.StartedAt,
FinishedAt: result.FinishedAt,
Report: append([]byte(nil), result.Report...),
Error: result.Error,
}
}
artifact.StatusError = result.StatusError
}
if notifyErr != nil {
artifact.Status = "failed"
artifact.Error = notifyErr.Error()
}
if artifact.Status == "" {
artifact.Status = "unknown"
}
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
Batch: string(batch),
BatchRunID: runID,
StartedAt: startedAt,
Location: location,
}, artifact)
}
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
if len(reports) == 0 {
return nil
}
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
for _, item := range reports {
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.SourcePath,
BundlePaths: append([]string(nil), item.BundlePaths...),
})
}
return artifacts
}
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
if err != nil {
return batchNotificationIdentity{}, err
}
bundleID, err := config.RenderDistributorBatchBundleID(cfg.Notify.Distributor.Batch.BundleIDTemplate, values)
if err != nil {
return batchNotificationIdentity{}, err
}
values.BundleID = bundleID
pipelineID, err := config.RenderDistributorBatchPipelineID(cfg.Notify.Distributor.Batch.PipelineIDTemplate, values)
if err != nil {
return batchNotificationIdentity{}, err
}
idempotencyKey, err := config.RenderDistributorBatchIdempotencyKey(cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate, values)
if err != nil {
return batchNotificationIdentity{}, err
}
return batchNotificationIdentity{
PipelineID: pipelineID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
}, nil
}
func batchNotificationTemplateValues(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (config.DistributorBatchTemplateValues, error) {
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return config.DistributorBatchTemplateValues{}, fmt.Errorf("load batch notification timezone: %w", err)
}
return config.DistributorBatchTemplateValues{
LocationID: cfg.Location.ID,
Batch: string(batch),
BatchRunID: runID,
BatchStartedDate: startedAt.In(location).Format(timeutil.DateLayout),
}, nil
}

139
internal/app/batch_plan.go Normal file
View File

@@ -0,0 +1,139 @@
package app
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type plannedBatchReport struct {
Resolved report.Resolved
OutputCopyName string
}
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := report.BatchForCommandName(string(req.Batch))
if err != nil {
return nil, err
}
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
resolveReq := report.ResolveRequest{
Now: now,
Location: location,
}
var planned []plannedBatchReport
switch batch {
case report.Morning:
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
if err != nil {
return nil, err
}
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
if err != nil {
return nil, err
}
case report.Evening:
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
var hourly *weatherdata.ForecastRun
if collection.Bundle != nil {
hourly = collection.Bundle.Hourly
}
for _, date := range eligibleDailyDates(hourly, now, location) {
dailyReq := resolveReq
dailyReq.Date = date
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
if err != nil {
return nil, err
}
}
return planned, nil
}
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
resolved, err := registry.Resolve(id, req)
if err != nil {
return nil, err
}
return append(planned, plannedBatchReport{
Resolved: resolved,
OutputCopyName: outputCopyName,
}), nil
}
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
return nil
}
hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods))
var maxLocalDate time.Time
for _, period := range hourly.Periods {
if !isHourlyPeriod(period) {
continue
}
start := period.StartTime
hourlyStarts[instantKey(start)] = struct{}{}
localDate := localDateStart(start, location)
if maxLocalDate.IsZero() || localDate.After(maxLocalDate) {
maxLocalDate = localDate
}
}
if len(hourlyStarts) == 0 || maxLocalDate.IsZero() {
return nil
}
startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location)
var dates []time.Time
for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) {
if hasFullHourlyCoverage(candidate, location, hourlyStarts) {
dates = append(dates, candidate)
}
}
return dates
}
func isHourlyPeriod(period weatherdata.ForecastPeriod) bool {
if period.StartTime.IsZero() || period.EndTime.IsZero() {
return false
}
return period.EndTime.Equal(period.StartTime.Add(time.Hour))
}
func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool {
day := timeutil.CivilDay(date, location)
for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) {
if _, ok := hourlyStarts[instantKey(required)]; !ok {
return false
}
}
return true
}
func instantKey(value time.Time) time.Time {
return value.UTC()
}
func localDateStart(value time.Time, location *time.Location) time.Time {
local := value.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}

View File

@@ -0,0 +1,352 @@
package app
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestPlanBatchRunMorningOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
}
func TestPlanBatchRunEveningOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
}
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-30", location)
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
daily := plannedDailyReports(planned)
if len(daily) != 2 {
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
}
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
}
func TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
for _, item := range planned {
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
}
}
}
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
daily := plannedDailyReports(planned)
if len(daily) != 1 {
t.Fatalf("daily reports = %#v, want one Daily report", daily)
}
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
}
if planned[0].OutputCopyName != "" {
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
}
}
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
}
}
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-31", location)
periods = append(periods[:12], periods[13:]...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location)
}
func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-31", location)
periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-29", location)
periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...)
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-31", location)
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31", "2026-06-01")
}
func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location)
periods := []weatherdata.ForecastPeriod{
{StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)},
{StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)},
{StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)},
}
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-06-01")
}
func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) {
location := mustLoadTestLocation(t, "America/New_York")
tests := []struct {
name string
now string
date string
}{
{
name: "spring forward",
now: "2026-03-06T08:00:00-05:00",
date: "2026-03-08",
},
{
name: "fall back",
now: "2026-10-30T08:00:00-04:00",
date: "2026-11-01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...)
got := eligibleDailyDates(hourly, mustParse(tt.now), location)
assertLocalDates(t, got, location, tt.date)
})
}
}
func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
fullDay := fullDayPeriods(t, "2026-05-31", location)
tests := []struct {
name string
hourly *weatherdata.ForecastRun
}{
{name: "nil run"},
{name: "empty periods", hourly: hourlyRun()},
{name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location)
})
}
}
func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
return forecastRun("hourly", periods...)
}
func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
return &weatherdata.ForecastRun{
Product: product,
Periods: periods,
}
}
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
}
func planningConfig() config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
t.Helper()
gotIDs := make([]string, 0, len(got))
for _, item := range got {
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
}
wantIDs := make([]string, 0, len(want))
for _, id := range want {
wantIDs = append(wantIDs, string(id))
}
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
}
}
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
var daily []plannedBatchReport
for _, item := range planned {
if item.Resolved.Definition.ID == report.Daily {
daily = append(daily, item)
}
}
return daily
}
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
t.Helper()
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
var periods []weatherdata.ForecastPeriod
for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) {
periods = append(periods, weatherdata.ForecastPeriod{
StartTime: start,
EndTime: start.Add(time.Hour),
})
}
return periods
}
func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod {
t.Helper()
periods := fullDayPeriods(t, date, location)
if count > len(periods) {
count = len(periods)
}
return periods[:count]
}
func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod {
t.Helper()
out := make([]weatherdata.ForecastPeriod, 0, len(periods))
for _, period := range periods {
start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339))
if err != nil {
t.Fatalf("parse fixed-offset start: %v", err)
}
end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339))
if err != nil {
t.Fatalf("parse fixed-offset end: %v", err)
}
out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end})
}
return out
}
func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) {
t.Helper()
gotDates := make([]string, 0, len(got))
for _, date := range got {
gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout))
}
if strings.Join(gotDates, ",") != strings.Join(want, ",") {
t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ","))
}
for _, date := range got {
day := timeutil.CivilDay(date, location)
if !date.Equal(day.Start) {
t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start)
}
}
}
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
t.Fatalf("period = %#v, want valid", period)
}
if got := period.Start.Format(time.RFC3339); got != wantStart {
t.Fatalf("Start = %s, want %s", got, wantStart)
}
if got := period.End.Format(time.RFC3339); got != wantEnd {
t.Fatalf("End = %s, want %s", got, wantEnd)
}
}
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
t.Helper()
location, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%q) error = %v", name, err)
}
return location
}
func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time {
t.Helper()
parsed, err := timeutil.ParseLocalDate(value, location)
if err != nil {
t.Fatalf("ParseLocalDate(%q) error = %v", value, err)
}
return parsed
}

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,36 +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)
}
_, _ = 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,12 +179,12 @@ 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:")
} }
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -175,9 +195,11 @@ func TestRunMorningIncludesWeekendExceptSunday(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", "weekend", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "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", "daily", "2026-05-29", "data_package.*.yaml")
} }
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
@@ -191,44 +213,51 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("Run() error = nil, want aggregate failure") t.Fatal("Run() error = nil, want aggregate failure")
} }
if !strings.Contains(err.Error(), "1 of 3 reports failed") { if !strings.Contains(err.Error(), "1 of 2 reports failed") {
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 != 3 || summary.Succeeded != 2 || summary.Failed != 1 { if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/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)
} }
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", "weekend", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
} }
func TestBatchOutputIncludesNotificationDetails(t *testing.T) { func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
result := &app.BatchResult{ result := &app.BatchResult{
Batch: app.BatchMorning, Batch: app.BatchMorning,
Total: 2, Total: 2,
Succeeded: 1, Succeeded: 2,
Failed: 1, Failed: 0,
Notification: &app.BatchNotificationResult{
Status: "succeeded",
RunID: "batch-distributor-run",
PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.morning",
IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning",
Path: "/tmp/distributor.batch.json",
IncludedReports: []app.BatchNotificationReport{
{ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}},
},
},
Reports: []app.BatchReportResult{ Reports: []app.BatchReportResult{
{ {
ReportID: "daily", ReportID: "daily",
Status: "succeeded", Status: "succeeded",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
NotificationStatus: "accepted",
NotificationRunID: "distributor-run-1",
}, },
{ {
ReportID: "three_day", ReportID: "tomorrow",
Status: "failed", Status: "succeeded",
Error: "notify report three_day: upload failed", OutputPath: "/tmp/tomorrow.md",
NotificationStatus: "failed",
NotificationError: "notify report three_day: upload failed",
}, },
}, },
} }
@@ -238,23 +267,28 @@ func TestBatchOutputIncludesNotificationDetails(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 {
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String()) t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
} }
if decoded.Reports[0].NotificationStatus != "accepted" || decoded.Reports[0].NotificationRunID != "distributor-run-1" { if decoded.Notification == nil || decoded.Notification.Status != "succeeded" || decoded.Notification.RunID != "batch-distributor-run" || decoded.Notification.PipelineID != "weatherreporter" || len(decoded.Notification.IncludedReports) != 1 {
t.Fatalf("success notification fields = %#v", decoded.Reports[0]) t.Fatalf("top-level notification = %#v, want succeeded batch notification", decoded.Notification)
} }
if decoded.Reports[1].NotificationStatus != "failed" || !strings.Contains(decoded.Reports[1].NotificationError, "upload failed") { for _, report := range decoded.Reports {
t.Fatalf("failure notification fields = %#v", decoded.Reports[1]) if report.NotificationStatus != "" || report.NotificationRunID != "" || report.NotificationError != "" {
t.Fatalf("report notification fields = %#v, want empty", report)
}
} }
if !strings.Contains(stderr.String(), `notificationStatus="accepted"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) { if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr missing success notification fields:\n%s", stderr.String()) t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
} }
if !strings.Contains(stderr.String(), `notificationStatus="failed"`) || !strings.Contains(stderr.String(), `notificationError="notify report three_day: upload failed"`) { if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) || !strings.Contains(stderr.String(), `pipelineId="weatherreporter"`) {
t.Fatalf("stderr missing failure notification fields:\n%s", stderr.String()) t.Fatalf("stderr missing batch notification details:\n%s", stderr.String())
}
if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") {
t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String())
} }
} }
@@ -263,13 +297,14 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
Batch: app.BatchMorning, Batch: app.BatchMorning,
Total: 1, Total: 1,
Failed: 1, Failed: 1,
Notification: &app.BatchNotificationResult{
Status: "failed",
Error: "notify batch morning: upload failed: [redacted]",
},
Reports: []app.BatchReportResult{ Reports: []app.BatchReportResult{
{ {
ReportID: "daily", ReportID: "daily",
Status: "failed", Status: "succeeded",
Error: "notify report daily: upload failed: [redacted]",
NotificationStatus: "failed",
NotificationError: "notify report daily: upload failed: [redacted]",
}, },
}, },
} }
@@ -279,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") {
@@ -291,6 +326,74 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
} }
} }
func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning,
Total: 2,
Succeeded: 1,
Failed: 1,
Notification: &app.BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
},
Reports: []app.BatchReportResult{
{ReportID: "today", Status: "succeeded", OutputPath: "/tmp/today.md"},
{ReportID: "tomorrow", Status: "failed", Error: "render failed"},
},
}
var stderr bytes.Buffer
var stdout bytes.Buffer
if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
writeBatchStatus(&stderr, result)
var decoded app.BatchResult
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
}
if decoded.Notification == nil || decoded.Notification.Status != "skipped" || decoded.Notification.Reason != "one or more reports failed" {
t.Fatalf("top-level notification = %#v, want skipped notification", decoded.Notification)
}
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if !strings.Contains(stderr.String(), `batchNotification status="skipped" reason="one or more reports failed"`) {
t.Fatalf("stderr missing skipped batch notification:\n%s", stderr.String())
}
}
func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchEvening,
Total: 1,
Succeeded: 1,
Failed: 1,
Notification: &app.BatchNotificationResult{
Status: "failed",
Error: "notify batch evening: upload failed",
},
Reports: []app.BatchReportResult{
{ReportID: "tomorrow", Status: "succeeded", OutputPath: "/tmp/tomorrow.md"},
},
}
var stderr bytes.Buffer
writeBatchStatus(&stderr, result)
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if count := strings.Count(stderr.String(), "notify batch evening: upload failed"); count != 1 {
t.Fatalf("stderr batch notification error occurrences = %d, want one:\n%s", count, stderr.String())
}
reportLine := firstLineWithPrefix(stderr.String(), "report=tomorrow ")
if strings.Contains(reportLine, "notify batch evening") || strings.Contains(reportLine, "notificationError") {
t.Fatalf("report line repeats batch notification error:\n%s", reportLine)
}
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
outputDir := fixture.path("copies") outputDir := fixture.path("copies")
@@ -304,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)
@@ -319,20 +422,40 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
} }
} }
func TestRunEveningReportsNotificationSuccess(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) {
server := dailyServer(t) server := dailyServer(t)
var uploadCount int
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/runs/distributor-run-1" { if r.URL.Path == "/runs/batch-distributor-run" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.tomorrow","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`)) _, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","pipeline_id":"weatherreporter","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
return return
} }
if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" { if r.URL.Path != "/v1/pipelines/weatherreporter/upload" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
uploadCount++
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","status":"accepted"}`)) _, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","status":"accepted"}`))
})) }))
t.Cleanup(distributorServer.Close) t.Cleanup(distributorServer.Close)
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -352,36 +475,46 @@ func TestRunEveningReportsNotificationSuccess(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)
} }
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.tomorrow" { if uploadCount != 1 {
t.Fatalf("notification fields = %#v", summary.Reports[0]) t.Fatalf("batch upload count = %d, want 1", uploadCount)
} }
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) { if summary.Notification == nil || summary.Notification.Status != "succeeded" || summary.Notification.RunID != "batch-distributor-run" {
t.Fatalf("stderr missing notification fields:\n%s", stderr.String()) t.Fatalf("batch notification = %#v, want succeeded batch notification", summary.Notification)
}
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) {
t.Fatalf("stderr missing batch notification success:\n%s", stderr.String())
}
if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationRunID != "" || summary.Reports[0].NotificationPipelineID != "" || summary.Reports[0].NotificationError != "" || summary.Reports[0].NotificationPath != "" {
t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0])
}
if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") {
t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String())
} }
if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") { if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") {
t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String())
} }
} }
func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) { func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest) t.Fatalf("unexpected distributor request %s", r.URL.Path)
_, _ = w.Write([]byte(`{"error":"rejected cli-secret-token","retryable":false}`))
})) }))
t.Cleanup(distributorServer.Close) t.Cleanup(distributorServer.Close)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace") workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -390,30 +523,26 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
"run", "evening", "run", "evening",
"--config", configPath, "--config", configPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if err == nil { if err != nil {
t.Fatal("Run() error = nil, want notification failure") 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 || summary.Reports[0].NotificationStatus != "failed" { if len(summary.Reports) != 1 {
t.Fatalf("summary reports = %#v, want failed notification", summary.Reports) t.Fatalf("summary reports = %#v, want one report", summary.Reports)
} }
for _, output := range []string{stdout.String(), stderr.String(), err.Error()} { if summary.Notification != nil {
if strings.Contains(output, "cli-secret-token") { t.Fatalf("batch notification = %#v, want omitted when disabled", summary.Notification)
t.Fatalf("output contains token value:\n%s", output)
}
} }
for _, output := range []string{stdout.String(), stderr.String()} { if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationError != "" {
if !strings.Contains(output, "[redacted]") { t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0])
t.Fatalf("output missing redaction marker:\n%s", output)
}
} }
} }
func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) { func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -426,9 +555,11 @@ func TestRunMorningGeneratesTodayAndThreeDayOnSunday(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", "three-day", "2026-05-31", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "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", "daily", "2026-05-31", "data_package.*.yaml")
} }
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
@@ -448,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)
@@ -470,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)
@@ -478,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"`)
@@ -505,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)
} }
@@ -514,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)
@@ -528,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) {
@@ -554,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)
} }
@@ -564,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)
@@ -578,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": {`)
@@ -592,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()}
@@ -606,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)
@@ -651,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)
@@ -698,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"))
@@ -1086,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) {
@@ -1159,6 +1408,12 @@ func writeTestConfigWithDistributor(t *testing.T, server *httptest.Server, scrip
return writeConfigFile(t, configBody) return writeConfigFile(t, configBody)
} }
func writeTestConfigWithDisabledBatchDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string {
t.Helper()
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n batch:\n enabled: false\n"
return writeConfigFile(t, configBody)
}
func writeWorkspaceConfig(t *testing.T, workspaceRoot string) string { func writeWorkspaceConfig(t *testing.T, workspaceRoot string) string {
t.Helper() t.Helper()
return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n") return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n")
@@ -1187,6 +1442,25 @@ 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 {
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, prefix) {
return line
}
}
return ""
}
func assertFileContains(t *testing.T, path string, want string) { func assertFileContains(t *testing.T, path string, want string) {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -1377,7 +1651,7 @@ if [ "$1" = "render" ]; then
fi fi
shift shift
done done
if [ "$prompt" = "weather.three_day_outlook" ]; then if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
printf 'render failed\n' >&2 printf 'render failed\n' >&2
exit 1 exit 1
fi fi

View File

@@ -0,0 +1,31 @@
// Package collect owns upstream weather source collection for application use.
package collect
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type Request struct {
Config config.Config
}
type Result struct {
Bundle *weatherdata.Bundle
}
func Run(ctx context.Context, req Request) (*Result, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
return nil, fmt.Errorf("prepare weather collection: %w", err)
}
bundle, err := client.FetchBundle(ctx)
if err != nil {
return nil, fmt.Errorf("collect weather bundle: %w", err)
}
return &Result{Bundle: bundle}, nil
}

View File

@@ -0,0 +1,99 @@
package collect
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
func TestRunFetchesBundle(t *testing.T) {
server := collectionTestServer(t, nil)
defer server.Close()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
result, err := Run(context.Background(), Request{Config: cfg})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if result == nil || result.Bundle == nil {
t.Fatal("Run() result bundle = nil, want fetched bundle")
}
if result.Bundle.Hourly == nil {
t.Fatal("Hourly = nil, want fetched hourly forecast")
}
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
}
}
func TestRunWrapsAdapterConstructionError(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Secrets.Directory = "super-secret-directory"
_, err := Run(context.Background(), Request{Config: cfg})
if err == nil {
t.Fatal("Run() error = nil, want adapter construction error")
}
if !strings.Contains(err.Error(), "prepare weather collection") {
t.Fatalf("error = %q, want collection setup context", err.Error())
}
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
t.Fatalf("error = %q, want no secret path leakage", err.Error())
}
}
func TestRunWrapsFetchError(t *testing.T) {
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
defer server.Close()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
_, err := Run(context.Background(), Request{Config: cfg})
if err == nil {
t.Fatal("Run() error = nil, want fetch error")
}
if !strings.Contains(err.Error(), "collect weather bundle") {
t.Fatalf("error = %q, want collection fetch context", err.Error())
}
if !strings.Contains(err.Error(), "/observations") {
t.Fatalf("error = %q, want source endpoint context", err.Error())
}
}
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status := statusByPath[r.URL.Path]; status != 0 {
http.Error(w, "upstream failure", status)
return
}
switch r.URL.Path {
case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default:
http.NotFound(w, r)
}
}))
}

View File

@@ -58,15 +58,22 @@ type NotifyConfig struct {
} }
type DistributorNotifyConfig struct { type DistributorNotifyConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
TokenEnv string `yaml:"token_env"` TokenEnv string `yaml:"token_env"`
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"` FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
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"`
}
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"`
} }
type MissingSourceConfig struct { type MissingSourceConfig struct {
@@ -105,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"`
@@ -127,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)
} }
@@ -134,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,11 +58,17 @@ 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{ if !cfg.Notify.Distributor.Batch.Enabled {
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md", t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
} }
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") { if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths) t.Fatalf("Notify.Distributor.Batch.PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Notify.Distributor.Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Notify.Distributor.Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
} }
if cfg.MissingSource.Default != MissingSourceWarn { if cfg.MissingSource.Default != MissingSourceWarn {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
@@ -74,7 +81,7 @@ func TestLoadExampleConfig(t *testing.T) {
t.Fatalf("LoadFile() error = %v", err) t.Fatalf("LoadFile() error = %v", err)
} }
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" { if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL) t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
} }
if cfg.WeatherAPI.Timeout != 15*time.Second { if cfg.WeatherAPI.Timeout != 15*time.Second {
@@ -89,8 +96,17 @@ 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 { if !cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates) t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Batch PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Batch BundleIDTemplate = %q, want example batch bundle template", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Batch IdempotencyKeyTemplate = %q, want example batch idempotency template", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
} }
overrides, err := cfg.ReportModuleOverrides() overrides, err := cfg.ReportModuleOverrides()
if err != nil { if err != nil {
@@ -289,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{
@@ -541,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
@@ -782,6 +1084,89 @@ 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) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = false
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestEnabledDistributorNotifyValidation(t *testing.T) { func TestEnabledDistributorNotifyValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -859,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",
}, },
} }
@@ -906,6 +1270,109 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
} }
} }
func TestEnabledDistributorBatchNotifyValidation(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
wantErr string
}{
{
name: "PipelineTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = ""
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "PipelineTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{report_id}"
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "PipelineTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = " "
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "BundleTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = ""
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "BundleTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "BundleTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = " "
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "IdempotencyTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = ""
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
{
name: "IdempotencyTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{report_id}"
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
{
name: "IdempotencyTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = " "
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
tt.mutate(&cfg)
err := Validate(cfg)
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDisabledDistributorBatchNotifySkipsBatchTemplateValidation(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.Batch.Enabled = false
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestDistributorTemplateRendering(t *testing.T) { func TestDistributorTemplateRendering(t *testing.T) {
values := DistributorTemplateValues{ values := DistributorTemplateValues{
LocationID: "home", LocationID: "home",
@@ -919,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 {
@@ -955,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") {
@@ -962,6 +1433,113 @@ 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) {
values := DistributorBatchTemplateValues{
LocationID: "home",
Batch: "evening",
BatchRunID: "20260617T235037.642224552Z_evening",
BatchStartedDate: "2026-06-17",
}
bundleID, err := RenderDistributorBatchBundleID("weatherreporter.{location_id}.{batch}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchBundleID() error = %v", err)
}
if bundleID != "weatherreporter.home.evening" {
t.Fatalf("bundleID = %q, want batch bundle ID", bundleID)
}
values.BundleID = bundleID
pipelineID, err := RenderDistributorBatchPipelineID("weatherreporter", values)
if err != nil {
t.Fatalf("RenderDistributorBatchPipelineID() error = %v", err)
}
if pipelineID != "weatherreporter" {
t.Fatalf("pipelineID = %q, want weatherreporter", pipelineID)
}
idempotencyKey, err := RenderDistributorBatchIdempotencyKey("{bundle_id}.{batch_run_id}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchIdempotencyKey() error = %v", err)
}
if idempotencyKey != "weatherreporter.home.evening.20260617T235037.642224552Z_evening" {
t.Fatalf("idempotencyKey = %q, want batch retry key", idempotencyKey)
}
bundleID, err = RenderDistributorBatchBundleID("weatherreporter.{batch_started_date}.{batch}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchBundleID() with date error = %v", err)
}
if bundleID != "weatherreporter.2026-06-17.evening" {
t.Fatalf("bundleID = %q, want date-aware batch bundle ID", bundleID)
}
}
func TestDistributorBatchTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
tests := []struct {
name string
template string
}{
{name: "Unknown", template: "{report_id}"},
{name: "Unclosed", template: "{batch"},
{name: "Unopened", template: "batch}"},
{name: "Empty", template: "{}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorBatchBundleID(tt.template, DistributorBatchTemplateValues{})
if err == nil {
t.Fatal("RenderDistributorBatchBundleID() error = nil, want error")
}
})
}
}
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) { func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -999,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 {
@@ -1026,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,8 +31,11 @@ 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{ Batch: DistributorBatchNotifyConfig{
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md", Enabled: true,
PipelineIDTemplate: "weatherreporter",
BundleIDTemplate: "weatherreporter.{location_id}.{batch}",
IdempotencyKeyTemplate: "{bundle_id}.{batch_run_id}",
}, },
}, },
}, },

View File

@@ -18,9 +18,18 @@ type DistributorTemplateValues struct {
ValidEndTime string ValidEndTime string
ValidStartStamp string ValidStartStamp string
ValidEndStamp string ValidEndStamp string
StormID string
BundleID string BundleID string
} }
type DistributorBatchTemplateValues struct {
LocationID string
Batch string
BatchRunID string
BatchStartedDate string
BundleID string
}
var distributorTemplateVariables = map[string]struct{}{ var distributorTemplateVariables = map[string]struct{}{
"location_id": {}, "location_id": {},
"report_id": {}, "report_id": {},
@@ -33,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{}{
@@ -47,11 +57,29 @@ 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": {},
} }
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables
var distributorBatchTemplateVariables = map[string]struct{}{
"location_id": {},
"batch": {},
"batch_run_id": {},
"batch_started_date": {},
}
var distributorBatchIdempotencyTemplateVariables = map[string]struct{}{
"location_id": {},
"batch": {},
"batch_run_id": {},
"batch_started_date": {},
"bundle_id": {},
}
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) { func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables) return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
} }
@@ -71,23 +99,56 @@ func RenderDistributorIdempotencyKey(template string, values DistributorTemplate
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables) return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
} }
func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) { func RenderDistributorBatchBundleID(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", template, values, distributorBatchTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.batch.bundle_id_template renders an empty bundle id")
}
return rendered, nil
}
func RenderDistributorBatchPipelineID(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", template, values, distributorBatchPipelineTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.batch.pipeline_id_template renders an empty pipeline id")
}
return rendered, nil
}
func RenderDistributorBatchIdempotencyKey(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", template, values, distributorBatchIdempotencyTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.batch.idempotency_key_template renders an empty idempotency key")
}
return rendered, nil
}
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)
@@ -100,6 +161,11 @@ func validateDistributorTemplate(name, template string, allowed map[string]struc
return err return err
} }
func validateDistributorBatchTemplate(name, template string, allowed map[string]struct{}) error {
_, err := renderDistributorBatchTemplate(name, template, DistributorBatchTemplateValues{}, allowed)
return err
}
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) { func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
var rendered strings.Builder var rendered strings.Builder
for i := 0; i < len(template); { for i := 0; i < len(template); {
@@ -128,6 +194,34 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
return rendered.String(), nil return rendered.String(), nil
} }
func renderDistributorBatchTemplate(name, template string, values DistributorBatchTemplateValues, allowed map[string]struct{}) (string, error) {
var rendered strings.Builder
for i := 0; i < len(template); {
switch template[i] {
case '{':
end := strings.IndexByte(template[i+1:], '}')
if end < 0 {
return "", fmt.Errorf("%s contains an unclosed template variable", name)
}
variable := template[i+1 : i+1+end]
if variable == "" {
return "", fmt.Errorf("%s contains an empty template variable", name)
}
if _, ok := allowed[variable]; !ok {
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
}
rendered.WriteString(distributorBatchTemplateValue(variable, values))
i += end + 2
case '}':
return "", fmt.Errorf("%s contains an unopened template variable", name)
default:
rendered.WriteByte(template[i])
i++
}
}
return rendered.String(), nil
}
func distributorTemplateValue(variable string, values DistributorTemplateValues) string { func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
switch variable { switch variable {
case "location_id": case "location_id":
@@ -152,6 +246,25 @@ 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":
return values.BundleID
default:
return ""
}
}
func distributorBatchTemplateValue(variable string, values DistributorBatchTemplateValues) string {
switch variable {
case "location_id":
return values.LocationID
case "batch":
return values.Batch
case "batch_run_id":
return values.BatchRunID
case "batch_started_date":
return values.BatchStartedDate
case "bundle_id": case "bundle_id":
return values.BundleID return values.BundleID
default: default:
@@ -178,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)
} }
} }
@@ -186,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,13 +133,56 @@ 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 { if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
return err return err
} }
return nil return nil
} }
func validateDistributorBatchNotify(cfg DistributorBatchNotifyConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.PipelineIDTemplate == "" {
return fmt.Errorf("notify.distributor.batch.pipeline_id_template is required when enabled")
}
if err := validateDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", cfg.PipelineIDTemplate, distributorBatchPipelineTemplateVariables); err != nil {
return err
}
if cfg.BundleIDTemplate == "" {
return fmt.Errorf("notify.distributor.batch.bundle_id_template is required when enabled")
}
if err := validateDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", cfg.BundleIDTemplate, distributorBatchTemplateVariables); err != nil {
return err
}
if cfg.IdempotencyKeyTemplate == "" {
return fmt.Errorf("notify.distributor.batch.idempotency_key_template is required when enabled")
}
if err := validateDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorBatchIdempotencyTemplateVariables); err != nil {
return err
}
values := DistributorBatchTemplateValues{
LocationID: "location",
Batch: "morning",
BatchRunID: "20260529T100000.000000000Z_morning",
BatchStartedDate: "2026-05-29",
}
bundleID, err := RenderDistributorBatchBundleID(cfg.BundleIDTemplate, values)
if err != nil {
return err
}
values.BundleID = bundleID
if _, err := RenderDistributorBatchPipelineID(cfg.PipelineIDTemplate, values); err != nil {
return err
}
if _, err := RenderDistributorBatchIdempotencyKey(cfg.IdempotencyKeyTemplate, values); err != nil {
return err
}
return nil
}
func validatePolicy(name string, policy MissingSourcePolicy) error { func validatePolicy(name string, policy MissingSourcePolicy) error {
switch policy { switch policy {
case MissingSourceError, MissingSourceWarn, MissingSourceNone: case MissingSourceError, MissingSourceWarn, MissingSourceNone:

View File

@@ -18,15 +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, },
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,
@@ -35,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) {
@@ -50,3 +58,14 @@ func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
} }
return timeutil.CivilDay(req.Date, req.Location), nil return timeutil.CivilDay(req.Date, req.Location), nil
} }
func validStartDateRunIDDisambiguator(resolved Resolved) string {
if resolved.ValidPeriod.Start.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(resolved.Timezone)
if err != nil {
return resolved.ValidPeriod.Start.Format(timeutil.DateLayout)
}
return resolved.ValidPeriod.Start.In(location).Format(timeutil.DateLayout)
}

View File

@@ -3,6 +3,7 @@ package report
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -45,21 +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
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) {
@@ -112,7 +115,7 @@ type Metadata struct {
func (r Resolved) Metadata() Metadata { func (r Resolved) Metadata() Metadata {
return Metadata{ return Metadata{
RunID: r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID), RunID: r.runID(),
ReportID: r.Definition.ID, ReportID: r.Definition.ID,
PromptID: r.Definition.PromptID, PromptID: r.Definition.PromptID,
GeneratedAt: r.GeneratedAt, GeneratedAt: r.GeneratedAt,
@@ -120,3 +123,15 @@ func (r Resolved) Metadata() Metadata {
ValidPeriod: r.ValidPeriod, ValidPeriod: r.ValidPeriod,
} }
} }
func (r Resolved) runID() string {
runID := r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID)
if r.Definition.runIDDisambiguator == nil {
return runID
}
disambiguator := strings.TrimSpace(r.Definition.runIDDisambiguator(r))
if disambiguator == "" {
return runID
}
return runID + "_" + disambiguator
}

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

@@ -1,9 +1,6 @@
package report package report
import ( import "time"
"fmt"
"time"
)
func Resolve(id ID, req ResolveRequest) (Resolved, error) { func Resolve(id ID, req ResolveRequest) (Resolved, error) {
return DefaultRegistry().Resolve(id, req) return DefaultRegistry().Resolve(id, req)
@@ -17,39 +14,6 @@ func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
return r.resolveDefinition(definition, req) return r.resolveDefinition(definition, req)
} }
func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, error) {
if req.Location == nil {
req.Location = time.UTC
}
if req.Now.IsZero() {
req.Now = time.Now()
}
switch batch {
case Morning:
ids := []ID{Today, ThreeDay}
if req.Now.In(req.Location).Weekday() != time.Sunday {
ids = append(ids, Weekend)
}
return r.resolveIDs(ids, req)
case Evening:
return r.resolveIDs([]ID{Tomorrow}, req)
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
}
func (r Registry) resolveIDs(ids []ID, req ResolveRequest) ([]Resolved, error) {
resolved := make([]Resolved, 0, len(ids))
for _, id := range ids {
item, err := r.Resolve(id, req)
if err != nil {
return nil, err
}
resolved = append(resolved, item)
}
return resolved, nil
}
func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) { func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
if req.Location == nil { if req.Location == nil {
req.Location = time.UTC req.Location = time.UTC

View File

@@ -241,55 +241,6 @@ func TestStormResolve(t *testing.T) {
} }
} }
func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-31T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "today,three_day" {
t.Fatalf("ids = %v, want today and three_day", ids)
}
}
func TestEveningBatchIncludesTomorrow(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Evening, ResolveRequest{
Now: mustParse("2026-05-29T18:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "tomorrow" {
t.Fatalf("ids = %v, want tomorrow", ids)
}
}
func TestBatchesDoNotIncludeManualReports(t *testing.T) {
location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
morning, err := DefaultRegistry().BatchReports(Morning, req)
if err != nil {
t.Fatalf("BatchReports(morning) error = %v", err)
}
evening, err := DefaultRegistry().BatchReports(Evening, req)
if err != nil {
t.Fatalf("BatchReports(evening) error = %v", err)
}
for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
}
}
}
func TestIDForCommandName(t *testing.T) { func TestIDForCommandName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -409,21 +360,6 @@ func TestBatchForCommandName(t *testing.T) {
} }
} }
func TestMorningBatchReportOrder(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "today,three_day,weekend" {
t.Fatalf("ids = %v, want morning report order", ids)
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) { func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown")) _, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil { if err == nil {
@@ -584,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
@@ -746,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) {
@@ -777,8 +798,41 @@ func TestResolvedMetadata(t *testing.T) {
if metadata.PromptID != "weather.daily_generated_text" { if metadata.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "daily") { if metadata.RunID != "20260529T100000.000000000Z_daily_2026-05-29" {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want dated Daily report id", metadata.RunID)
}
}
func TestDailyRunIDIncludesValidDateDisambiguator(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:00:00-05:00")
first, err := Resolve(Daily, ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-05-31T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve(first) error = %v", err)
}
second, err := Resolve(Daily, ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-06-01T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve(second) error = %v", err)
}
firstRunID := first.Metadata().RunID
secondRunID := second.Metadata().RunID
if firstRunID == secondRunID {
t.Fatalf("Daily RunIDs both = %q, want distinct valid-date suffixes", firstRunID)
}
if firstRunID != "20260529T100000.000000000Z_daily_2026-05-31" {
t.Fatalf("first RunID = %q, want valid-date suffix", firstRunID)
}
if secondRunID != "20260529T100000.000000000Z_daily_2026-06-01" {
t.Fatalf("second RunID = %q, want valid-date suffix", secondRunID)
} }
} }
@@ -795,8 +849,8 @@ func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
if metadata.PromptID != "weather.hourly_generated_text" { if metadata.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "hourly") { if metadata.RunID != "20260529T101500.000000000Z_hourly" {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want unchanged report id shape", metadata.RunID)
} }
} }
@@ -813,14 +867,6 @@ func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEn
} }
} }
func resolvedIDs(resolved []Resolved) []string {
ids := make([]string, 0, len(resolved))
for _, item := range resolved {
ids = append(ids, string(item.Definition.ID))
}
return ids
}
func resolvedDefinitionIDs(definitions []Definition) []string { func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions)) ids := make([]string, 0, len(definitions))
for _, definition := range definitions { for _, definition := range definitions {

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
} }
@@ -141,6 +140,33 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve
}, artifact) }, artifact)
} }
func (s *FilesystemStore) BatchDistributorNotificationPath(ref BatchDistributorNotificationRef) (string, error) {
if s == nil {
return "", fmt.Errorf("state store is required")
}
if err := validateBatchNotificationRef(ref); err != nil {
return "", err
}
localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02")
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) {
path, err := s.BatchDistributorNotificationPath(ref)
if err != nil {
return "", err
}
if artifact.SchemaVersion == "" {
artifact.SchemaVersion = BatchDistributorNotificationSchemaVersion
}
artifact.Batch = ref.Batch
artifact.BatchRunID = ref.BatchRunID
if err := fileutil.WriteJSONAtomic(path, artifact); err != nil {
return "", err
}
return path, nil
}
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) { func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string { return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
return paths.GeneratedTextRaw return paths.GeneratedTextRaw
@@ -245,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())
@@ -296,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)
@@ -461,6 +487,43 @@ func validateRelativeDir(name string, value string) error {
return nil return nil
} }
func validateBatchNotificationRef(ref BatchDistributorNotificationRef) error {
if err := validatePathSegment("batch kind", ref.Batch); err != nil {
return err
}
if err := validatePathSegment("batch run id", ref.BatchRunID); err != nil {
return err
}
if ref.StartedAt.IsZero() {
return fmt.Errorf("batch started time is required")
}
if ref.Location == nil {
return fmt.Errorf("batch location is required")
}
return nil
}
func validatePathSegment(name string, value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
if strings.ContainsAny(value, `/\`) {
return fmt.Errorf("%s must not contain path separators", name)
}
if value == "." || value == ".." {
return fmt.Errorf("%s must be a safe path segment", name)
}
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.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.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.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.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.distributor.json"), filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily.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)
@@ -40,6 +40,236 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
} }
func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
store := newTestStore(t)
first := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-05-31T12:00:00-05:00")
second := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-06-01T12:00:00-05:00")
firstPaths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths(first) error = %v", err)
}
secondPaths, err := store.Paths(second)
if err != nil {
t.Fatalf("Paths(second) error = %v", err)
}
if first.Metadata().RunID == second.Metadata().RunID {
t.Fatalf("RunIDs both = %q, want distinct daily run ids", first.Metadata().RunID)
}
for name, values := range map[string][2]string{
"RenderedReport": {firstPaths.RenderedReport, secondPaths.RenderedReport},
"Metadata": {firstPaths.Metadata, secondPaths.Metadata},
"DataPackage": {firstPaths.DataPackage, secondPaths.DataPackage},
"Notification": {firstPaths.Notification, secondPaths.Notification},
} {
if values[0] == values[1] {
t.Fatalf("%s paths both = %q, want distinct daily artifact paths", name, values[0])
}
}
}
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) {
store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago")
startedAt := time.Date(2026, 6, 18, 3, 30, 0, 123456789, time.UTC)
path, err := store.BatchDistributorNotificationPath(BatchDistributorNotificationRef{
Batch: "evening",
BatchRunID: "20260618T033000.123456789Z_evening",
StartedAt: startedAt,
Location: location,
})
if err != nil {
t.Fatalf("BatchDistributorNotificationPath() error = %v", err)
}
want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "distributor.20260618T033000.123456789Z_evening.json")
if !strings.Contains(path, want) {
t.Fatalf("path = %q, want component %q", path, want)
}
if !strings.HasPrefix(path, store.root) {
t.Fatalf("path = %q, want workspace root prefix %q", path, store.root)
}
}
func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago")
startedAt := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
bundleCreated := startedAt.Add(2 * time.Second)
attemptedAt := startedAt.Add(3 * time.Second)
acceptedAt := startedAt.Add(4 * time.Second)
finishedAt := startedAt.Add(5 * time.Second)
ref := BatchDistributorNotificationRef{
Batch: "morning",
BatchRunID: "20260617T120000.000000000Z_morning",
StartedAt: startedAt,
Location: location,
}
path, err := store.SaveBatchDistributorNotification(context.Background(), ref, BatchDistributorNotificationArtifact{
AttemptedAt: attemptedAt,
Endpoint: "https://distributor.example.test",
PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.morning",
IdempotencyKey: "weatherreporter.home.morning.20260617T120000.000000000Z_morning",
BundleCreated: bundleCreated,
Reports: []BatchDistributorNotificationReportArtifact{
{
ReportID: report.Today,
RunID: "20260617T120000.000000000Z_today",
SourcePath: "/workspace/reports/today/2026-06-17/report.20260617T120000.000000000Z_today.md",
BundlePaths: []string{"2026-06-17/today/report.md"},
},
{
ReportID: report.Daily,
RunID: "20260617T120000.000000000Z_daily_2026-06-19",
SourcePath: "/workspace/reports/daily/2026-06-19/report.20260617T120000.000000000Z_daily_2026-06-19.md",
BundlePaths: []string{"2026-06-19/daily/report.md"},
},
},
Status: "failed",
Upload: &DistributorUploadResult{
RunID: "distributor-run",
Status: "accepted",
},
RunStatus: &DistributorRunStatus{
RunID: "distributor-run",
PipelineID: "weatherreporter",
Status: "failed",
AcceptedAt: acceptedAt,
FinishedAt: &finishedAt,
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
Error: "destination conflict",
},
StatusError: "status lookup failed",
Error: "batch upload failed",
})
if err != nil {
t.Fatalf("SaveBatchDistributorNotification() error = %v", err)
}
wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "distributor.20260617T120000.000000000Z_morning.json")
if !strings.Contains(path, wantPath) {
t.Fatalf("path = %q, want component %q", path, wantPath)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read batch notification: %v", err)
}
var artifact BatchDistributorNotificationArtifact
if err := json.Unmarshal(data, &artifact); err != nil {
t.Fatalf("decode batch notification: %v", err)
}
if artifact.SchemaVersion != BatchDistributorNotificationSchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, BatchDistributorNotificationSchemaVersion)
}
if artifact.Batch != "morning" || artifact.BatchRunID != ref.BatchRunID {
t.Fatalf("artifact batch identity = %q/%q, want ref values", artifact.Batch, artifact.BatchRunID)
}
if artifact.Endpoint != "https://distributor.example.test" || artifact.PipelineID != "weatherreporter" || artifact.BundleID != "weatherreporter.home.morning" || artifact.IdempotencyKey == "" {
t.Fatalf("artifact identity = %#v, want distributor identity", artifact)
}
if len(artifact.Reports) != 2 || artifact.Reports[0].ReportID != report.Today || strings.Join(artifact.Reports[1].BundlePaths, ",") != "2026-06-19/daily/report.md" {
t.Fatalf("Reports = %#v, want included report records", artifact.Reports)
}
if artifact.Upload == nil || artifact.Upload.RunID != "distributor-run" {
t.Fatalf("Upload = %#v, want accepted upload result", artifact.Upload)
}
if artifact.RunStatus == nil || artifact.RunStatus.Status != "failed" || !strings.Contains(string(artifact.RunStatus.Report), "failed") || artifact.RunStatus.FinishedAt == nil {
t.Fatalf("RunStatus = %#v, want failed run status with raw report", artifact.RunStatus)
}
if artifact.StatusError != "status lookup failed" || artifact.Error != "batch upload failed" {
t.Fatalf("errors = %q/%q, want persisted error fields", artifact.StatusError, artifact.Error)
}
}
func TestBatchDistributorNotificationPathRejectsInvalidIdentity(t *testing.T) {
store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago")
valid := BatchDistributorNotificationRef{
Batch: "morning",
BatchRunID: "20260617T120000.000000000Z_morning",
StartedAt: time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC),
Location: location,
}
tests := []struct {
name string
mutate func(*BatchDistributorNotificationRef)
wantErr string
}{
{
name: "Batch",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.Batch = ""
},
wantErr: "batch kind is required",
},
{
name: "BatchSeparator",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.Batch = "../morning"
},
wantErr: "batch kind must not contain path separators",
},
{
name: "BatchRunID",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.BatchRunID = ""
},
wantErr: "batch run id is required",
},
{
name: "BatchRunIDSeparator",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.BatchRunID = "nested/run"
},
wantErr: "batch run id must not contain path separators",
},
{
name: "StartedAt",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.StartedAt = time.Time{}
},
wantErr: "batch started time is required",
},
{
name: "Location",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.Location = nil
},
wantErr: "batch location is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref := valid
tt.mutate(&ref)
_, err := store.BatchDistributorNotificationPath(ref)
if err == nil {
t.Fatal("BatchDistributorNotificationPath() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
tests := []struct { tests := []struct {
@@ -54,7 +284,7 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"), resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"),
group: "daily", group: "daily",
validDate: "2026-05-29", validDate: "2026-05-29",
runID: "20260529T100000.000000000Z_daily", runID: "20260529T100000.000000000Z_daily_2026-05-29",
}, },
{ {
name: "hourly", name: "hourly",
@@ -86,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,
@@ -394,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,
@@ -418,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 {
@@ -566,20 +872,38 @@ func newTestStore(t *testing.T) *FilesystemStore {
return store return store
} }
func mustLoadStateLocation(t *testing.T, name string) *time.Location {
t.Helper()
location, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%q) error = %v", name, err)
}
return location
}
func resolveDailyAt(t *testing.T, value string) report.Resolved { func resolveDailyAt(t *testing.T, value string) report.Resolved {
t.Helper()
return resolveDailyForDateAt(t, value, value)
}
func resolveDailyForDateAt(t *testing.T, nowValue string, dateValue string) report.Resolved {
t.Helper() t.Helper()
location, err := timeutil.LoadLocation("America/Chicago") location, err := timeutil.LoadLocation("America/Chicago")
if err != nil { if err != nil {
t.Fatalf("LoadLocation() error = %v", err) t.Fatalf("LoadLocation() error = %v", err)
} }
now, err := time.Parse(time.RFC3339, value) now, err := time.Parse(time.RFC3339, nowValue)
if err != nil { if err != nil {
t.Fatalf("parse time: %v", err) t.Fatalf("parse now time: %v", err)
}
date, err := time.Parse(time.RFC3339, dateValue)
if err != nil {
t.Fatalf("parse date time: %v", err)
} }
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
Date: now, Date: date,
}) })
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)

View File

@@ -17,6 +17,7 @@ type Store interface {
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error) SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
SaveBatchDistributorNotification(context.Context, BatchDistributorNotificationRef, BatchDistributorNotificationArtifact) (string, error)
SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error) SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error)
SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error) SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error)
SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error) SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error)
@@ -45,6 +46,14 @@ type PreflightArtifact struct {
} }
const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1" const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1"
const BatchDistributorNotificationSchemaVersion = "weatherreporter.batch_distributor_notification.v1"
type BatchDistributorNotificationRef struct {
Batch string
BatchRunID string
StartedAt time.Time
Location *time.Location
}
type DistributorNotificationArtifact struct { type DistributorNotificationArtifact struct {
SchemaVersion string `json:"schemaVersion"` SchemaVersion string `json:"schemaVersion"`
@@ -80,3 +89,28 @@ type DistributorRunStatus struct {
Report json.RawMessage `json:"report,omitempty"` Report json.RawMessage `json:"report,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type BatchDistributorNotificationArtifact struct {
SchemaVersion string `json:"schemaVersion"`
Batch string `json:"batch"`
BatchRunID string `json:"batchRunId"`
AttemptedAt time.Time `json:"attemptedAt"`
Endpoint string `json:"endpoint"`
PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
BundleCreated time.Time `json:"bundleCreated,omitempty"`
Reports []BatchDistributorNotificationReportArtifact `json:"includedReports,omitempty"`
Status string `json:"status"`
Upload *DistributorUploadResult `json:"upload,omitempty"`
RunStatus *DistributorRunStatus `json:"runStatus,omitempty"`
StatusError string `json:"statusError,omitempty"`
Error string `json:"error,omitempty"`
}
type BatchDistributorNotificationReportArtifact struct {
ReportID report.ID `json:"reportId"`
RunID string `json:"runId"`
SourcePath string `json:"sourcePath"`
BundlePaths []string `json:"bundlePaths"`
}