Compare commits
31 Commits
133f83f4ce
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 | |||
| f4f009b904 | |||
| 3c1b753952 | |||
| bdbab48d10 | |||
| 16cc4b3f63 | |||
| 0ef861ed8f | |||
| 6ae7eb44cf | |||
| 8f6aa8aa8b | |||
| b8e889ad13 | |||
| 15ee4af1a1 | |||
| 4c606eb39f | |||
| 8d2ac163ae | |||
| 8709b5f4d8 | |||
| fd48ebecb8 | |||
| 021e5dd8b1 | |||
| 7adf5e1b08 | |||
| 455cc67d4c | |||
| dd3133ee2a | |||
| b3637cddd6 | |||
| 662db5e511 | |||
| 2ef91cf1b1 | |||
| 1d2f176977 | |||
| 2b3bcdd4f1 | |||
| a82f03feb8 | |||
| f1d4e38414 | |||
| 32060bd370 |
129
docs/cli.md
129
docs/cli.md
@@ -20,15 +20,15 @@ Markdown report after final metadata is saved.
|
||||
|
||||
```text
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter 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] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
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] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
@@ -37,10 +37,13 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
```
|
||||
|
||||
Implemented `generate` commands write a JSON module 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`,
|
||||
Implemented `generate` commands emit a compact JSON summary to stdout on
|
||||
success. The summary includes command identity, report identity, RunID, status,
|
||||
valid period, and managed artifact paths. They also write a JSON module
|
||||
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
|
||||
generated-text artifacts, validate structured text from Scriptorium, and render
|
||||
the managed Markdown report from embedded templates. `generate daily` requires
|
||||
@@ -60,17 +63,102 @@ stops the batch before any report is generated. Batch runs continue independent
|
||||
reports after a later report failure, print a JSON summary to stdout, write
|
||||
compact status lines to stderr, and return nonzero when any report failed.
|
||||
`--out-dir` writes extra Markdown copies for the operator; distributor
|
||||
notification uses each managed report path, not the extra copies. Today and
|
||||
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 notification is enabled, batch summaries and status
|
||||
lines include notification status, accepted distributor run ID, or notification
|
||||
error fields for each attempted report.
|
||||
`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, 3-Day Outlook, and Weekend Outlook are explicit only; they are
|
||||
not included in `run morning` or `run evening`.
|
||||
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not collect weather data or invoke `scriptorium`.
|
||||
`inspect` commands read existing workspace artifacts and emit the requested
|
||||
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
|
||||
|
||||
@@ -80,6 +168,7 @@ They do not collect weather data or invoke `scriptorium`.
|
||||
- `--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-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.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
@@ -103,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 run morning --out-dir ./reports
|
||||
weatherreporter run evening --out-dir ./reports
|
||||
weatherreporter generate today --quiet
|
||||
weatherreporter run morning --quiet
|
||||
```
|
||||
|
||||
## Inspection
|
||||
|
||||
@@ -78,10 +78,13 @@ missing directories, and unreadable files fail config loading.
|
||||
|
||||
### `notify`
|
||||
|
||||
`notify.distributor` controls distributor notification after successful report
|
||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||
weatherreporter uploads one distributor bundle per generated report after
|
||||
report rendering succeeds and final metadata is saved.
|
||||
`notify.distributor` controls distributor uploads after successful report
|
||||
rendering. It is disabled by default and does not add CLI flags. When enabled,
|
||||
`generate <report>` uploads one distributor bundle for the generated report
|
||||
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:
|
||||
`false`.
|
||||
@@ -92,33 +95,63 @@ report rendering succeeds and final metadata is saved.
|
||||
- `timeout`: distributor operation timeout. Must be greater than zero when
|
||||
enabled. Default: `30s`.
|
||||
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
||||
- `pipeline_id_template`: template for the distributor pipeline ID. Required
|
||||
when enabled. Default: empty.
|
||||
- `bundle_id_template`: template for distributor bundle IDs. Default:
|
||||
`weatherreporter.{location_id}.{report_id}`.
|
||||
- `idempotency_key_template`: template for distributor idempotency keys.
|
||||
Default: `{bundle_id}.{run_id}`.
|
||||
- `report_path_templates`: ordered list of templates for Markdown report paths
|
||||
inside the distributor bundle. Each rendered path maps to the same managed
|
||||
Markdown report source. Default:
|
||||
```yaml
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
```
|
||||
- `pipeline_id_template`: template for single-report distributor pipeline IDs.
|
||||
Required when enabled. Default: empty.
|
||||
- `bundle_id_template`: template for single-report distributor bundle IDs.
|
||||
Default: `weatherreporter.{location_id}.{report_id}`.
|
||||
- `idempotency_key_template`: template for single-report distributor
|
||||
idempotency keys. Default: `{bundle_id}.{run_id}`.
|
||||
- `batch.enabled`: whether batch distributor notification config is active
|
||||
when distributor notification is enabled. Default: `true`.
|
||||
- `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
|
||||
Required when distributor notification and batch notification are enabled.
|
||||
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`,
|
||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and
|
||||
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
||||
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.
|
||||
`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`
|
||||
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.
|
||||
|
||||
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
|
||||
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`
|
||||
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`.
|
||||
|
||||
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`
|
||||
|
||||
@@ -194,12 +229,19 @@ Each report entry supports:
|
||||
|
||||
- `deterministic_modules`: ordered module list. Entries may be string module
|
||||
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:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
daily:
|
||||
distributor:
|
||||
path_templates:
|
||||
- "daily/{valid_start_date}/{run_id}.md"
|
||||
- "daily/{valid_start_date}/index.md"
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
@@ -209,7 +251,7 @@ reports:
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- daily_planning
|
||||
- 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.
|
||||
`area_forecast_discussion.options.sections` may contain `product`,
|
||||
`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
|
||||
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ Invalid paths include:
|
||||
- paths containing backslashes;
|
||||
- `.` or `..` 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.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Outputs:
|
||||
result details, generated-text artifact paths when applicable, and
|
||||
notification result when attempted
|
||||
- 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 explicit bundle collection workflows
|
||||
- inspection JSON values for reports, metadata, module snapshots, data
|
||||
packages, prior snapshots, and source provenance
|
||||
@@ -74,7 +74,8 @@ metadata stay in `internal/state`.
|
||||
- `workspace.*` for filesystem state
|
||||
- `dayparts` for daily and outlook summarization
|
||||
- `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
|
||||
defaults.
|
||||
@@ -83,7 +84,9 @@ defaults.
|
||||
|
||||
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`.
|
||||
plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
|
||||
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
|
||||
callers.
|
||||
|
||||
`GenerateReport` then uses this setup:
|
||||
|
||||
@@ -151,10 +154,27 @@ A collection failure stops the batch before planning or report generation.
|
||||
After planning succeeds, batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
When notification is enabled, each successfully generated report is notified
|
||||
independently. Notification failure marks that report failed, records
|
||||
notification fields in the batch result, and does not stop later reports.
|
||||
`--out-dir` copies are never used as notification source files.
|
||||
|
||||
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
|
||||
|
||||
@@ -172,8 +192,12 @@ inspection view.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
- Generated-text report errors preserve available intermediate artifacts and do
|
||||
not create extra output copies.
|
||||
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||
context and are recorded separately in batch results.
|
||||
- Single-report notification errors are wrapped with report ID, RunID, and
|
||||
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.
|
||||
- Batch failures are recorded per report and surfaced through an aggregate
|
||||
batch error.
|
||||
@@ -201,5 +225,9 @@ Inspect:
|
||||
a raw data package.
|
||||
- Recent Changes are computed from structured module snapshots.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Distributor notification maps the managed Markdown report path to configured
|
||||
bundle paths; extra output copies are not upload sources.
|
||||
- Single-report distributor notification maps the managed Markdown report path
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||
|
||||
67
docs/internal/cli.md
Normal file
67
docs/internal/cli.md
Normal 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`.
|
||||
@@ -6,10 +6,10 @@ This document describes the distributor upload adapter in
|
||||
## Purpose
|
||||
|
||||
The adapter submits generated weatherreporter Markdown reports to a configured
|
||||
distributor HTTP upload endpoint. It isolates distributor package types,
|
||||
token-env lookup, upload client construction, source-bundle file mapping,
|
||||
timeout handling, status polling, and upload error wrapping from app
|
||||
orchestration.
|
||||
distributor HTTP upload endpoint. It supports one or more file mappings per
|
||||
upload request. It isolates distributor package types, token-env lookup, upload
|
||||
client construction, source-bundle file mapping, timeout handling, status
|
||||
polling, and upload error wrapping from app orchestration.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -21,7 +21,7 @@ Inputs:
|
||||
- pipeline ID
|
||||
- bundle ID
|
||||
- idempotency key
|
||||
- source Markdown report path and bundle-relative path mappings
|
||||
- source Markdown report paths and bundle-relative path mappings
|
||||
- bundle created timestamp
|
||||
- context for cancellation
|
||||
|
||||
@@ -40,8 +40,9 @@ Outputs:
|
||||
|
||||
The app layer passes weatherreporter-owned request values to the adapter. The
|
||||
adapter does not choose report types, render templates, select output copies,
|
||||
configure destinations, wait for downstream publication, transform Markdown, or
|
||||
persist notification state.
|
||||
decide whether an upload represents one report or a batch, configure
|
||||
destinations, wait for downstream publication, transform Markdown, or persist
|
||||
notification state.
|
||||
|
||||
Full upstream distributor package and HTTP contract details stay under
|
||||
`docs/integrations/distributor/`.
|
||||
@@ -54,13 +55,22 @@ The adapter is built from `notify.distributor` config:
|
||||
- `token_env`
|
||||
- `timeout`
|
||||
|
||||
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths
|
||||
from:
|
||||
The app layer renders single-report pipeline ID, bundle ID, idempotency key,
|
||||
and bundle paths from:
|
||||
|
||||
- `pipeline_id_template`
|
||||
- `bundle_id_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`
|
||||
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:
|
||||
|
||||
- pipeline ID: the rendered distributor workflow selector
|
||||
- source path: the managed Markdown report path selected by app orchestration
|
||||
- bundle paths: rendered bundle-relative report paths
|
||||
- created: the report generation timestamp
|
||||
- source paths: managed Markdown report paths selected by app orchestration
|
||||
- bundle paths: rendered bundle-relative report paths for each source
|
||||
- created: the report or batch generation timestamp
|
||||
|
||||
The adapter creates a distributor upload client with the configured endpoint,
|
||||
bearer token, and timeout-backed HTTP client. It also wraps the upload context
|
||||
@@ -115,7 +125,8 @@ distributor service.
|
||||
## Invariants
|
||||
|
||||
- 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.
|
||||
- Token values are not included in errors, CLI output, metadata, docs, or
|
||||
examples.
|
||||
|
||||
@@ -99,7 +99,8 @@ The default Daily Report module order is:
|
||||
14. `hourly_forecast`
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ Outputs:
|
||||
- render context JSON path
|
||||
- managed Markdown report path
|
||||
- metadata JSON path
|
||||
- distributor notification debug artifact paths
|
||||
- prior comparable snapshot metadata
|
||||
- loaded module snapshot, data package, generated text, generated-text run
|
||||
result, or render context
|
||||
@@ -61,20 +62,22 @@ Workspace subdirectories must be relative paths that stay under
|
||||
## Managed Layout
|
||||
|
||||
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
|
||||
<workspace.root>/
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
||||
reports/<artifact_group>/<run_id>.md
|
||||
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||
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
|
||||
@@ -85,6 +88,10 @@ Scriptorium run result, validated generated text, and render context artifacts.
|
||||
Markdown-report metadata omits those generated-text fields. Report listing
|
||||
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 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
|
||||
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,
|
||||
generated text, generated-text run result, and render context files. Missing
|
||||
metadata directories return no inspection records or no prior snapshot rather
|
||||
@@ -145,4 +173,6 @@ Inspect:
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Generated-text artifacts live under the snapshots tree beside module
|
||||
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.
|
||||
|
||||
@@ -37,7 +37,9 @@ effective report timezone and is not included in `run morning` or
|
||||
When distributor notification is enabled, weatherreporter uploads the managed
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -57,12 +59,15 @@ batch before any report is generated.
|
||||
|
||||
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. When notification
|
||||
is configured, the summary and status lines include notification status,
|
||||
accepted distributor run ID, or notification error fields for each attempted
|
||||
report. `--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.
|
||||
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
|
||||
|
||||
@@ -70,150 +75,47 @@ The default workspace root is `workspace`.
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
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
|
||||
today/
|
||||
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
|
||||
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
|
||||
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||
|
||||
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||
```
|
||||
|
||||
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
||||
period do not overwrite each other.
|
||||
Managed artifact filenames use the artifact kind and RunID, so repeated runs
|
||||
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
|
||||
|
||||
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 and notification
|
||||
idempotency keys:
|
||||
reports in one batch have distinct managed artifacts:
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_2026-05-31
|
||||
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:
|
||||
|
||||
- RunID, report ID, variant, and prompt ID
|
||||
@@ -227,19 +129,32 @@ Each generated report writes metadata that links:
|
||||
generated-text-template reports
|
||||
- distributor notification debug artifact path, when notification is attempted
|
||||
|
||||
Batch summaries include report status, error text when applicable, notification
|
||||
outcome when attempted, valid period, and known artifact paths for each
|
||||
attempted report. Notification fields are `notificationStatus`,
|
||||
`notificationRunId`, and `notificationError`.
|
||||
Batch summaries include report status, error text when applicable, valid
|
||||
period, and known artifact paths for each attempted report. Single-report
|
||||
notification fields on report items are empty for batch commands. When a batch
|
||||
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 is configured with `notify.distributor` and is
|
||||
disabled by default. When enabled, weatherreporter uploads the managed Markdown
|
||||
report path recorded in the report result and metadata. That single source file
|
||||
can be mapped to one or more configured bundle paths. By default, it is mapped
|
||||
to one dated report path. Extra copies written by `--out` or `--out-dir` are
|
||||
operator conveniences only.
|
||||
disabled by default. For `generate <report>`, weatherreporter uploads the
|
||||
managed Markdown report path recorded in the report result and metadata. That
|
||||
single source file is mapped to report-specific bundle paths. Extra copies
|
||||
written by `--out` or `--out-dir` are 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`
|
||||
workflow. The default bundle ID is a stable logical source identity derived from
|
||||
@@ -249,31 +164,40 @@ producer name, location ID, and report ID:
|
||||
weatherreporter.{location_id}.{report_id}
|
||||
```
|
||||
|
||||
The default idempotency key appends RunID to the rendered bundle ID so each
|
||||
report generation has a distinct retry identity. The default bundle path uses
|
||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||
For Daily, the default report ID and artifact group values are both `daily`,
|
||||
and the 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`.
|
||||
The default single-report idempotency key appends RunID to the rendered bundle
|
||||
ID so each report generation has a distinct retry identity. The default bundle
|
||||
path uses the valid-period start date, artifact group, and RunID. Batch bundle
|
||||
IDs default to `weatherreporter.{location_id}.{batch}`, and batch idempotency
|
||||
keys default to `{bundle_id}.{batch_run_id}`. Distributor owns destination
|
||||
merge, retention, and derived snapshot behavior such as `latest`. For Daily,
|
||||
the default report ID and artifact group values are both `daily`, and the
|
||||
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.
|
||||
Collection, module snapshot, data-package, render preflight, Scriptorium run,
|
||||
generated-text validation, template rendering, and metadata-save failures do
|
||||
not trigger notification. A notification failure fails that report.
|
||||
In a batch, other reports continue, the failed report includes notification
|
||||
fields in the JSON summary, and the batch returns nonzero.
|
||||
Single-report notification happens after final metadata save for generated
|
||||
reports. Batch notification happens after all planned reports have finished and
|
||||
only when all report generations succeeded. Collection, module snapshot,
|
||||
data-package, render preflight, Scriptorium run, generated-text validation,
|
||||
template rendering, and metadata-save failures do not trigger notification. A
|
||||
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
|
||||
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
|
||||
source path, bundle-relative paths, bundle created 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.
|
||||
Each notification attempt writes a debug artifact under `notifications/`.
|
||||
Single-report artifacts live under
|
||||
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
|
||||
artifacts live under
|
||||
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
|
||||
where the date directory is the batch start date in the effective report
|
||||
timezone. The artifact records the rendered pipeline ID, bundle ID,
|
||||
idempotency key, managed source paths, bundle-relative paths, bundle created
|
||||
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,
|
||||
constructing a source bundle, and submitting it to the configured distributor
|
||||
@@ -333,10 +257,13 @@ A failed generation run may still leave useful artifacts:
|
||||
available intermediate artifacts, such as the structured run result, raw
|
||||
generated-text JSON, validated generated text, and render context. Metadata
|
||||
links those paths when it can be safely written.
|
||||
- If distributor notification fails, report artifacts and final metadata remain
|
||||
available, but the report or batch command returns nonzero.
|
||||
- If single-report distributor notification fails, report artifacts and final
|
||||
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
|
||||
artifact paths for each failed report.
|
||||
artifact paths for each failed report or the top-level notification path.
|
||||
|
||||
For a bad report, start with:
|
||||
|
||||
|
||||
@@ -121,8 +121,12 @@ The CLI is owned by `internal/cli`.
|
||||
When adding or changing a command or flag:
|
||||
|
||||
- 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;
|
||||
- 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`;
|
||||
- update `docs/cli.md`;
|
||||
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
# Batch Distributor Upload Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the target behavior for distributor notification from
|
||||
`weatherreporter run morning` and `weatherreporter run evening`.
|
||||
|
||||
Batch commands should publish the generated weather reports as one coherent
|
||||
distributor source bundle. The batch upload should be atomic from
|
||||
weatherreporter's point of view: either every planned report is successfully
|
||||
generated and the whole bundle is uploaded, or distributor is not notified.
|
||||
|
||||
Single-report `weatherreporter generate ...` commands should keep the existing
|
||||
per-report distributor notification behavior.
|
||||
|
||||
## Current Problem
|
||||
|
||||
Per-report uploads in one batch can create multiple distributor source bundles
|
||||
with the same logical destination scope. Depending on configured bundle IDs,
|
||||
created timestamps, distributor merge mode, and destination state, distributor
|
||||
may reject later uploads as conflicting with an earlier bundle from the same
|
||||
batch.
|
||||
|
||||
The motivating failure was:
|
||||
|
||||
```text
|
||||
destination source has same id and created time but different digest
|
||||
```
|
||||
|
||||
Unique per-report RunIDs reduce one collision class, but they do not provide
|
||||
batch-level publication semantics. A batch-level upload is the cleaner
|
||||
long-term boundary when the downstream pipeline is intended to publish a
|
||||
coherent set of weatherreporter outputs.
|
||||
|
||||
## Target Behavior
|
||||
|
||||
- `generate <report>` uploads one distributor bundle for that single generated
|
||||
report when distributor notification is enabled.
|
||||
- `run morning` and `run evening` generate all planned reports first.
|
||||
- If collection, planning, report generation, Scriptorium, template rendering,
|
||||
metadata persistence, or output-copy persistence fails for any report, the
|
||||
batch distributor upload is skipped for the whole batch.
|
||||
- If every planned report succeeds, weatherreporter uploads one distributor
|
||||
bundle containing every managed Markdown report from the batch.
|
||||
- The batch bundle maps each managed Markdown source path to one or more
|
||||
bundle-relative paths rendered from the report's definition, valid period,
|
||||
RunID, and configured distributor path templates.
|
||||
- Batch upload failure marks the batch as failed and makes the command return a
|
||||
nonzero aggregate error, but it does not retroactively mark individual report
|
||||
generation as failed.
|
||||
- Distributor status polling and debug artifact capture remain required for the
|
||||
batch upload, just as they are for per-report uploads today.
|
||||
- Optional `--out-dir` copies remain operator conveniences and are never used as
|
||||
distributor upload sources.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Batch notification is all-or-nothing. If one report fails, skip the
|
||||
distributor upload for the entire batch.
|
||||
- The initial batch upload uses one distributor pipeline for the whole batch.
|
||||
- Do not implement report-type-specific routing inside a single batch upload.
|
||||
- Keep distributor package types confined to `internal/adapters/distributor`.
|
||||
- Keep single-report notification behavior in place for `generate` commands.
|
||||
- Do not implement batch-level publication by asking distributor to scan the
|
||||
weatherreporter workspace.
|
||||
- Do not introduce a manifest/resume system, workflow engine, plugin system, or
|
||||
distributor-specific CLI flags.
|
||||
|
||||
## Batch Artifact Path
|
||||
|
||||
Batch notification should have its own debug artifact path rather than being
|
||||
attached to one arbitrary report.
|
||||
|
||||
Target layout:
|
||||
|
||||
```text
|
||||
workspace/
|
||||
notifications/
|
||||
batches/
|
||||
morning/
|
||||
2026-06-17/
|
||||
20260617T120000.123456789Z_morning.distributor.json
|
||||
evening/
|
||||
2026-06-17/
|
||||
20260617T230000.123456789Z_evening.distributor.json
|
||||
```
|
||||
|
||||
The batch notification artifact should include:
|
||||
|
||||
- schema version;
|
||||
- batch kind;
|
||||
- batch run ID;
|
||||
- attempted time;
|
||||
- endpoint;
|
||||
- pipeline ID;
|
||||
- bundle ID;
|
||||
- idempotency key;
|
||||
- bundle created timestamp;
|
||||
- included report records, each with report ID, RunID, managed source path, and
|
||||
rendered bundle paths;
|
||||
- accepted distributor run ID and upload status;
|
||||
- latest distributor run status;
|
||||
- raw distributor run report JSON when available;
|
||||
- status lookup error when available;
|
||||
- redacted notification error when upload or downstream run processing fails.
|
||||
|
||||
This makes batch notification a first-class batch artifact and keeps future
|
||||
debugging or inspection behavior straightforward.
|
||||
|
||||
## Batch JSON Shape
|
||||
|
||||
Batch notification should be represented as a top-level object on `BatchResult`.
|
||||
|
||||
Target shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"batch": "evening",
|
||||
"startedAt": "2026-06-17T18:50:37.642224552-05:00",
|
||||
"finishedAt": "2026-06-17T18:51:04.000000000-05:00",
|
||||
"total": 3,
|
||||
"succeeded": 3,
|
||||
"failed": 0,
|
||||
"notification": {
|
||||
"status": "succeeded",
|
||||
"runId": "weatherreporter.20260617T235104Z.77d8f75f",
|
||||
"pipelineId": "weatherreporter",
|
||||
"bundleId": "weatherreporter.home.evening",
|
||||
"idempotencyKey": "weatherreporter.home.evening.20260617T235037.642224552Z_evening",
|
||||
"path": "/var/lib/weatherreporter/notifications/batches/evening/2026-06-17/20260617T235037.642224552Z_evening.distributor.json",
|
||||
"includedReports": [
|
||||
{
|
||||
"reportId": "tomorrow",
|
||||
"runId": "20260617T235037.642224552Z_tomorrow",
|
||||
"sourcePath": "/var/lib/weatherreporter/reports/tomorrow/20260617T235037.642224552Z_tomorrow.md",
|
||||
"bundlePaths": ["tomorrow/index.md"]
|
||||
},
|
||||
{
|
||||
"reportId": "daily",
|
||||
"runId": "20260617T235037.642224552Z_daily_2026-06-23",
|
||||
"sourcePath": "/var/lib/weatherreporter/reports/daily/20260617T235037.642224552Z_daily_2026-06-23.md",
|
||||
"bundlePaths": ["archive/2026-06-23/daily/20260617T235037.642224552Z_daily_2026-06-23.md"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"reports": []
|
||||
}
|
||||
```
|
||||
|
||||
When distributor is disabled, omit the notification object. When notification is
|
||||
skipped because a report failed, include:
|
||||
|
||||
```json
|
||||
"notification": {
|
||||
"status": "skipped",
|
||||
"reason": "one or more reports failed"
|
||||
}
|
||||
```
|
||||
|
||||
Do not duplicate the same batch notification status into every report item.
|
||||
Per-report notification fields should remain meaningful for single-report
|
||||
notification paths.
|
||||
|
||||
## Configuration Model
|
||||
|
||||
Use the existing `notify.distributor` block for both single-report and batch
|
||||
notification, but add batch-specific identity templates so operators can keep
|
||||
single-report and batch source identity separate.
|
||||
|
||||
Target config shape:
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
distributor:
|
||||
enabled: false
|
||||
endpoint: https://distributor.example.com
|
||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||
timeout: 30s
|
||||
failure_policy: error
|
||||
pipeline_id_template: "weatherreporter.{report_id}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
report_path_templates:
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
batch:
|
||||
enabled: true
|
||||
pipeline_id_template: "weatherreporter"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{batch}"
|
||||
idempotency_key_template: "{bundle_id}.{batch_run_id}"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `notify.distributor.enabled=false` disables both single-report and batch
|
||||
notification.
|
||||
- `notify.distributor.batch.enabled=true` makes run commands use the new
|
||||
batch-level upload behavior.
|
||||
- `notify.distributor.batch.enabled=false` disables distributor notification
|
||||
for run commands. It does not fall back to legacy per-report batch uploads.
|
||||
- Existing report-level `report_path_templates` are reused to map each report
|
||||
source into the batch bundle.
|
||||
- Each report path template is rendered once per included report using that
|
||||
report's normal distributor template values.
|
||||
- Batch templates support at least `location_id`, `batch`, `batch_run_id`, and
|
||||
`batch_started_date`.
|
||||
- Batch idempotency templates may also use `bundle_id`.
|
||||
- Batch-level `pipeline_id_template` does not use `report_id` because one
|
||||
upload contains multiple reports.
|
||||
- Batch bundle IDs are stable for a logical batch stream. The idempotency key,
|
||||
not the bundle ID, identifies a specific retryable batch attempt.
|
||||
|
||||
## Batch Run Identity
|
||||
|
||||
Batch notification needs an app-level batch run ID for notification identity and
|
||||
artifact naming.
|
||||
|
||||
Format:
|
||||
|
||||
```text
|
||||
<timestamp>_<batch>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
20260617T235037.642224552Z_morning
|
||||
20260617T235037.642224552Z_evening
|
||||
```
|
||||
|
||||
The timestamp is based on batch `StartedAt` in UTC with the same nanosecond
|
||||
layout used by report RunIDs. This ID is separate from report RunIDs and should
|
||||
not replace them.
|
||||
|
||||
## Bundle Construction
|
||||
|
||||
Batch uploads use one distributor adapter upload request with multiple file
|
||||
mappings.
|
||||
|
||||
For each successful report:
|
||||
|
||||
1. Use `BatchReportResult.ReportPath` as the source path.
|
||||
2. Render `notify.distributor.report_path_templates` using that report's
|
||||
existing distributor template values.
|
||||
3. Add one upload file mapping per rendered bundle path.
|
||||
|
||||
Validation should fail before upload if:
|
||||
|
||||
- no reports are included;
|
||||
- any included report lacks a managed report path;
|
||||
- any source path is empty;
|
||||
- any bundle path is empty or invalid;
|
||||
- two rendered bundle paths in the same batch collide.
|
||||
|
||||
Errors should include report ID, RunID, source path, and bundle path context
|
||||
where useful. Token values must never appear in errors, JSON output, metadata,
|
||||
or artifacts.
|
||||
|
||||
## App Workflow
|
||||
|
||||
Batch commands should still collect once, plan once, and generate reports
|
||||
through the same report generation path used by single-report commands. The
|
||||
batch workflow differs only at the notification boundary:
|
||||
|
||||
- per-report distributor notification is suppressed for `run morning` and
|
||||
`run evening`;
|
||||
- report generation continues after individual report failures, preserving
|
||||
current batch behavior;
|
||||
- batch notification is skipped if any report failed;
|
||||
- one batch notification is attempted only when all planned reports succeeded;
|
||||
- batch notification failure produces an aggregate batch error and a batch
|
||||
notification debug artifact.
|
||||
|
||||
Suppressing per-report notification should be explicit app-layer behavior, not a
|
||||
hidden side effect inside `GenerateReport`.
|
||||
|
||||
## Adapter Boundary
|
||||
|
||||
The existing distributor adapter already supports multiple file mappings through
|
||||
its upload request. Batch uploads should reuse that adapter API rather than
|
||||
introducing a second distributor client.
|
||||
|
||||
The app layer owns:
|
||||
|
||||
- batch report selection;
|
||||
- batch upload identity rendering;
|
||||
- source path selection;
|
||||
- bundle path rendering;
|
||||
- debug artifact persistence;
|
||||
- batch JSON shape.
|
||||
|
||||
The adapter owns:
|
||||
|
||||
- distributor package type conversion;
|
||||
- token lookup;
|
||||
- upload client construction;
|
||||
- upload request execution;
|
||||
- status polling;
|
||||
- upload/status error wrapping and redaction.
|
||||
|
||||
## State And Inspection
|
||||
|
||||
State should support saving batch notification artifacts without introducing a
|
||||
full manifest/resume system.
|
||||
|
||||
Expected additions:
|
||||
|
||||
- batch-level artifact path calculation for distributor notification;
|
||||
- save support for the batch distributor notification artifact;
|
||||
- no required changes to existing report metadata artifact shape.
|
||||
|
||||
If linking is useful later, prefer adding the batch notification path to the
|
||||
top-level batch JSON result first, then consider metadata links.
|
||||
|
||||
## CLI Output
|
||||
|
||||
Command syntax remains unchanged:
|
||||
|
||||
```text
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
Stderr should keep one compact line per report generation result, then add one
|
||||
compact batch notification line when notification is attempted, skipped, or
|
||||
failed. The same notification error should not be repeated on every report line.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
report=tomorrow status=succeeded output=""
|
||||
report=daily status=succeeded output=""
|
||||
batchNotification status=succeeded runId="weatherreporter.20260617T235104Z.77d8f75f"
|
||||
batch=evening total=2 succeeded=2 failed=0
|
||||
```
|
||||
|
||||
If report generation fails:
|
||||
|
||||
```text
|
||||
report=daily status=failed error="..."
|
||||
batchNotification status=skipped reason="one or more reports failed"
|
||||
batch=evening total=2 succeeded=1 failed=1
|
||||
```
|
||||
|
||||
## Documentation Boundary
|
||||
|
||||
Until implemented, this roadmap is the only place this behavior should be
|
||||
described. After implementation, update the relevant implemented docs and keep
|
||||
future extensions under `docs/roadmap/`.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- Batch-level durable resume or retry queues.
|
||||
- Batch-level distributor status inspection command.
|
||||
- Multiple distributor pipelines within one batch.
|
||||
- Report-type-specific distributor routing inside batch uploads.
|
||||
- Uploading data packages, metadata, render contexts, or notification artifacts
|
||||
in the batch bundle.
|
||||
- Distributor-specific CLI flags.
|
||||
- Batch manifest or progress system.
|
||||
- Changing distributor destination merge semantics from weatherreporter.
|
||||
@@ -1,463 +0,0 @@
|
||||
# Batch Distributor Upload Implementation Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
Implement the batch distributor upload behavior defined in
|
||||
`docs/roadmap/batch-distributor.md`.
|
||||
|
||||
This plan is written for an LLM coding agent. Implement the stages in order.
|
||||
Preserve existing public CLI syntax, report generation behavior, managed report
|
||||
paths, and single-report distributor notification behavior unless a stage
|
||||
explicitly changes batch notification behavior.
|
||||
|
||||
## Source Roadmap
|
||||
|
||||
Use `docs/roadmap/batch-distributor.md` as the authoritative source for:
|
||||
|
||||
- user intent;
|
||||
- locked policy decisions;
|
||||
- target batch JSON shape;
|
||||
- batch notification artifact layout;
|
||||
- configuration shape;
|
||||
- app, state, and adapter boundaries;
|
||||
- deferred work.
|
||||
|
||||
Do not move aspirational batch distributor behavior into non-roadmap docs until
|
||||
the corresponding code is implemented.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- `generate <report>` keeps per-report distributor notification.
|
||||
- `run morning` and `run evening` use one batch-level distributor upload when
|
||||
distributor notification is enabled and batch notification is enabled.
|
||||
- `notify.distributor.batch.enabled=false` disables distributor notification
|
||||
for run commands; it does not fall back to per-report batch uploads.
|
||||
- Batch upload is all-or-nothing: if any report fails, skip the distributor
|
||||
upload for the whole batch.
|
||||
- A batch notification failure makes the batch command return a nonzero
|
||||
aggregate error, but it does not mark individual report generation as failed.
|
||||
- The initial implementation uses one distributor pipeline for the whole batch.
|
||||
- The batch upload source files are managed Markdown report paths only.
|
||||
`--out-dir` copies are never uploaded.
|
||||
- Reuse the existing distributor adapter; do not import distributor package
|
||||
types outside `internal/adapters/distributor`.
|
||||
- Do not introduce batch manifest/resume behavior, distributor-specific CLI
|
||||
flags, a workflow engine, plugin system, or broad CLI redesign.
|
||||
|
||||
## Stage 1: Batch Notify Config And Template Rendering
|
||||
|
||||
Goal: add batch-specific distributor notification configuration and rendering
|
||||
without changing runtime behavior yet.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Add a nested batch config under `notify.distributor`, for example:
|
||||
```go
|
||||
type DistributorBatchNotifyConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||
}
|
||||
```
|
||||
- Add it to `config.DistributorNotifyConfig` as `Batch`.
|
||||
- Defaults:
|
||||
- `batch.enabled: true`
|
||||
- `batch.pipeline_id_template: "weatherreporter"`
|
||||
- `batch.bundle_id_template: "weatherreporter.{location_id}.{batch}"`
|
||||
- `batch.idempotency_key_template: "{bundle_id}.{batch_run_id}"`
|
||||
- Preserve existing report-level distributor defaults and validation.
|
||||
- When `notify.distributor.enabled=false`, batch values may remain defaulted but
|
||||
must not require endpoint/token availability.
|
||||
- When `notify.distributor.enabled=true` and `batch.enabled=true`, validate:
|
||||
- non-empty batch pipeline template;
|
||||
- non-empty batch bundle template;
|
||||
- non-empty batch idempotency template;
|
||||
- known template variables only;
|
||||
- rendered values are non-empty for a representative validation input.
|
||||
- Supported batch template variables:
|
||||
- `location_id`
|
||||
- `batch`
|
||||
- `batch_run_id`
|
||||
- `batch_started_date`
|
||||
- `bundle_id` for idempotency templates only
|
||||
- Add config rendering helpers, for example:
|
||||
- `RenderDistributorBatchBundleID`
|
||||
- `RenderDistributorBatchPipelineID`
|
||||
- `RenderDistributorBatchIdempotencyKey`
|
||||
- Keep report-level render helpers unchanged.
|
||||
- Update `examples/config.yml` only after the config fields are implemented.
|
||||
|
||||
Tests:
|
||||
|
||||
- Defaults include enabled batch config and expected templates.
|
||||
- Disabled distributor config accepts default batch settings.
|
||||
- Enabled distributor config rejects malformed batch templates.
|
||||
- Unknown batch template variables are rejected.
|
||||
- Batch idempotency templates can reference `{bundle_id}`.
|
||||
- Example config loads and includes the batch block if updated in this stage.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/config
|
||||
```
|
||||
|
||||
## Stage 2: Batch Notification State Artifacts
|
||||
|
||||
Goal: add batch-level notification artifact paths and persistence.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Add a state-owned batch notification schema version, for example:
|
||||
`weatherreporter.batch_distributor_notification.v1`.
|
||||
- Add a `BatchDistributorNotificationArtifact` type with fields matching
|
||||
`docs/roadmap/batch-distributor.md`:
|
||||
- schema version;
|
||||
- batch kind;
|
||||
- batch run ID;
|
||||
- attempted time;
|
||||
- endpoint;
|
||||
- pipeline ID;
|
||||
- bundle ID;
|
||||
- idempotency key;
|
||||
- bundle created timestamp;
|
||||
- included report records;
|
||||
- status;
|
||||
- upload result;
|
||||
- run status;
|
||||
- status error;
|
||||
- error.
|
||||
- Add included report record fields:
|
||||
- report ID;
|
||||
- RunID;
|
||||
- source path;
|
||||
- bundle paths.
|
||||
- Add state path support for:
|
||||
```text
|
||||
notifications/batches/<batch>/<local-date>/<batch_run_id>.distributor.json
|
||||
```
|
||||
- Use the batch start date in the effective Weather API/report timezone for the
|
||||
local date directory.
|
||||
- Add a save method such as `SaveBatchDistributorNotification`.
|
||||
- Use existing atomic JSON write helpers.
|
||||
- Do not change existing per-report metadata JSON shape in this stage.
|
||||
|
||||
Tests:
|
||||
|
||||
- Batch notification paths use the configured workspace root.
|
||||
- Morning and evening paths include batch kind, local date, and batch run ID.
|
||||
- Saved artifact round-trips with included reports, upload result, run status,
|
||||
raw report JSON, and error fields.
|
||||
- Empty batch run ID or batch kind fails with actionable errors.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/state
|
||||
```
|
||||
|
||||
## Stage 3: Batch Notification App Types And Identity
|
||||
|
||||
Goal: add app-owned batch notification request/result types and deterministic
|
||||
batch run ID helpers.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Add app-level types, for example:
|
||||
```go
|
||||
type BatchNotificationResult struct {
|
||||
Status string
|
||||
Reason string
|
||||
RunID string
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Path string
|
||||
IncludedReports []BatchNotificationReport
|
||||
Error string
|
||||
}
|
||||
```
|
||||
- Add JSON tags matching the target `BatchResult.notification` shape.
|
||||
- Add `Notification *BatchNotificationResult` to `BatchResult` with
|
||||
`json:"notification,omitempty"`.
|
||||
- Add `BatchNotificationReport` with report ID, RunID, source path, and bundle
|
||||
paths.
|
||||
- Add an unexported batch run ID helper:
|
||||
```text
|
||||
<started_at_utc_timestamp>_<batch>
|
||||
```
|
||||
using the same UTC nanosecond timestamp layout as report RunIDs.
|
||||
- Add unexported batch distributor template values and rendering glue in
|
||||
`internal/app` or call the new `internal/config` helpers directly with a
|
||||
narrow value struct.
|
||||
- Do not call distributor in this stage.
|
||||
|
||||
Tests:
|
||||
|
||||
- Batch run ID for morning/evening uses UTC timestamp plus batch name.
|
||||
- Batch result JSON omits `notification` when nil.
|
||||
- Batch result JSON includes notification fields when populated.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/app
|
||||
```
|
||||
|
||||
## Stage 4: Batch Bundle Request Construction
|
||||
|
||||
Goal: build a validated multi-file distributor notification request from a
|
||||
successful batch result.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Add an unexported app helper that accepts:
|
||||
- config;
|
||||
- batch kind;
|
||||
- batch run ID;
|
||||
- batch started time;
|
||||
- successful batch report results;
|
||||
- the matching resolved/planned reports if needed for valid-period template
|
||||
values.
|
||||
- Render batch `pipeline_id`, `bundle_id`, and `idempotency_key` from
|
||||
`notify.distributor.batch`.
|
||||
- For each included report, render existing report-level
|
||||
`report_path_templates` using that report's normal distributor template
|
||||
values.
|
||||
- Build one app notification request with multiple file mappings.
|
||||
- If the current `NotificationRequest` is too report-specific, introduce a
|
||||
separate unexported batch request type and convert it to
|
||||
`distributoradapter.UploadRequest` at the notifier boundary.
|
||||
- Validate before upload:
|
||||
- no reports included;
|
||||
- missing source path;
|
||||
- missing or invalid bundle path;
|
||||
- duplicate rendered bundle paths across the batch;
|
||||
- missing batch pipeline, bundle ID, or idempotency key.
|
||||
- Error context should include report ID, RunID, source path, and bundle path
|
||||
where useful.
|
||||
- Do not include token values in errors.
|
||||
|
||||
Tests:
|
||||
|
||||
- Evening batch with Tomorrow plus two Daily reports renders one request with
|
||||
all managed report paths.
|
||||
- Every included report uses its own normal rendered report path templates.
|
||||
- Duplicate bundle paths fail before upload.
|
||||
- Missing report path fails before upload.
|
||||
- Batch ID, bundle ID, pipeline ID, and idempotency key match configured
|
||||
templates.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/config
|
||||
```
|
||||
|
||||
## Stage 5: Suppress Per-Report Batch Notification
|
||||
|
||||
Goal: make run commands stop notifying once per report while preserving
|
||||
single-report notification.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Add an explicit app-layer mechanism to suppress per-report notification when
|
||||
`GenerateReport` is called from `RunBatchDetailed`.
|
||||
- Prefer a private field on `ReportRequest` or a private generation helper over
|
||||
changing public CLI behavior.
|
||||
- Ensure `GenerateReport` used by `generate <report>` still notifies exactly as
|
||||
it does today.
|
||||
- Suppress per-report notification for all `run morning` and `run evening`
|
||||
report generation, regardless of whether batch notification is enabled,
|
||||
disabled, skipped, or fails later.
|
||||
- Ensure report metadata and report artifacts remain saved normally when
|
||||
per-report notification is suppressed.
|
||||
- Do not remove the existing per-report notification implementation because
|
||||
single-report generation still uses it.
|
||||
|
||||
Tests:
|
||||
|
||||
- `generate tomorrow` with distributor enabled calls the notifier once.
|
||||
- `run evening` with distributor enabled does not call the notifier once per
|
||||
generated report before the batch notification stage is wired in.
|
||||
- Per-report notification artifacts are not written for batch-generated reports
|
||||
when batch notification is enabled.
|
||||
- Existing single-report notification artifact tests still pass.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/cli
|
||||
```
|
||||
|
||||
## Stage 6: Batch Notification Orchestration
|
||||
|
||||
Goal: upload one distributor bundle after a fully successful batch and persist a
|
||||
batch notification debug artifact.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- In `RunBatchDetailed`, keep current report generation behavior:
|
||||
- collect once;
|
||||
- plan reports;
|
||||
- continue generating later reports after individual failures;
|
||||
- record per-report success/failure.
|
||||
- After report generation finishes:
|
||||
- if distributor is disabled, leave `BatchResult.Notification` nil;
|
||||
- if distributor is enabled but `notify.distributor.batch.enabled=false`, set
|
||||
batch notification status to disabled or omit the notification object, and
|
||||
do not call distributor for the batch;
|
||||
- if any report failed, set `BatchResult.Notification` to status `skipped`
|
||||
with reason `one or more reports failed`, and do not call distributor;
|
||||
- if all reports succeeded, build and send one batch notification request.
|
||||
- Use the existing distributor adapter with multiple files.
|
||||
- Save the batch notification artifact for attempted, succeeded, failed, and
|
||||
status-error outcomes.
|
||||
- Persist distributor accepted run ID, upload status, terminal run status, raw
|
||||
run report JSON, status lookup error, and redacted failure error.
|
||||
- Batch notification failure should:
|
||||
- set top-level batch notification status to `failed`;
|
||||
- increment or otherwise reflect aggregate batch failure consistently with
|
||||
existing `BatchError` behavior;
|
||||
- not mark individual report generation items as failed;
|
||||
- return `BatchError` from `RunBatch` and an error from CLI execution.
|
||||
|
||||
Tests:
|
||||
|
||||
- All-success morning batch calls notifier exactly once with all report files.
|
||||
- All-success evening batch calls notifier exactly once with all report files.
|
||||
- Report failure skips notification and sets top-level notification status
|
||||
`skipped`.
|
||||
- Batch notification upload failure returns aggregate batch error and keeps
|
||||
report item statuses succeeded.
|
||||
- Batch notification run-status failure records raw status report JSON in the
|
||||
debug artifact.
|
||||
- Disabled distributor produces no notification object and no notifier call.
|
||||
- Batch notification disabled produces no batch notifier call and no per-report
|
||||
notifier calls from run commands.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/state ./internal/adapters/distributor
|
||||
```
|
||||
|
||||
## Stage 7: CLI JSON And Stderr Output
|
||||
|
||||
Goal: expose one batch notification result clearly without repeating it on every
|
||||
report item.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Extend CLI JSON output naturally through `BatchResult.Notification`.
|
||||
- Update stderr batch logging:
|
||||
- keep existing compact per-report lines;
|
||||
- add one `batchNotification ...` line when notification is attempted,
|
||||
skipped, or failed;
|
||||
- do not repeat the same batch notification error on every report line.
|
||||
- Keep command syntax unchanged.
|
||||
- Do not add distributor-specific CLI flags.
|
||||
- Ensure secret values and bearer tokens cannot appear in JSON or stderr.
|
||||
|
||||
Tests:
|
||||
|
||||
- Batch JSON includes top-level notification fields for success.
|
||||
- Batch JSON includes top-level skipped notification when a report failed.
|
||||
- Batch JSON omits notification when distributor is disabled.
|
||||
- Stderr includes one batch notification line on success/failure/skipped.
|
||||
- Stderr does not repeat batch notification errors per report.
|
||||
- Existing help output remains unchanged.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/cli ./internal/app
|
||||
go run ./cmd/weatherreporter --help
|
||||
```
|
||||
|
||||
## Stage 8: Documentation And Examples
|
||||
|
||||
Goal: move implemented behavior out of roadmap-only status after the code is in
|
||||
place.
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Update `docs/config.md` with:
|
||||
- `notify.distributor.batch.enabled`;
|
||||
- batch pipeline, bundle ID, and idempotency templates;
|
||||
- supported batch template variables;
|
||||
- relationship between report path templates and batch bundle file mappings.
|
||||
- Update `examples/config.yml` with the batch block and no secrets.
|
||||
- Update `docs/operations.md` with:
|
||||
- batch upload ordering;
|
||||
- skip-all notification policy;
|
||||
- batch artifact path;
|
||||
- top-level batch notification JSON behavior.
|
||||
- Update `docs/troubleshooting.md` with:
|
||||
- skipped batch upload;
|
||||
- batch upload failure;
|
||||
- duplicate bundle path validation;
|
||||
- distributor source conflicts.
|
||||
- Update `docs/internal/app-orchestration.md` with batch notification workflow.
|
||||
- Update `docs/internal/distributor-adapter.md` to clarify that multi-file
|
||||
upload is supported by the adapter and batch orchestration lives in app.
|
||||
- Update `docs/internal/state.md` with batch notification artifact paths and
|
||||
JSON shape.
|
||||
- Keep future extensions only under `docs/roadmap/`.
|
||||
|
||||
Tests/checks:
|
||||
|
||||
- Config examples load.
|
||||
- Non-roadmap docs describe only implemented behavior.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/config
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Stage 9: Final Validation
|
||||
|
||||
Run the full validation set:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/config ./internal/state ./internal/cli ./internal/adapters/distributor
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Manual checks:
|
||||
|
||||
- `weatherreporter generate tomorrow` still uses per-report notification.
|
||||
- `weatherreporter run morning` sends at most one distributor upload.
|
||||
- `weatherreporter run evening` sends at most one distributor upload.
|
||||
- A failed report in a batch skips the batch upload.
|
||||
- Batch upload source paths are managed report paths, not `--out-dir` copies.
|
||||
- Batch upload bundle paths are unique.
|
||||
- No token values appear in errors, batch JSON, notification artifacts, docs, or
|
||||
examples.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- Batch-level durable resume or retry queues.
|
||||
- Batch-level distributor status inspection command.
|
||||
- Multiple distributor pipelines within one batch.
|
||||
- Report-type-specific distributor routing inside batch uploads.
|
||||
- Uploading data packages, metadata, render contexts, or notification artifacts
|
||||
in the batch bundle.
|
||||
- Distributor-specific CLI flags.
|
||||
- Batch manifest or progress system.
|
||||
- Changing distributor destination merge semantics from weatherreporter.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The roadmap decisions are sufficient for implementation:
|
||||
|
||||
- failed report means skip all batch upload;
|
||||
- initial batch upload uses one pipeline;
|
||||
- batch debug artifacts live under `notifications/batches/...`;
|
||||
- batch JSON uses one top-level `notification` object;
|
||||
- single-report generation keeps per-report notification.
|
||||
@@ -236,20 +236,107 @@ Relevant docs: [Report Templates](templates.md),
|
||||
Symptom: `run morning` or `run evening` returns nonzero.
|
||||
|
||||
Likely cause: weather collection failed before planning, or at least one
|
||||
planned report failed after planning succeeded.
|
||||
planned report failed after planning succeeded, or every report succeeded but
|
||||
the top-level batch distributor notification failed.
|
||||
|
||||
Diagnostic: if stdout contains a JSON summary, inspect each failed report item
|
||||
and stderr status line. If no summary was emitted, inspect the command error;
|
||||
configuration, Weather API collection, or batch validation failed before any
|
||||
report artifacts were created.
|
||||
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: for collection failures, fix the configuration or upstream Weather
|
||||
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.
|
||||
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).
|
||||
|
||||
## 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
|
||||
|
||||
Symptom: config loading fails with `read secrets directory`, `secret file`, or
|
||||
@@ -296,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.
|
||||
|
||||
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
|
||||
and idempotency context. Compare the configured templates with the report RunID
|
||||
and report path.
|
||||
and idempotency context. For batch commands, use the top-level notification
|
||||
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
|
||||
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run
|
||||
status, status error, and raw run report JSON when available.
|
||||
Also inspect the notification artifact linked from metadata or from the
|
||||
top-level batch notification path. It records the rendered pipeline ID, bundle
|
||||
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
|
||||
report, but do not reuse the same rendered key for different generated report
|
||||
@@ -319,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.
|
||||
|
||||
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.report_path_templates`, and token configuration. Token
|
||||
report-specific distributor paths, and token configuration. Token
|
||||
values are redacted from weatherreporter errors.
|
||||
|
||||
If the upload was accepted but destination output did not change, inspect the
|
||||
@@ -346,8 +436,8 @@ Likely cause: the configured distributor endpoint is unreachable, slow, or
|
||||
temporarily unavailable.
|
||||
|
||||
Diagnostic: check network access from the weatherreporter host to
|
||||
`notify.distributor.endpoint`. For batch runs, inspect which reports have
|
||||
`notificationStatus: "failed"`.
|
||||
`notify.distributor.endpoint`. For batch runs, inspect the top-level
|
||||
notification object and the artifact linked by `notification.path`.
|
||||
|
||||
Safe fix: restore distributor service availability and rerun the affected
|
||||
report or batch. Stable idempotency keys make retrying the same generated report
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
weather_api:
|
||||
base_url: https://weather.api.rakestrawhome.com/
|
||||
base_url: https://weather.api.example.com/
|
||||
timeout: 15s
|
||||
precision: 1
|
||||
units: us
|
||||
@@ -24,8 +24,11 @@ notify:
|
||||
pipeline_id_template: "weatherreporter.{report_id}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
report_path_templates:
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
batch:
|
||||
enabled: true
|
||||
pipeline_id_template: "weatherreporter"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{batch}"
|
||||
idempotency_key_template: "{bundle_id}.{batch_run_id}"
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
@@ -69,6 +72,10 @@ recent_change:
|
||||
|
||||
reports:
|
||||
daily:
|
||||
distributor:
|
||||
path_templates:
|
||||
- "daily/{valid_start_date}/{run_id}.md"
|
||||
- "daily/{valid_start_date}/index.md"
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
@@ -81,9 +88,6 @@ reports:
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
|
||||
@@ -164,8 +164,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
||||
|
||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||
DataPackagePath: "/tmp/data_package.hourly.yaml",
|
||||
OutputPath: "/tmp/generated_text_raw.hourly.json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StructuredRun() error = %v", err)
|
||||
@@ -176,8 +176,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.hourly_generated_text",
|
||||
"--input", "data_package=/tmp/hourly.data_package.yaml",
|
||||
"--out", "/tmp/hourly.generated_text.raw.json",
|
||||
"--input", "data_package=/tmp/data_package.hourly.yaml",
|
||||
"--out", "/tmp/generated_text_raw.hourly.json",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -217,8 +217,8 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
||||
|
||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||
DataPackagePath: "/tmp/data_package.hourly.yaml",
|
||||
OutputPath: "/tmp/generated_text_raw.hourly.json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
||||
@@ -229,7 +229,7 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
||||
if result.ExitCode != 3 {
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "structured output failed") {
|
||||
|
||||
@@ -91,6 +91,7 @@ type ReportRequest struct {
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
type ReportResult struct {
|
||||
@@ -117,13 +118,33 @@ type ReportResult struct {
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
Batch BatchKind `json:"batch"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Reports []BatchReportResult `json:"reports"`
|
||||
Batch BatchKind `json:"batch"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
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 {
|
||||
@@ -155,9 +176,32 @@ func (e BatchError) Error() string {
|
||||
if e.Result == nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||
@@ -224,29 +268,33 @@ func (e *NotificationError) Unwrap() 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
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := ResolveGenerate(req, now)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if resolved.Definition.Generated {
|
||||
_, err := GenerateReport(ctx, ReportRequest{
|
||||
return GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
Collection: *collection,
|
||||
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 {
|
||||
@@ -311,6 +359,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
Renderer: req.Renderer,
|
||||
Store: store,
|
||||
Notifier: req.Notifier,
|
||||
noNotify: true,
|
||||
})
|
||||
if err != nil {
|
||||
item.Status = "failed"
|
||||
@@ -343,6 +392,13 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
result.Reports = append(result.Reports, item)
|
||||
}
|
||||
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()
|
||||
return result, nil
|
||||
}
|
||||
@@ -599,28 +655,40 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
OutputPath: req.OutputPath,
|
||||
Notifier: req.Notifier,
|
||||
GenerationErr: runErr,
|
||||
noNotify: req.noNotify,
|
||||
})
|
||||
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{
|
||||
ModuleSnapshot: moduleSnapshot,
|
||||
ModuleSnapshotPath: moduleSnapshotPath,
|
||||
DataPackage: dataPackage,
|
||||
DataPackagePath: dataPackagePath,
|
||||
PreflightPath: preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: finalized.OutputPath,
|
||||
NotificationPath: finalized.NotificationPath,
|
||||
Metadata: finalized.Metadata,
|
||||
MetadataPath: finalized.MetadataPath,
|
||||
PriorSnapshot: priorSnapshot,
|
||||
RecentChanges: recentChanges,
|
||||
RenderResult: renderResult,
|
||||
RunResult: runResult,
|
||||
Notification: finalized.Notification,
|
||||
}, nil
|
||||
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,
|
||||
}), nil
|
||||
}
|
||||
|
||||
type generatedReportRequest struct {
|
||||
@@ -721,32 +789,100 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
||||
ManagedReportPath: reportPath,
|
||||
OutputPath: req.OutputPath,
|
||||
Notifier: req.Notifier,
|
||||
noNotify: req.noNotify,
|
||||
})
|
||||
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{
|
||||
ModuleSnapshot: req.moduleSnapshot,
|
||||
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||
DataPackage: req.dataPackage,
|
||||
DataPackagePath: req.dataPackagePath,
|
||||
PreflightPath: req.preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: finalized.OutputPath,
|
||||
NotificationPath: finalized.NotificationPath,
|
||||
Metadata: finalized.Metadata,
|
||||
MetadataPath: finalized.MetadataPath,
|
||||
ReportPath: req.reportPath,
|
||||
OutputPath: req.finalized.OutputPath,
|
||||
NotificationPath: req.finalized.NotificationPath,
|
||||
Metadata: req.finalized.Metadata,
|
||||
MetadataPath: req.finalized.MetadataPath,
|
||||
PriorSnapshot: req.priorSnapshot,
|
||||
RecentChanges: req.recentChanges,
|
||||
RenderResult: req.renderResult,
|
||||
StructuredRunResult: structuredResult,
|
||||
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
GeneratedTextResultPath: generatedTextResultPath,
|
||||
GeneratedTextPath: generatedTextPath,
|
||||
RenderContextPath: renderContextPath,
|
||||
Notification: finalized.Notification,
|
||||
}, nil
|
||||
RunResult: req.runResult,
|
||||
StructuredRunResult: req.structuredRunResult,
|
||||
GeneratedTextRawPath: req.generatedTextRawPath,
|
||||
GeneratedTextResultPath: req.generatedTextResultPath,
|
||||
GeneratedTextPath: req.generatedTextPath,
|
||||
RenderContextPath: req.renderContextPath,
|
||||
Notification: req.finalized.Notification,
|
||||
}
|
||||
}
|
||||
|
||||
type finalizeRenderedReportRequest struct {
|
||||
@@ -758,6 +894,7 @@ type finalizeRenderedReportRequest struct {
|
||||
OutputPath string
|
||||
Notifier Notifier
|
||||
GenerationErr error
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
type finalizeRenderedReportResult struct {
|
||||
@@ -800,6 +937,9 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
|
||||
if req.GenerationErr != nil {
|
||||
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)
|
||||
if notificationPath != "" {
|
||||
@@ -859,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) {
|
||||
values := config.DistributorTemplateValues{
|
||||
LocationID: cfg.Location.ID,
|
||||
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 {
|
||||
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
|
||||
@@ -882,7 +1016,7 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values)
|
||||
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values)
|
||||
if err != nil {
|
||||
return NotificationRequest{}, err
|
||||
}
|
||||
@@ -898,6 +1032,59 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
|
||||
}, 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 {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
@@ -1005,6 +1192,38 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
|
||||
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 {
|
||||
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
||||
for _, bundlePath := range bundlePaths {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
379
internal/app/batch_notification.go
Normal file
379
internal/app/batch_notification.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||
|
||||
@@ -263,24 +263,56 @@ func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
||||
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||
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, ",") != "key_messages,short_term" {
|
||||
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
||||
}
|
||||
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
id report.ID
|
||||
wantSections string
|
||||
}{
|
||||
{id: report.Daily, wantSections: "long_term"},
|
||||
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
||||
}
|
||||
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
75
internal/cli/output.go
Normal 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)
|
||||
}
|
||||
59
internal/cli/output_test.go
Normal file
59
internal/cli/output_test.go
Normal 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
162
internal/cli/result.go
Normal 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
231
internal/cli/result_test.go
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,15 +16,15 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
||||
|
||||
Usage:
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter 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] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
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] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
@@ -40,6 +39,7 @@ Options:
|
||||
--tz NAME Override weather API timezone.
|
||||
--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.
|
||||
--quiet Suppress successful generate and run output.
|
||||
`
|
||||
|
||||
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 {
|
||||
_ = stderr
|
||||
if r.Clock == nil {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
}
|
||||
@@ -62,23 +61,32 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
req, err := r.resolveGenerate(args[1:])
|
||||
req, opts, err := r.resolveGenerateAction(args[1:])
|
||||
if err != nil {
|
||||
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":
|
||||
req, err := r.resolveRun(args[1:])
|
||||
req, opts, err := r.resolveRunAction(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := app.RunBatchDetailed(ctx, req)
|
||||
if result != nil {
|
||||
writeRunLogs(stderr, result)
|
||||
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||
summary := newBatchSummary(result)
|
||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
||||
writeBatchStatus(w, result)
|
||||
}); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
if summary.Status == summaryStatusFailed {
|
||||
return app.BatchError{Result: result}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +104,7 @@ type commonOptions struct {
|
||||
Timezone string
|
||||
Output string
|
||||
OutputDir string
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
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) {
|
||||
req, _, err := r.resolveGenerateAction(args)
|
||||
return req, err
|
||||
}
|
||||
|
||||
func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) {
|
||||
if r.Clock == nil {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
}
|
||||
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 {
|
||||
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])
|
||||
|
||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{
|
||||
Path: opts.ConfigPath,
|
||||
@@ -202,11 +216,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
Timezone: opts.Timezone,
|
||||
})
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
|
||||
req := app.GenerateRequest{
|
||||
@@ -219,11 +233,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
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)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
case app.ReportToday:
|
||||
if opts.Date == "" {
|
||||
@@ -231,41 +245,46 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
} else {
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
}
|
||||
case app.ReportStorm:
|
||||
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 == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
req.StormStart = period.Start
|
||||
req.StormEnd = period.End
|
||||
}
|
||||
|
||||
return req, nil
|
||||
return req, opts.commonOptions, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
}
|
||||
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 {
|
||||
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])
|
||||
opts, err := parseRunFlags(args[1:])
|
||||
if err != nil {
|
||||
return app.BatchRequest{}, err
|
||||
return app.BatchRequest{}, commonOptions{}, err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{
|
||||
Path: opts.ConfigPath,
|
||||
@@ -273,9 +292,9 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
||||
Timezone: opts.Timezone,
|
||||
})
|
||||
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) {
|
||||
@@ -287,6 +306,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
||||
fs.SetOutput(io.Discard)
|
||||
opts := generateOptions{}
|
||||
addCommonFlags(fs, &opts.commonOptions, true)
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
if report == app.ReportDaily || report == app.ReportToday {
|
||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||
}
|
||||
@@ -309,6 +329,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
||||
opts := commonOptions{}
|
||||
addCommonFlags(fs, &opts, false)
|
||||
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 {
|
||||
return commonOptions{}, err
|
||||
}
|
||||
@@ -351,36 +372,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
||||
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) {
|
||||
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||
|
||||
@@ -32,6 +32,12 @@ func TestRunHelpLongFlag(t *testing.T) {
|
||||
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
|
||||
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"}, "-")
|
||||
if strings.Contains(output.stdout, removedGenerateCommand) {
|
||||
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)
|
||||
}
|
||||
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, "prompt_id: weather.storm_report")
|
||||
}
|
||||
@@ -99,10 +105,10 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
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, "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") {
|
||||
t.Fatalf("managed report = %q, want tomorrow report", reportPath)
|
||||
}
|
||||
@@ -119,8 +125,8 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
|
||||
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
||||
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")
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
_, err := runTestCommand(t, runner,
|
||||
output, err := runTestCommand(t, runner,
|
||||
"generate", "three-day",
|
||||
"--config", fixture.configPath,
|
||||
"--out", outPath,
|
||||
@@ -140,9 +146,23 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
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, "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) {
|
||||
@@ -159,7 +179,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
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, "derived_daypart_summaries:")
|
||||
}
|
||||
@@ -175,11 +195,11 @@ func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
|
||||
if err != nil {
|
||||
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", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||
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) {
|
||||
@@ -197,9 +217,9 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
|
||||
}
|
||||
|
||||
var summary app.BatchResult
|
||||
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
||||
summary := decodeBatchSummary(t, output.stdout)
|
||||
if summary.Command != "run" || summary.Status != "failed" {
|
||||
t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status)
|
||||
}
|
||||
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
|
||||
@@ -207,30 +227,37 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
||||
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", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||
}
|
||||
|
||||
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
||||
func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
|
||||
result := &app.BatchResult{
|
||||
Batch: app.BatchMorning,
|
||||
Total: 2,
|
||||
Succeeded: 1,
|
||||
Failed: 1,
|
||||
Succeeded: 2,
|
||||
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{
|
||||
{
|
||||
ReportID: "daily",
|
||||
Status: "succeeded",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
NotificationStatus: "accepted",
|
||||
NotificationRunID: "distributor-run-1",
|
||||
ReportID: "daily",
|
||||
Status: "succeeded",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
},
|
||||
{
|
||||
ReportID: "three_day",
|
||||
Status: "failed",
|
||||
Error: "notify report three_day: upload failed",
|
||||
NotificationStatus: "failed",
|
||||
NotificationError: "notify report three_day: upload failed",
|
||||
ReportID: "tomorrow",
|
||||
Status: "succeeded",
|
||||
OutputPath: "/tmp/tomorrow.md",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -240,23 +267,28 @@ func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
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.Reports[0].NotificationStatus != "accepted" || decoded.Reports[0].NotificationRunID != "distributor-run-1" {
|
||||
t.Fatalf("success notification fields = %#v", decoded.Reports[0])
|
||||
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("top-level notification = %#v, want succeeded batch notification", decoded.Notification)
|
||||
}
|
||||
if decoded.Reports[1].NotificationStatus != "failed" || !strings.Contains(decoded.Reports[1].NotificationError, "upload failed") {
|
||||
t.Fatalf("failure notification fields = %#v", decoded.Reports[1])
|
||||
for _, report := range decoded.Reports {
|
||||
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"`) {
|
||||
t.Fatalf("stderr missing success notification fields:\n%s", stderr.String())
|
||||
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(), `notificationStatus="failed"`) || !strings.Contains(stderr.String(), `notificationError="notify report three_day: upload failed"`) {
|
||||
t.Fatalf("stderr missing failure notification fields:\n%s", stderr.String())
|
||||
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 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,13 +297,14 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
||||
Batch: app.BatchMorning,
|
||||
Total: 1,
|
||||
Failed: 1,
|
||||
Notification: &app.BatchNotificationResult{
|
||||
Status: "failed",
|
||||
Error: "notify batch morning: upload failed: [redacted]",
|
||||
},
|
||||
Reports: []app.BatchReportResult{
|
||||
{
|
||||
ReportID: "daily",
|
||||
Status: "failed",
|
||||
Error: "notify report daily: upload failed: [redacted]",
|
||||
NotificationStatus: "failed",
|
||||
NotificationError: "notify report daily: upload failed: [redacted]",
|
||||
ReportID: "daily",
|
||||
Status: "succeeded",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -281,7 +314,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
||||
if err := writeJSON(&stdout, result); err != nil {
|
||||
t.Fatalf("writeJSON() error = %v", err)
|
||||
}
|
||||
writeRunLogs(&stderr, result)
|
||||
writeBatchStatus(&stderr, result)
|
||||
|
||||
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
|
||||
@@ -293,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) {
|
||||
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||
outputDir := fixture.path("copies")
|
||||
@@ -306,9 +407,9 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var summary app.BatchResult
|
||||
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
||||
summary := decodeBatchSummary(t, output.stdout)
|
||||
if summary.Command != "run" || summary.Status != "succeeded" {
|
||||
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
|
||||
}
|
||||
if summary.Total != 1 || summary.Failed != 0 {
|
||||
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
|
||||
@@ -321,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)
|
||||
var uploadCount int
|
||||
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.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
|
||||
}
|
||||
if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" {
|
||||
if r.URL.Path != "/v1/pipelines/weatherreporter/upload" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
uploadCount++
|
||||
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)
|
||||
tempDir := t.TempDir()
|
||||
@@ -354,36 +475,46 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
var summary app.BatchResult
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
||||
summary := decodeBatchSummary(t, stdout.String())
|
||||
if summary.Command != "run" || summary.Status != "succeeded" {
|
||||
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
|
||||
}
|
||||
if len(summary.Reports) != 1 {
|
||||
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" {
|
||||
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
||||
if uploadCount != 1 {
|
||||
t.Fatalf("batch upload count = %d, want 1", uploadCount)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||
t.Fatalf("stderr missing notification fields:\n%s", stderr.String())
|
||||
if summary.Notification == nil || summary.Notification.Status != "succeeded" || summary.Notification.RunID != "batch-distributor-run" {
|
||||
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") {
|
||||
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)
|
||||
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"rejected cli-secret-token","retryable":false}`))
|
||||
t.Fatalf("unexpected distributor request %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", "cli-secret-token")
|
||||
configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
@@ -392,26 +523,22 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
|
||||
"run", "evening",
|
||||
"--config", configPath,
|
||||
}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want notification failure")
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
var summary app.BatchResult
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
||||
summary := decodeBatchSummary(t, stdout.String())
|
||||
if summary.Command != "run" || summary.Status != "succeeded" {
|
||||
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
|
||||
}
|
||||
if len(summary.Reports) != 1 || summary.Reports[0].NotificationStatus != "failed" {
|
||||
t.Fatalf("summary reports = %#v, want failed notification", summary.Reports)
|
||||
if len(summary.Reports) != 1 {
|
||||
t.Fatalf("summary reports = %#v, want one report", summary.Reports)
|
||||
}
|
||||
for _, output := range []string{stdout.String(), stderr.String(), err.Error()} {
|
||||
if strings.Contains(output, "cli-secret-token") {
|
||||
t.Fatalf("output contains token value:\n%s", output)
|
||||
}
|
||||
if summary.Notification != nil {
|
||||
t.Fatalf("batch notification = %#v, want omitted when disabled", summary.Notification)
|
||||
}
|
||||
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||
if !strings.Contains(output, "[redacted]") {
|
||||
t.Fatalf("output missing redaction marker:\n%s", output)
|
||||
}
|
||||
if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationError != "" {
|
||||
t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,11 +555,11 @@ func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
|
||||
if err != nil {
|
||||
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", "tomorrow", "2026-06-01", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml")
|
||||
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) {
|
||||
@@ -452,14 +579,14 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
report, err := os.ReadFile(outPath)
|
||||
reportData, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(report), "# Friday's Weather") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
if !strings.Contains(string(reportData), "# Friday's Weather") {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
@@ -474,7 +601,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
!strings.Contains(string(data), "timezone: UTC") {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("read preflight: %v", err)
|
||||
@@ -482,11 +609,11 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(preflight), `ok`) {
|
||||
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
|
||||
}
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "*.md")
|
||||
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")
|
||||
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")
|
||||
_ = 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")
|
||||
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")
|
||||
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, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
|
||||
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
|
||||
@@ -509,7 +636,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
report, err := os.ReadFile(outPath)
|
||||
reportData, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
@@ -518,11 +645,11 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
||||
"Today starts with showers before improving.",
|
||||
"Morning showers should taper as drier air arrives.",
|
||||
} {
|
||||
if !strings.Contains(string(report), want) {
|
||||
t.Fatalf("today report output missing %q:\n%s", want, string(report))
|
||||
if !strings.Contains(string(reportData), want) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
@@ -532,15 +659,29 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
||||
!strings.Contains(string(dataPackage), "today_planning:") {
|
||||
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")
|
||||
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")
|
||||
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json")
|
||||
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "*.md")
|
||||
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")
|
||||
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")
|
||||
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "2026-05-29", "report.*.md")
|
||||
assertFileContains(t, rawGeneratedTextPath, `"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, 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) {
|
||||
@@ -558,7 +699,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
report, err := os.ReadFile(outPath)
|
||||
reportData, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
@@ -568,11 +709,11 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
"A cold front is moving into the region.",
|
||||
"A front will keep the region unsettled.",
|
||||
} {
|
||||
if !strings.Contains(string(report), want) {
|
||||
t.Fatalf("report output missing %q:\n%s", want, string(report))
|
||||
if !strings.Contains(string(reportData), want) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
@@ -582,10 +723,10 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
!strings.Contains(string(dataPackage), "hourly_forecast:") {
|
||||
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")
|
||||
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")
|
||||
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "*.md")
|
||||
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")
|
||||
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json")
|
||||
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md")
|
||||
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
||||
assertFileContains(t, renderContextPath, `"Report": {`)
|
||||
@@ -596,6 +737,76 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
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) {
|
||||
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
@@ -610,8 +821,8 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run(generate) error = %v", err)
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
||||
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
|
||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||
runID := runIDFromDataPackagePath(t, dataPackagePath)
|
||||
|
||||
stdout.Reset()
|
||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
|
||||
@@ -655,8 +866,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run(generate) error = %v", err)
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
||||
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
|
||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
|
||||
runID := runIDFromDataPackagePath(t, dataPackagePath)
|
||||
|
||||
stdout.Reset()
|
||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
|
||||
@@ -702,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) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
|
||||
@@ -1090,6 +1317,24 @@ func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput,
|
||||
}, 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 {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1163,6 +1408,12 @@ func writeTestConfigWithDistributor(t *testing.T, server *httptest.Server, scrip
|
||||
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 {
|
||||
t.Helper()
|
||||
return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n")
|
||||
@@ -1191,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) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -58,15 +58,22 @@ type NotifyConfig struct {
|
||||
}
|
||||
|
||||
type DistributorNotifyConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
|
||||
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||
ReportPathTemplates []string `yaml:"report_path_templates"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
|
||||
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||
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 {
|
||||
@@ -105,10 +112,16 @@ type RecentChangeConfig struct {
|
||||
}
|
||||
|
||||
type ReportConfig struct {
|
||||
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
|
||||
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
|
||||
Distributor ReportDistributorConfig `yaml:"distributor"`
|
||||
deterministicModulesSet bool
|
||||
}
|
||||
|
||||
type ReportDistributorConfig struct {
|
||||
PathTemplates []string `yaml:"path_templates"`
|
||||
pathTemplatesSet bool
|
||||
}
|
||||
|
||||
type ModuleConfigItem struct {
|
||||
ID module.ID `yaml:"id"`
|
||||
Options any `yaml:"options,omitempty"`
|
||||
@@ -127,6 +140,10 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||
return err
|
||||
}
|
||||
c.deterministicModulesSet = true
|
||||
case "distributor":
|
||||
if err := node.Decode(&c.Distributor); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown report entry field %q", key)
|
||||
}
|
||||
@@ -134,6 +151,112 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||
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 {
|
||||
switch value.Kind {
|
||||
case yaml.ScalarNode:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
@@ -57,11 +58,17 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" {
|
||||
t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate)
|
||||
}
|
||||
wantReportPaths := []string{
|
||||
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||
t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
|
||||
}
|
||||
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") {
|
||||
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths)
|
||||
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||
@@ -89,8 +96,17 @@ func TestLoadExampleConfig(t *testing.T) {
|
||||
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
|
||||
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||
}
|
||||
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||
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()
|
||||
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) {
|
||||
cfg := Defaults()
|
||||
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) {
|
||||
tests := []struct {
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -859,32 +1244,11 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
wantErr: "notify.distributor.idempotency_key_template",
|
||||
},
|
||||
{
|
||||
name: "ReportPathTemplatesEmpty",
|
||||
name: "BatchTemplate",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Notify.Distributor.ReportPathTemplates = nil
|
||||
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
|
||||
},
|
||||
wantErr: "notify.distributor.report_path_templates",
|
||||
},
|
||||
{
|
||||
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",
|
||||
wantErr: "notify.distributor.batch.bundle_id_template",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
values := DistributorTemplateValues{
|
||||
LocationID: "home",
|
||||
@@ -919,35 +1386,38 @@ func TestDistributorTemplateRendering(t *testing.T) {
|
||||
ValidEndTime: "0600",
|
||||
ValidStartStamp: "2026-06-07T1800",
|
||||
ValidEndStamp: "2026-06-08T0600",
|
||||
StormID: "2026-06-07T1800-2026-06-08T0600",
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values)
|
||||
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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",
|
||||
"storm/{storm_id}/index.md",
|
||||
"{valid_start_date}/{artifact_group}/latest.md",
|
||||
}, values)
|
||||
if err != nil {
|
||||
@@ -955,6 +1425,7 @@ func TestDistributorTemplateRendering(t *testing.T) {
|
||||
}
|
||||
wantPaths := []string{
|
||||
"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",
|
||||
}
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -999,7 +1577,7 @@ func TestDistributorReportPathValidation(t *testing.T) {
|
||||
{name: "ParentSegment", path: "reports/../daily.md", ok: false},
|
||||
{name: "EmptySegment", path: "reports//daily.md", 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 {
|
||||
@@ -1026,17 +1604,20 @@ func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
|
||||
{name: "ParentSegment", batchOutputName: "../daily.md"},
|
||||
{name: "EmptySegment", batchOutputName: "reports//daily.md"},
|
||||
{name: "Manifest", batchOutputName: "manifest.json"},
|
||||
{name: "DistributorMetadata", batchOutputName: ".distributor.json"},
|
||||
{name: "DistributorMetadata", batchOutputName: distributorSidecarBasename()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
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,
|
||||
})
|
||||
if err == nil {
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,11 @@ func Defaults() Config {
|
||||
PipelineIDTemplate: "",
|
||||
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
|
||||
IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
|
||||
ReportPathTemplates: []string{
|
||||
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
||||
Batch: DistributorBatchNotifyConfig{
|
||||
Enabled: true,
|
||||
PipelineIDTemplate: "weatherreporter",
|
||||
BundleIDTemplate: "weatherreporter.{location_id}.{batch}",
|
||||
IdempotencyKeyTemplate: "{bundle_id}.{batch_run_id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,9 +18,18 @@ type DistributorTemplateValues struct {
|
||||
ValidEndTime string
|
||||
ValidStartStamp string
|
||||
ValidEndStamp string
|
||||
StormID string
|
||||
BundleID string
|
||||
}
|
||||
|
||||
type DistributorBatchTemplateValues struct {
|
||||
LocationID string
|
||||
Batch string
|
||||
BatchRunID string
|
||||
BatchStartedDate string
|
||||
BundleID string
|
||||
}
|
||||
|
||||
var distributorTemplateVariables = map[string]struct{}{
|
||||
"location_id": {},
|
||||
"report_id": {},
|
||||
@@ -33,6 +42,7 @@ var distributorTemplateVariables = map[string]struct{}{
|
||||
"valid_end_time": {},
|
||||
"valid_start_stamp": {},
|
||||
"valid_end_stamp": {},
|
||||
"storm_id": {},
|
||||
}
|
||||
|
||||
var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
||||
@@ -47,11 +57,29 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
||||
"valid_end_time": {},
|
||||
"valid_start_stamp": {},
|
||||
"valid_end_stamp": {},
|
||||
"storm_id": {},
|
||||
"bundle_id": {},
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
seen := make(map[string]struct{}, len(templates))
|
||||
for i, template := range templates {
|
||||
name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i)
|
||||
rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables)
|
||||
itemName := fmt.Sprintf("%s[%d]", name, i)
|
||||
rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateDistributorReportPath(name, rendered); err != nil {
|
||||
if err := ValidateDistributorReportPath(itemName, rendered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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{}{}
|
||||
paths = append(paths, rendered)
|
||||
@@ -100,6 +161,11 @@ func validateDistributorTemplate(name, template string, allowed map[string]struc
|
||||
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) {
|
||||
var rendered strings.Builder
|
||||
for i := 0; i < len(template); {
|
||||
@@ -128,6 +194,34 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
|
||||
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 {
|
||||
switch variable {
|
||||
case "location_id":
|
||||
@@ -152,6 +246,25 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues)
|
||||
return values.ValidStartStamp
|
||||
case "valid_end_stamp":
|
||||
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":
|
||||
return values.BundleID
|
||||
default:
|
||||
@@ -178,7 +291,7 @@ func ValidateDistributorReportPath(name, path string) error {
|
||||
if segment == "." || segment == ".." {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -186,6 +299,10 @@ func ValidateDistributorReportPath(name, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func distributorSidecarBasename() string {
|
||||
return "." + "distributor.json"
|
||||
}
|
||||
|
||||
func isDistributorAbsolutePath(path string) bool {
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
||||
return true
|
||||
|
||||
@@ -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 {
|
||||
if cfg.Reports == nil {
|
||||
cfg.Reports = map[string]ReportConfig{}
|
||||
@@ -35,6 +39,11 @@ func validateReportModules(cfg Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func validateReportDistributorPathOverrides(cfg Config) error {
|
||||
_, err := traverseReportDistributorPathOverrides(cfg)
|
||||
return err
|
||||
}
|
||||
|
||||
type reportModuleTraversalOptions struct {
|
||||
normalizeOptions bool
|
||||
updateConfig bool
|
||||
@@ -82,6 +91,67 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[
|
||||
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) {
|
||||
out := make([]module.ConfigItem, 0, len(items))
|
||||
normalizedItems := append([]ModuleConfigItem(nil), items...)
|
||||
|
||||
@@ -12,6 +12,9 @@ func Validate(cfg Config) error {
|
||||
if err := validateReportModules(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateReportDistributorPathOverrides(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.WeatherAPI.BaseURL != "" {
|
||||
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if len(cfg.ReportPathTemplates) == 0 {
|
||||
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",
|
||||
}
|
||||
values := sampleDistributorTemplateValues()
|
||||
bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -145,13 +133,56 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
||||
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil {
|
||||
if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
switch policy {
|
||||
case MissingSourceError, MissingSourceWarn, MissingSourceNone:
|
||||
|
||||
@@ -18,16 +18,20 @@ func dailyDefinition() Definition {
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Daily},
|
||||
Modules: dailyModules(),
|
||||
resolve: resolveDaily,
|
||||
runIDDisambiguator: validStartDateRunIDDisambiguator,
|
||||
DistributorPathTemplates: []string{
|
||||
"daily/{valid_start_date}/{run_id}.md",
|
||||
"daily/{valid_start_date}/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Daily},
|
||||
Modules: dailyModules(),
|
||||
resolve: resolveDaily,
|
||||
runIDDisambiguator: validStartDateRunIDDisambiguator,
|
||||
}
|
||||
}
|
||||
|
||||
func dailyModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
items := moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.NarrativeForecast,
|
||||
@@ -36,13 +40,16 @@ func dailyModules() []module.ConfigItem {
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
)
|
||||
items = append(items, areaForecastDiscussionItem("long_term"))
|
||||
items = append(items, moduleItems(
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.DailyPlanning,
|
||||
module.HourlyForecast,
|
||||
)
|
||||
)...)
|
||||
return items
|
||||
}
|
||||
|
||||
func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
|
||||
|
||||
@@ -46,22 +46,23 @@ const (
|
||||
)
|
||||
|
||||
type Definition struct {
|
||||
ID ID
|
||||
Name string
|
||||
PromptID string
|
||||
GenerationMode GenerationMode
|
||||
TemplateID string
|
||||
GeneratedTextSchemaID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Modules []module.ConfigItem
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
runIDDisambiguator func(Resolved) string
|
||||
ID ID
|
||||
Name string
|
||||
PromptID string
|
||||
GenerationMode GenerationMode
|
||||
TemplateID string
|
||||
GeneratedTextSchemaID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
DistributorPathTemplates []string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Modules []module.ConfigItem
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
runIDDisambiguator func(Resolved) string
|
||||
}
|
||||
|
||||
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||
|
||||
@@ -20,10 +20,13 @@ func hourlyDefinition() Definition {
|
||||
ComparisonStrategy: CompareRollingWindow,
|
||||
ArtifactGroup: "hourly",
|
||||
BatchOutputName: "hourly.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Hourly},
|
||||
Modules: hourlyModules(),
|
||||
resolve: resolveHourly,
|
||||
DistributorPathTemplates: []string{
|
||||
"hourly/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Hourly},
|
||||
Modules: hourlyModules(),
|
||||
resolve: resolveHourly,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +38,7 @@ func hourlyModules() []module.ConfigItem {
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{
|
||||
Sections: []string{"key_messages", "short_term"},
|
||||
},
|
||||
},
|
||||
areaForecastDiscussionItem("key_messages", "short_term"),
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
}
|
||||
|
||||
@@ -520,6 +520,88 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedRegistryDefinitionsDeclareDistributorPathDefaults(t *testing.T) {
|
||||
for _, definition := range DefaultRegistry().All() {
|
||||
if !definition.Generated {
|
||||
continue
|
||||
}
|
||||
if len(definition.DistributorPathTemplates) == 0 {
|
||||
t.Fatalf("%s DistributorPathTemplates is empty", definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDefinitionsDeclareDefaultDistributorPathTemplates(t *testing.T) {
|
||||
tests := []struct {
|
||||
id ID
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
id: Hourly,
|
||||
want: []string{
|
||||
"hourly/index.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Daily,
|
||||
want: []string{
|
||||
"daily/{valid_start_date}/{run_id}.md",
|
||||
"daily/{valid_start_date}/index.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Today,
|
||||
want: []string{
|
||||
"daily/{valid_start_date}/{run_id}.md",
|
||||
"daily/{valid_start_date}/index.md",
|
||||
"today/index.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Tomorrow,
|
||||
want: []string{
|
||||
"daily/{valid_start_date}/{run_id}.md",
|
||||
"daily/{valid_start_date}/index.md",
|
||||
"tomorrow/index.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ThreeDay,
|
||||
want: []string{
|
||||
"three-day/{valid_start_date}/{run_id}.md",
|
||||
"three-day/{valid_start_date}/index.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Weekend,
|
||||
want: []string{
|
||||
"weekend/{valid_start_date}/{run_id}.md",
|
||||
"weekend/{valid_start_date}/index.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: Storm,
|
||||
want: []string{
|
||||
"storm/{storm_id}/{run_id}.md",
|
||||
"storm/{storm_id}/index.md",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registry := DefaultRegistry()
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
definition, err := registry.Lookup(tt.id)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(definition.DistributorPathTemplates, tt.want) {
|
||||
t.Fatalf("DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
tests := []struct {
|
||||
id ID
|
||||
@@ -682,6 +764,9 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
||||
if len(defaultDefinition.ModuleIDs()) <= len(definition.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) {
|
||||
|
||||
@@ -30,8 +30,7 @@ func DefaultRegistry() Registry {
|
||||
func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) {
|
||||
next := Registry{definitions: map[ID]Definition{}}
|
||||
for id, definition := range r.definitions {
|
||||
definition.Modules = append([]module.ConfigItem(nil), definition.Modules...)
|
||||
next.definitions[id] = definition
|
||||
next.definitions[id] = cloneDefinition(definition)
|
||||
}
|
||||
for id, items := range overrides {
|
||||
definition, ok := next.definitions[id]
|
||||
@@ -44,6 +43,12 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
|
||||
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 {
|
||||
items := make([]module.ConfigItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
@@ -52,6 +57,15 @@ func moduleItems(ids ...module.ID) []module.ConfigItem {
|
||||
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 {
|
||||
cloned := make([]module.ConfigItem, len(items))
|
||||
copy(cloned, items)
|
||||
|
||||
@@ -17,6 +17,10 @@ func stormDefinition() Definition {
|
||||
ComparisonStrategy: CompareExplicitWindow,
|
||||
ArtifactGroup: "storm",
|
||||
BatchOutputName: "storm.md",
|
||||
DistributorPathTemplates: []string{
|
||||
"storm/{storm_id}/{run_id}.md",
|
||||
"storm/{storm_id}/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Storm},
|
||||
Modules: stormModules(),
|
||||
|
||||
@@ -16,6 +16,10 @@ func threeDayDefinition() Definition {
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "three-day",
|
||||
BatchOutputName: "three-day.md",
|
||||
DistributorPathTemplates: []string{
|
||||
"three-day/{valid_start_date}/{run_id}.md",
|
||||
"three-day/{valid_start_date}/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{ThreeDay},
|
||||
Modules: threeDayModules(),
|
||||
|
||||
@@ -16,11 +16,16 @@ func todayDefinition() Definition {
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "today",
|
||||
BatchOutputName: "today.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Today},
|
||||
Modules: todayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveToday,
|
||||
DistributorPathTemplates: []string{
|
||||
"daily/{valid_start_date}/{run_id}.md",
|
||||
"daily/{valid_start_date}/index.md",
|
||||
"today/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Today},
|
||||
Modules: todayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveToday,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,16 @@ func tomorrowDefinition() Definition {
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "tomorrow",
|
||||
BatchOutputName: "tomorrow.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Tomorrow},
|
||||
Modules: tomorrowModules(),
|
||||
Evening: true,
|
||||
resolve: resolveTomorrow,
|
||||
DistributorPathTemplates: []string{
|
||||
"daily/{valid_start_date}/{run_id}.md",
|
||||
"daily/{valid_start_date}/index.md",
|
||||
"tomorrow/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Tomorrow},
|
||||
Modules: tomorrowModules(),
|
||||
Evening: true,
|
||||
resolve: resolveTomorrow,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ func weekendDefinition() Definition {
|
||||
ComparisonStrategy: CompareWeekendWindow,
|
||||
ArtifactGroup: "weekend",
|
||||
BatchOutputName: "weekend.md",
|
||||
DistributorPathTemplates: []string{
|
||||
"weekend/{valid_start_date}/{run_id}.md",
|
||||
"weekend/{valid_start_date}/index.md",
|
||||
},
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Weekend},
|
||||
Modules: weekendModules(),
|
||||
|
||||
@@ -83,26 +83,25 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
||||
return ArtifactPaths{}, fmt.Errorf("state store is required")
|
||||
}
|
||||
metadata := resolved.Metadata()
|
||||
if metadata.RunID == "" {
|
||||
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
||||
if err := validatePathSegment("run id", metadata.RunID); err != nil {
|
||||
return ArtifactPaths{}, err
|
||||
}
|
||||
group := resolved.Definition.ArtifactGroup
|
||||
if group == "" {
|
||||
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||
}
|
||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
filenameBase := metadata.RunID
|
||||
return ArtifactPaths{
|
||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
||||
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
||||
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
|
||||
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
||||
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
|
||||
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"),
|
||||
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"),
|
||||
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"),
|
||||
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"),
|
||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"),
|
||||
Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"),
|
||||
Preflight: s.join(s.preflightDir, group, validDate, "render."+metadata.RunID+".json"),
|
||||
Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"),
|
||||
RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"),
|
||||
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"),
|
||||
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, "generated_text_result."+metadata.RunID+".json"),
|
||||
GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"),
|
||||
RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -141,6 +140,33 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve
|
||||
}, 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) {
|
||||
return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
|
||||
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)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
||||
if entry.IsDir() || !isMetadataFilename(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
@@ -296,7 +322,7 @@ func (s *FilesystemStore) ListReports(_ context.Context, limit int) ([]ReportRec
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
record, err := s.reportRecord(path)
|
||||
@@ -461,6 +487,43 @@ func validateRelativeDir(name string, value string) error {
|
||||
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 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.modules.json"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.metadata.json"),
|
||||
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.data_package.yaml"),
|
||||
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.render.json"),
|
||||
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.distributor.json"),
|
||||
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_2026-05-29.md"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"),
|
||||
filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||
filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"),
|
||||
} {
|
||||
if !strings.Contains(pathsString(paths), want) {
|
||||
t.Fatalf("paths = %#v, want component %q", paths, want)
|
||||
@@ -69,6 +69,207 @@ func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsRejectRunIDPathSeparators(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
resolved.Definition.ID = report.ID("daily/bad")
|
||||
|
||||
_, err := store.Paths(resolved)
|
||||
if err == nil {
|
||||
t.Fatal("Paths() error = nil, want invalid run id error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run id must not contain path separators") {
|
||||
t.Fatalf("error = %q, want run id path separator context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) {
|
||||
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) {
|
||||
store := newTestStore(t)
|
||||
tests := []struct {
|
||||
@@ -115,15 +316,23 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
wants := map[string]string{
|
||||
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"),
|
||||
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"),
|
||||
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"),
|
||||
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"),
|
||||
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"),
|
||||
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"),
|
||||
"ModuleSnapshot": filepath.Join("snapshots", tt.group, tt.validDate, "modules."+tt.runID+".json"),
|
||||
"Metadata": filepath.Join("snapshots", tt.group, tt.validDate, "metadata."+tt.runID+".json"),
|
||||
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, "data_package."+tt.runID+".yaml"),
|
||||
"Preflight": filepath.Join("preflight", tt.group, tt.validDate, "render."+tt.runID+".json"),
|
||||
"Notification": filepath.Join("notifications", tt.group, tt.validDate, "distributor."+tt.runID+".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{
|
||||
"ModuleSnapshot": paths.ModuleSnapshot,
|
||||
"Metadata": paths.Metadata,
|
||||
"DataPackage": paths.DataPackage,
|
||||
"Preflight": paths.Preflight,
|
||||
"Notification": paths.Notification,
|
||||
"RenderedReport": paths.RenderedReport,
|
||||
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
||||
"GeneratedTextResult": paths.GeneratedTextResult,
|
||||
@@ -423,7 +632,7 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
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{
|
||||
ModuleSnapshot: paths.ModuleSnapshot,
|
||||
@@ -447,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) {
|
||||
store := newTestStore(t)
|
||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -595,6 +872,15 @@ func newTestStore(t *testing.T) *FilesystemStore {
|
||||
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 {
|
||||
t.Helper()
|
||||
return resolveDailyForDateAt(t, value, value)
|
||||
|
||||
@@ -17,6 +17,7 @@ type Store interface {
|
||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (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)
|
||||
SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error)
|
||||
SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error)
|
||||
@@ -45,6 +46,14 @@ type PreflightArtifact struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
@@ -80,3 +89,28 @@ type DistributorRunStatus struct {
|
||||
Report json.RawMessage `json:"report,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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user