12 Commits

32 changed files with 1579 additions and 763 deletions

View File

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

View File

@@ -135,7 +135,8 @@ identify a specific retryable batch attempt.
Rendered report paths must be unique relative paths with `/` separators. They Rendered report paths must be unique relative paths with `/` separators. They
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`, must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
or `.distributor.json`. In a batch upload, uniqueness is checked across every or the reserved distributor sidecar basename, formed from a leading dot plus
`distributor.json`. In a batch upload, uniqueness is checked across every
rendered bundle path for every included report before distributor is called. rendered bundle path for every included report before distributor is called.
Managed Markdown report paths are the only upload source files; copies written Managed Markdown report paths are the only upload source files; copies written
with `--out` or `--out-dir` are never uploaded. with `--out` or `--out-dir` are never uploaded.
@@ -184,7 +185,9 @@ the missing-source policy. Source override keys include `observations`,
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`. - `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
Workspace subdirectories must be relative paths that stay inside Workspace subdirectories must be relative paths that stay inside
`workspace.root`. `workspace.root`. Managed artifact paths below those directories are grouped by
artifact group and valid-period start date; the path template is not
configurable.
### `dayparts` ### `dayparts`
@@ -248,7 +251,7 @@ reports:
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - long_term
- spc_convective_discussion - spc_convective_discussion
- daily_planning - daily_planning
- hourly_forecast - hourly_forecast
@@ -289,7 +292,8 @@ Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading. combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`, `area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections` `key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD sections. includes all available AFD sections. Default report definitions may choose a
smaller report-specific subset, such as daily reports using only `long_term`.
The module registry accepts all module IDs documented in The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Unknown or unimplemented [Module Contract Internals](internal/module.md). Unknown or unimplemented

View File

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

View File

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

View File

@@ -84,7 +84,9 @@ defaults.
Single-report commands validate the report command, collect once through Single-report commands validate the report command, collect once through
`internal/collect`, resolve the requested report, and pass the resolved report `internal/collect`, resolve the requested report, and pass the resolved report
plus explicit collection into `GenerateReport`. plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
callers.
`GenerateReport` then uses this setup: `GenerateReport` then uses this setup:
@@ -191,7 +193,9 @@ inspection view.
- Generated-text report errors preserve available intermediate artifacts and do - Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies. not create extra output copies.
- Single-report notification errors are wrapped with report ID, RunID, and - Single-report notification errors are wrapped with report ID, RunID, and
managed report path context. managed report path context. Detailed generation returns the inspectable
report, metadata, and notification artifact paths when finalization has
already saved them.
- Batch notification errors are recorded on the top-level batch notification - Batch notification errors are recorded on the top-level batch notification
result and do not change individual report item status. result and do not change individual report item status.
- Metadata and artifact path errors include filesystem context. - Metadata and artifact path errors include filesystem context.

View File

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

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

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

View File

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

View File

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

View File

@@ -37,7 +37,9 @@ effective report timezone and is not included in `run morning` or
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved. Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for generated reports; it is not used `--out PATH` writes an extra Markdown copy for generated reports; it is not used
as the distributor upload source. as the distributor upload source. Generate commands emit a compact JSON summary
to stdout by default. Use `--quiet` to suppress successful stdout for cron jobs
or other schedulers that only need nonzero exits and external logs.
Batch commands: Batch commands:
@@ -64,7 +66,8 @@ after every planned report succeeds. If any report fails, the batch upload is
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
using report default filenames such as `today.md` and `tomorrow.md`; dynamic using report default filenames such as `today.md` and `tomorrow.md`; dynamic
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
distributor upload sources. distributor upload sources. Use `--quiet` to suppress successful batch summary
and status output; failures still return nonzero.
## Filesystem Layout ## Filesystem Layout
@@ -72,143 +75,26 @@ The default workspace root is `workspace`.
```text ```text
workspace/ workspace/
snapshots/ reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
daily/
YYYY-MM-DD/ snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
<run_id>.modules.json snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
<run_id>.generated_text.raw.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
<run_id>.generated_text.run.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
<run_id>.generated_text.json snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
<run_id>.render_context.json
today/ data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
YYYY-MM-DD/ preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
<run_id>.modules.json
<run_id>.metadata.json notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
<run_id>.generated_text.raw.json notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
three-day/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
hourly/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
tomorrow/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.yaml
today/
YYYY-MM-DD/
<run_id>.data_package.yaml
three-day/
YYYY-MM-DD/
<run_id>.data_package.yaml
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
hourly/
YYYY-MM-DD/
<run_id>.data_package.yaml
tomorrow/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
today/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
weekend/
YYYY-MM-DD/
<run_id>.render.json
hourly/
YYYY-MM-DD/
<run_id>.render.json
tomorrow/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
notifications/
batches/
morning/
YYYY-MM-DD/
<batch_run_id>.distributor.json
evening/
YYYY-MM-DD/
<batch_run_id>.distributor.json
daily/
YYYY-MM-DD/
<run_id>.distributor.json
today/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/
YYYY-MM-DD/
<run_id>.distributor.json
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
hourly/
YYYY-MM-DD/
<run_id>.distributor.json
tomorrow/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
reports/
daily/
<run_id>.md
today/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
hourly/
<run_id>.md
tomorrow/
<run_id>.md
storm/
<run_id>.md
``` ```
Managed artifact filenames use the RunID, so repeated runs for the same valid Managed artifact filenames use the artifact kind and RunID, so repeated runs
period do not overwrite each other. for the same valid period do not overwrite each other. The date directory is
the valid-period start date in the effective report timezone. Generated-text
artifacts are written only for Daily, Today, Tomorrow, and Hourly reports.
## RunID And Metadata ## RunID And Metadata
@@ -300,9 +186,9 @@ count, but individual report items remain succeeded.
Each notification attempt writes a debug artifact under `notifications/`. Each notification attempt writes a debug artifact under `notifications/`.
Single-report artifacts live under Single-report artifacts live under
`notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json`. Batch `notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
artifacts live under artifacts live under
`notifications/batches/<batch>/<YYYY-MM-DD>/<batch_run_id>.distributor.json`, `notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
where the date directory is the batch start date in the effective report where the date directory is the batch start date in the effective report
timezone. The artifact records the rendered pipeline ID, bundle ID, timezone. The artifact records the rendered pipeline ID, bundle ID,
idempotency key, managed source paths, bundle-relative paths, bundle created idempotency key, managed source paths, bundle-relative paths, bundle created

View File

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

View File

@@ -1,194 +0,0 @@
# Workspace Layout Implementation Plan
## Purpose
This document is the staged implementation plan for
[workspace.md](workspace.md). It is written for an LLM coding agent that will
implement the workspace artifact layout refactor in order.
The feature is complete when every managed local run artifact uses the
kind-first, date-scoped layout defined in `docs/roadmap/workspace.md`, metadata
and inspection link those paths correctly, and distributor upload behavior is
unchanged.
## Ground Rules
- Review `docs/policy/architecture.md`, `docs/policy/development.md`, and
`docs/policy/documentation.md` before editing code.
- Keep path construction owned by `internal/state`.
- Do not add user-visible workspace path template configuration.
- Do not use distributor bundle paths for managed workspace paths.
- Do not implement cleanup, migration tooling, or a local publication mirror in
this work.
- Keep optional `--out` and `--out-dir` copies separate from managed workspace
artifacts.
## Target Path Contract
State-managed run artifacts must use these relative path shapes under
`workspace.root`:
```text
reports/<artifact_group>/<valid_start_date>/report.<run_id>.md
snapshots/<artifact_group>/<valid_start_date>/modules.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/metadata.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/generated_text_result.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/generated_text.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/render_context.<run_id>.json
data-packages/<artifact_group>/<valid_start_date>/data_package.<run_id>.yaml
preflight/<artifact_group>/<valid_start_date>/render.<run_id>.json
notifications/<artifact_group>/<valid_start_date>/distributor.<run_id>.json
notifications/batches/<batch>/<batch_start_date>/distributor.<batch_run_id>.json
```
`valid_start_date` and `batch_start_date` are local dates in the effective
report timezone already carried by the resolved report or batch reference.
## Stage 1: State Path Construction
Goal: switch the canonical managed path builder to the target layout.
Implementation:
- Update `internal/state.FilesystemStore.Paths` to build all `ArtifactPaths`
using the target path contract above.
- Keep existing validation for workspace subdirectories, RunID, and report
artifact group.
- Keep `PrepareRenderedReport` responsible for creating the parent report
directory before Scriptorium or template rendering writes Markdown.
- Update `BatchDistributorNotificationPath` to use
`distributor.<batch_run_id>.json`.
Tests:
- Update state path tests to assert the exact new relative paths for at least
`daily`, `today`, `tomorrow`, and `hourly`.
- Update batch notification path tests for
`notifications/batches/<batch>/<date>/distributor.<batch_run_id>.json`.
- Run:
```bash
go test ./internal/state
```
Completion criteria:
- `FilesystemStore.Paths` returns only target-layout managed paths.
- Existing save methods write to the new paths through the existing
`ArtifactPaths` fields.
## Stage 2: Metadata Discovery And Prior Lookup
Goal: make inspection and prior lookup discover the new metadata filenames.
Implementation:
- Replace metadata filename checks that currently rely on the old
`.metadata.json` suffix with a helper that matches only
`metadata.<run_id>.json`.
- Use that helper in `ListReports` and `FindPriorSnapshot`.
- Do not add legacy metadata filename fallback in this refactor; migration is
explicitly deferred by the feature roadmap.
- Keep `LoadMetadataByRunID` behavior based on `ListReports`.
Tests:
- Update report listing tests to create/read `metadata.<run_id>.json`.
- Update prior lookup tests to confirm comparable metadata is found under the
new filename.
- Add or adjust one negative test so non-metadata JSON in the snapshot tree is
ignored.
- Run:
```bash
go test ./internal/state
```
Completion criteria:
- Inspection and prior lookup work with new metadata names.
- Generated-text, module, and render-context JSON files are not mistaken for
metadata.
## Stage 3: App Workflow Expectations
Goal: update app-level tests and any path assumptions outside `internal/state`.
Implementation:
- Do not duplicate path-building logic in `internal/app`; continue asking the
state store for managed paths.
- Update app tests that assert managed report paths, data package paths,
generated-text paths, render context paths, notification source paths, batch
included report source paths, and result JSON paths.
- Preserve the invariant that distributor uploads use the managed Markdown
report path as the upload source and map it to separate distributor bundle
paths.
- Preserve `--out` and `--out-dir` behavior as extra copies outside the managed
report path.
Tests:
- Run:
```bash
go test ./internal/app ./internal/cli
```
Completion criteria:
- App workflows still generate, save metadata, notify distributor, and report
paths using state-owned managed paths.
- No app code constructs the new managed path layout manually.
## Stage 4: Documentation
Goal: move the implemented layout into maintained docs after code behavior has
changed.
Documentation changes:
- Update `docs/internal/state.md` with the new target managed layout and
filename convention.
- Update `docs/operations.md` filesystem layout examples and artifact wording.
- Update `docs/config.md` only if workspace field descriptions need
clarification.
- Keep cleanup, migration, and local publication mirror details in roadmap
docs until those features are implemented.
Validation:
```bash
go test ./internal/state ./internal/app ./internal/config
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Completion criteria:
- Implemented docs match the new behavior.
- Non-roadmap docs do not describe deferred cleanup or local publication mirror
behavior as implemented.
## Final Verification
Before considering the feature complete, run:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
rg "\\.metadata\\.json|\\.modules\\.json|\\.data_package\\.yaml|\\.render\\.json|\\.distributor\\.json|generated_text\\.raw|generated_text\\.run" internal docs examples --glob '!docs/roadmap/**'
```
The final `rg` should find no old managed filename patterns in implemented
code, maintained docs, or examples.
## Open Questions
None. The feature roadmap decisions are sufficient for implementation.

View File

@@ -1,119 +0,0 @@
# Workspace Artifact Layout Roadmap
## Purpose
This roadmap defines the target local workspace layout for artifacts generated
by weatherreporter.
Weatherreporter currently writes managed local artifacts for each run and also
maps the managed Markdown report into distributor bundle paths. Distributor
bundle paths are publication paths and may include overwrite-oriented aliases
such as `hourly/index.md`, `today/index.md`, and
`daily/<valid_start_date>/index.md`. Local workspace paths have a different
job: they preserve run-specific source artifacts for inspection, troubleshooting,
retry diagnosis, and future cleanup.
## Locked Decisions
- Keep managed local workspace paths separate from distributor bundle paths.
- Do not use distributor-style `index.md` aliases as the canonical managed
local artifact paths.
- Store managed local artifacts as immutable run artifacts.
- Group every durable run artifact by artifact family, report artifact group,
and local `valid_start_date`.
- Use `valid_start_date` in the effective report timezone as the directory key.
- Include the RunID in every durable run artifact filename.
- Prefer artifact-kind-first filenames: `<kind>.<run_id>.<extension>`.
- Keep optional `--out` and `--out-dir` copies separate from managed workspace
artifacts.
- Future cleanup should delete old managed artifact date directories by
`valid_start_date`; cleanup should be narrow and opt-in.
- If a local publication preview is added later, put it in a separate mirror
tree and treat it as rebuildable output, not canonical run state.
## Target Managed Layout
The target managed workspace layout is:
```text
<workspace.root>/
reports/<artifact_group>/<valid_start_date>/report.<run_id>.md
snapshots/<artifact_group>/<valid_start_date>/modules.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/metadata.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/generated_text_result.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/generated_text.<run_id>.json
snapshots/<artifact_group>/<valid_start_date>/render_context.<run_id>.json
data-packages/<artifact_group>/<valid_start_date>/data_package.<run_id>.yaml
preflight/<artifact_group>/<valid_start_date>/render.<run_id>.json
notifications/<artifact_group>/<valid_start_date>/distributor.<run_id>.json
notifications/batches/<batch>/<batch_start_date>/distributor.<batch_run_id>.json
```
`artifact_group` comes from the report definition. `valid_start_date` is the
resolved report valid period start date in the effective report timezone.
`batch_start_date` is the batch start date in the effective report timezone.
Generated-text-template reports use the generated-text artifacts. Markdown-path
reports omit generated-text artifacts they do not produce.
## Cleanup Compatibility
The target layout is designed to support a future cleanup command that removes
managed local artifacts where `valid_start_date` is more than two days before
the current local date.
Cleanup can operate by deleting matching date directories across managed
artifact families:
```text
reports/<artifact_group>/<old_date>/
snapshots/<artifact_group>/<old_date>/
data-packages/<artifact_group>/<old_date>/
preflight/<artifact_group>/<old_date>/
notifications/<artifact_group>/<old_date>/
```
The command should not infer dates from RunIDs when the directory date is
available. It should use the effective configured report timezone for "current
date" and for interpreting `valid_start_date` directories.
Batch notification cleanup should use `batch_start_date` under
`notifications/batches/<batch>/`. A future local publication mirror, if added,
should be ignored by cleanup or rebuilt after cleanup; it should not be treated
as run history.
## Intended Final State
`internal/state` owns the managed workspace layout and path construction. The
app layer continues to ask the state store for managed paths and records those
paths in metadata and command results.
Metadata links every artifact produced for a run using the new managed paths.
Inspection and prior-snapshot lookup continue to read metadata rather than
rendered Markdown text. Distributor uploads continue to use the managed
Markdown report as the source file and map it to separately resolved
distributor bundle paths.
Existing workspace configuration fields remain meaningful:
- `workspace.root`
- `workspace.snapshots_dir`
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
- `workspace.notifications_dir`
This roadmap does not require a new user-visible workspace path template config
surface.
## Deferred Work
- A cleanup command for managed local artifacts.
- Cleanup dry-run output, retention configuration, and deletion confirmation
behavior.
- A rebuildable local publication mirror that uses distributor-style paths.
- Migration tooling for existing pre-refactor workspace directories.

View File

@@ -88,9 +88,6 @@ reports:
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- product
- key_messages
- short_term
- long_term - long_term
- spc_convective_discussion - spc_convective_discussion
- weather_story - weather_story

View File

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

View File

@@ -268,29 +268,33 @@ func (e *NotificationError) Unwrap() error {
} }
func Generate(ctx context.Context, req GenerateRequest) error { func Generate(ctx context.Context, req GenerateRequest) error {
_, err := GenerateDetailed(ctx, req)
return err
}
func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult, error) {
now := req.Now now := req.Now
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
collection, err := collectWeather(ctx, req.Config, req.Collector) collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil { if err != nil {
return err return nil, err
} }
resolved, err := ResolveGenerate(req, now) resolved, err := ResolveGenerate(req, now)
if err != nil { if err != nil {
return err return nil, err
} }
if resolved.Definition.Generated { if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{ return GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Collection: *collection, Collection: *collection,
Notifier: req.Notifier, Notifier: req.Notifier,
}) })
return err
} }
return fmt.Errorf("generate is not implemented") return nil, fmt.Errorf("generate is not implemented")
} }
func RunBatch(ctx context.Context, req BatchRequest) error { func RunBatch(ctx context.Context, req BatchRequest) error {
@@ -654,26 +658,37 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
noNotify: req.noNotify, noNotify: req.noNotify,
}) })
if err != nil { if err != nil {
return nil, err if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), err
} }
return &ReportResult{ return renderedReportResult(reportResultRequest{
ModuleSnapshot: moduleSnapshot, moduleSnapshot: moduleSnapshot,
ModuleSnapshotPath: moduleSnapshotPath, moduleSnapshotPath: moduleSnapshotPath,
DataPackage: dataPackage, dataPackage: dataPackage,
DataPackagePath: dataPackagePath, dataPackagePath: dataPackagePath,
PreflightPath: preflightPath, preflightPath: preflightPath,
ReportPath: reportPath, reportPath: reportPath,
OutputPath: finalized.OutputPath, finalized: finalized,
NotificationPath: finalized.NotificationPath, priorSnapshot: priorSnapshot,
Metadata: finalized.Metadata, recentChanges: recentChanges,
MetadataPath: finalized.MetadataPath, renderResult: renderResult,
PriorSnapshot: priorSnapshot, runResult: runResult,
RecentChanges: recentChanges, }), nil
RenderResult: renderResult,
RunResult: runResult,
Notification: finalized.Notification,
}, nil
} }
type generatedReportRequest struct { type generatedReportRequest struct {
@@ -777,30 +792,97 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
noNotify: req.noNotify, noNotify: req.noNotify,
}) })
if err != nil { if err != nil {
return nil, err if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), err
} }
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), nil
}
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
return result.OutputPath == "" &&
result.NotificationPath == "" &&
result.MetadataPath == "" &&
result.Metadata.RunID == "" &&
result.Notification == nil
}
type reportResultRequest struct {
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
preflightPath string
reportPath string
finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
generatedTextRawPath string
generatedTextResultPath string
generatedTextPath string
renderContextPath string
}
func renderedReportResult(req reportResultRequest) *ReportResult {
return &ReportResult{ return &ReportResult{
ModuleSnapshot: req.moduleSnapshot, ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath, ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage, DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath, DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath, PreflightPath: req.preflightPath,
ReportPath: reportPath, ReportPath: req.reportPath,
OutputPath: finalized.OutputPath, OutputPath: req.finalized.OutputPath,
NotificationPath: finalized.NotificationPath, NotificationPath: req.finalized.NotificationPath,
Metadata: finalized.Metadata, Metadata: req.finalized.Metadata,
MetadataPath: finalized.MetadataPath, MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot, PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
RenderResult: req.renderResult, RenderResult: req.renderResult,
StructuredRunResult: structuredResult, RunResult: req.runResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw, StructuredRunResult: req.structuredRunResult,
GeneratedTextResultPath: generatedTextResultPath, GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextPath: generatedTextPath, GeneratedTextResultPath: req.generatedTextResultPath,
RenderContextPath: renderContextPath, GeneratedTextPath: req.generatedTextPath,
Notification: finalized.Notification, RenderContextPath: req.renderContextPath,
}, nil Notification: req.finalized.Notification,
}
} }
type finalizeRenderedReportRequest struct { type finalizeRenderedReportRequest struct {

View File

@@ -168,6 +168,97 @@ func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
} }
} }
func TestGenerateDetailedReturnsReportResult(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
collection := collectionForTest(t, cfg)
collector := &recordingCollector{result: &collection}
outputPath := filepath.Join(t.TempDir(), "three-day.md")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
OutputPath: outputPath,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
}
if result == nil {
t.Fatal("GenerateDetailed() result = nil, want report result")
}
if result.Metadata.ReportID != report.ThreeDay || result.Metadata.RunID == "" {
t.Fatalf("metadata = %#v, want 3-day report metadata with run id", result.Metadata)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection", len(collector.requests))
}
}
func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir()
wantErr := errors.New("collector unavailable")
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: &recordingCollector{err: wantErr},
})
if !errors.Is(err, wantErr) {
t.Fatalf("Generate() error = %v, want underlying collector error", err)
}
}
func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
cfg.Scriptorium.Binary = fakeScriptoriumBinary(t)
collection := collectionForTest(t, cfg)
notifier := &recordingNotifier{err: errors.New("upload rejected")}
outputPath := filepath.Join(t.TempDir(), "hourly.md")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportHourly,
OutputPath: outputPath,
Now: mustParse("2026-05-29T08:30:00-05:00"),
Collector: &recordingCollector{result: &collection},
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateDetailed() error = nil, want notification error")
}
var notificationErr *NotificationError
if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateDetailed() error = %T %v, want NotificationError", err, err)
}
if result == nil {
t.Fatal("GenerateDetailed() result = nil, want inspectable result on notification failure")
}
if result.Metadata.ReportID != report.Hourly || result.Metadata.NotificationPath != result.NotificationPath {
t.Fatalf("metadata = %#v notificationPath=%q, want hourly notification artifact link", result.Metadata, result.NotificationPath)
}
if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" {
t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath)
if result.Notification != nil {
t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification)
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests))
}
}
func TestGenerateReportWritesReportAndPreflight(t *testing.T) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
@@ -255,7 +346,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
} }
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") { if !strings.HasPrefix(filepath.Base(result.DataPackagePath), "data_package.") || !strings.HasSuffix(result.DataPackagePath, ".yaml") {
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath) t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
} }
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") ||
@@ -357,8 +448,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if !ok || story["title"] != "Several Chances for Rain Through Monday" { if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"]) t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
} }
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { if !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data)) t.Fatalf("data package missing daily long-term AFD discussion:\n%s", string(data))
}
for _, omitted := range []string{"Short-term AFD narrative for generated report.", "Storms are most likely during the morning."} {
if strings.Contains(string(data), omitted) {
t.Fatalf("data package contains daily omitted AFD field %q:\n%s", omitted, string(data))
}
} }
preflight, err := os.ReadFile(result.PreflightPath) preflight, err := os.ReadFile(result.PreflightPath)
if err != nil { if err != nil {
@@ -2548,12 +2644,12 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
PipelineID: "weatherreporter", PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.evening", BundleID: "weatherreporter.home.evening",
IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening", IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening",
Path: "notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json", Path: "notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json",
IncludedReports: []BatchNotificationReport{ IncludedReports: []BatchNotificationReport{
{ {
ReportID: report.Tomorrow, ReportID: report.Tomorrow,
RunID: "20260529T233000.000000000Z_tomorrow", RunID: "20260529T233000.000000000Z_tomorrow",
SourcePath: "reports/tomorrow.md", SourcePath: "reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md",
BundlePaths: []string{"tomorrow/index.md"}, BundlePaths: []string{"tomorrow/index.md"},
}, },
}, },
@@ -2573,10 +2669,10 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
`"pipelineId":"weatherreporter"`, `"pipelineId":"weatherreporter"`,
`"bundleId":"weatherreporter.home.evening"`, `"bundleId":"weatherreporter.home.evening"`,
`"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`, `"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`,
`"path":"notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json"`, `"path":"notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json"`,
`"includedReports":[`, `"includedReports":[`,
`"reportId":"tomorrow"`, `"reportId":"tomorrow"`,
`"sourcePath":"reports/tomorrow.md"`, `"sourcePath":"reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md"`,
`"bundlePaths":["tomorrow/index.md"]`, `"bundlePaths":["tomorrow/index.md"]`,
} { } {
if !strings.Contains(string(data), want) { if !strings.Contains(string(data), want) {
@@ -3346,15 +3442,22 @@ func plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch Batc
if err != nil { if err != nil {
t.Fatalf("planBatchRun() error = %v", err) t.Fatalf("planBatchRun() error = %v", err)
} }
reportDir := filepath.Join(t.TempDir(), "managed-reports") store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
results := make([]BatchReportResult, 0, len(planned)) results := make([]BatchReportResult, 0, len(planned))
for _, item := range planned { for _, item := range planned {
metadata := item.Resolved.Metadata() metadata := item.Resolved.Metadata()
paths, err := store.Paths(item.Resolved)
if err != nil {
t.Fatalf("Paths(%s) error = %v", item.Resolved.Definition.ID, err)
}
results = append(results, BatchReportResult{ results = append(results, BatchReportResult{
ReportID: item.Resolved.Definition.ID, ReportID: item.Resolved.Definition.ID,
RunID: metadata.RunID, RunID: metadata.RunID,
Status: "succeeded", Status: "succeeded",
ReportPath: filepath.Join(reportDir, string(item.Resolved.Definition.ID), metadata.RunID+".md"), ReportPath: paths.RenderedReport,
}) })
} }
return planned, results return planned, results
@@ -3489,6 +3592,66 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md") return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
} }
func fakeScriptoriumBinary(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "scriptorium")
script := `#!/bin/sh
set -eu
command_name="${1:-}"
shift || true
prompt=""
output=""
while [ "$#" -gt 0 ]; do
case "$1" in
--prompt)
shift
prompt="${1:-}"
;;
--out)
shift
output="${1:-}"
;;
esac
shift || true
done
case "$command_name" in
render)
printf '{"prepared":true}\n'
;;
run)
if [ -z "$output" ]; then
printf 'missing output path\n' >&2
exit 2
fi
case "$prompt" in
weather.hourly_generated_text)
cat > "$output" <<'EOF'
{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}
EOF
;;
*)
cat > "$output" <<'EOF'
# Generated Report
Prepared report body.
EOF
;;
esac
;;
*)
printf 'unknown command: %s\n' "$command_name" >&2
exit 2
;;
esac
`
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake scriptorium binary: %v", err)
}
return path
}
func validHourlyGeneratedTextJSON() string { func validHourlyGeneratedTextJSON() string {
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
} }

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -1577,7 +1577,7 @@ func TestDistributorReportPathValidation(t *testing.T) {
{name: "ParentSegment", path: "reports/../daily.md", ok: false}, {name: "ParentSegment", path: "reports/../daily.md", ok: false},
{name: "EmptySegment", path: "reports//daily.md", ok: false}, {name: "EmptySegment", path: "reports//daily.md", ok: false},
{name: "Manifest", path: "reports/manifest.json", ok: false}, {name: "Manifest", path: "reports/manifest.json", ok: false},
{name: "DistributorMetadata", path: "reports/.distributor.json", ok: false}, {name: "DistributorMetadata", path: "reports/" + distributorSidecarBasename(), ok: false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1604,7 +1604,7 @@ func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
{name: "ParentSegment", batchOutputName: "../daily.md"}, {name: "ParentSegment", batchOutputName: "../daily.md"},
{name: "EmptySegment", batchOutputName: "reports//daily.md"}, {name: "EmptySegment", batchOutputName: "reports//daily.md"},
{name: "Manifest", batchOutputName: "manifest.json"}, {name: "Manifest", batchOutputName: "manifest.json"},
{name: "DistributorMetadata", batchOutputName: ".distributor.json"}, {name: "DistributorMetadata", batchOutputName: distributorSidecarBasename()},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -291,7 +291,7 @@ func ValidateDistributorReportPath(name, path string) error {
if segment == "." || segment == ".." { if segment == "." || segment == ".." {
return fmt.Errorf("%s must not render . or .. path segments", name) return fmt.Errorf("%s must not render . or .. path segments", name)
} }
if segment == "manifest.json" || segment == ".distributor.json" { if segment == "manifest.json" || segment == distributorSidecarBasename() {
return fmt.Errorf("%s must not render reserved path segment %q", name, segment) return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
} }
} }
@@ -299,6 +299,10 @@ func ValidateDistributorReportPath(name, path string) error {
return nil return nil
} }
func distributorSidecarBasename() string {
return "." + "distributor.json"
}
func isDistributorAbsolutePath(path string) bool { func isDistributorAbsolutePath(path string) bool {
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") { if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
return true return true

View File

@@ -31,7 +31,7 @@ func dailyDefinition() Definition {
} }
func dailyModules() []module.ConfigItem { func dailyModules() []module.ConfigItem {
return moduleItems( items := moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
module.NarrativeForecast, module.NarrativeForecast,
@@ -40,13 +40,16 @@ func dailyModules() []module.ConfigItem {
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks, module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, )
items = append(items, areaForecastDiscussionItem("long_term"))
items = append(items, moduleItems(
module.SPCConvectiveDiscussion, module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning, module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
) )...)
return items
} }
func resolveDaily(req ResolveRequest) (timeutil.Period, error) { func resolveDaily(req ResolveRequest) (timeutil.Period, error) {

View File

@@ -38,12 +38,7 @@ func hourlyModules() []module.ConfigItem {
{ID: module.PrecipTiming}, {ID: module.PrecipTiming},
{ID: module.AlertDigest}, {ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks}, {ID: module.SPCConvectiveOutlooks},
{ areaForecastDiscussionItem("key_messages", "short_term"),
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: []string{"key_messages", "short_term"},
},
},
{ID: module.SPCConvectiveDiscussion}, {ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory}, {ID: module.WeatherStory},
} }

View File

@@ -57,6 +57,15 @@ func moduleItems(ids ...module.ID) []module.ConfigItem {
return items return items
} }
func areaForecastDiscussionItem(sections ...string) module.ConfigItem {
return module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: append([]string(nil), sections...),
},
}
}
func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem { func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem {
cloned := make([]module.ConfigItem, len(items)) cloned := make([]module.ConfigItem, len(items))
copy(cloned, items) copy(cloned, items)

View File

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

View File

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