Compare commits
6 Commits
f4f009b904
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 |
119
docs/cli.md
119
docs/cli.md
@@ -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
|
||||||
|
|||||||
@@ -251,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
|
||||||
@@ -292,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
67
docs/internal/cli.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# CLI Internals
|
||||||
|
|
||||||
|
This document describes command output ownership in `internal/cli`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/cli` owns command parsing, app request construction, help text, and
|
||||||
|
presentation of command results. It converts app-layer results into stable CLI
|
||||||
|
summaries and writes stdout/stderr through shared output helpers.
|
||||||
|
|
||||||
|
## Command Categories
|
||||||
|
|
||||||
|
- Action commands: `generate` and `run`. These perform work, write artifacts,
|
||||||
|
and return compact summaries.
|
||||||
|
- Inspection commands: `inspect reports`, `inspect metadata`, `inspect
|
||||||
|
modules`, `inspect data-package`, `inspect prior`, and `inspect sources`.
|
||||||
|
These read existing artifacts and return requested data.
|
||||||
|
|
||||||
|
Future commands must declare which category they belong to before adding output
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Stdout And Stderr
|
||||||
|
|
||||||
|
Action commands write JSON summaries to stdout by default. `run` also writes
|
||||||
|
compact status lines to stderr through `writeBatchStatus`. `generate` does not
|
||||||
|
write routine stderr today. Pre-run errors return without partial JSON.
|
||||||
|
|
||||||
|
Inspection commands write requested JSON data to stdout with `writeJSON`. They
|
||||||
|
do not use action output helpers and do not support quiet mode.
|
||||||
|
|
||||||
|
Returned errors are not hidden by output helpers. The caller remains
|
||||||
|
responsible for displaying command errors.
|
||||||
|
|
||||||
|
## Quiet Mode
|
||||||
|
|
||||||
|
`--quiet` is supported only by action commands. It suppresses successful stdout
|
||||||
|
and routine stderr by passing `outputOptions{Quiet: true}` to
|
||||||
|
`writeActionResult`. It does not suppress returned errors.
|
||||||
|
|
||||||
|
Quiet mode is intentionally not accepted by inspection commands because
|
||||||
|
inspection stdout is the command result.
|
||||||
|
|
||||||
|
## Summary Ownership
|
||||||
|
|
||||||
|
CLI-safe summary structs live in `internal/cli/result.go`.
|
||||||
|
|
||||||
|
- `newGenerateSummary` converts `*app.ReportResult` plus an optional error into
|
||||||
|
the generate JSON contract.
|
||||||
|
- `newBatchSummary` converts `*app.BatchResult` into the run JSON contract and
|
||||||
|
derives the top-level run status.
|
||||||
|
|
||||||
|
Summary types must not expose full app internals, module contents, data package
|
||||||
|
contents, raw generated text, Scriptorium result bodies, or full distributor
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
## Helper Path
|
||||||
|
|
||||||
|
New action commands should:
|
||||||
|
|
||||||
|
1. parse command-specific flags into CLI option structs;
|
||||||
|
2. call the app-layer use case;
|
||||||
|
3. convert app results into a CLI summary type;
|
||||||
|
4. write through `writeActionResult`;
|
||||||
|
5. use a status writer only for routine stderr status lines.
|
||||||
|
|
||||||
|
New inspection commands should call the app inspection use case and write the
|
||||||
|
returned data through `writeJSON`.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
# CLI Output Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the intended final shape for weatherreporter CLI output.
|
|
||||||
|
|
||||||
The current CLI has drifted:
|
|
||||||
|
|
||||||
- `run` commands emit JSON summaries to stdout and compact status lines to
|
|
||||||
stderr.
|
|
||||||
- `inspect` commands emit JSON to stdout.
|
|
||||||
- `generate` commands perform substantial work but are silent on success.
|
|
||||||
|
|
||||||
The target is a predictable command-line contract that is useful for operators,
|
|
||||||
easy to consume from scripts, and explicit enough that future commands naturally
|
|
||||||
reuse the same output path.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- Keep application orchestration and domain decisions in `internal/app`.
|
|
||||||
- Keep CLI presentation, stdout/stderr policy, and quiet-mode behavior in
|
|
||||||
`internal/cli`.
|
|
||||||
- Successful non-help commands should have a machine-readable JSON stdout
|
|
||||||
contract unless `--quiet` intentionally suppresses success output for an
|
|
||||||
action command.
|
|
||||||
- Help remains human-readable text.
|
|
||||||
- Stderr is for compact operational status and errors, not primary command
|
|
||||||
payloads.
|
|
||||||
- Do not print partial JSON when command construction, flag parsing, config
|
|
||||||
loading, or pre-run validation fails.
|
|
||||||
- Do not serialize large internal app objects directly as CLI output.
|
|
||||||
- Do not expose secret values in stdout or stderr.
|
|
||||||
- Add `--quiet` for state-changing action commands.
|
|
||||||
- Do not make `--quiet` suppress requested inspection data.
|
|
||||||
|
|
||||||
## Command Categories
|
|
||||||
|
|
||||||
CLI commands should be classified into one of these output categories.
|
|
||||||
|
|
||||||
### Help Commands
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `weatherreporter --help`
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
- stdout: human-readable help text
|
|
||||||
- stderr: none on success
|
|
||||||
- `--quiet`: not applicable
|
|
||||||
|
|
||||||
### Action Commands
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `weatherreporter generate today`
|
|
||||||
- `weatherreporter generate daily --date YYYY-MM-DD`
|
|
||||||
- `weatherreporter run morning`
|
|
||||||
- `weatherreporter run evening`
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
- stdout: compact JSON summary after the action completes
|
|
||||||
- stderr: compact status lines only when useful, especially for multi-report
|
|
||||||
batch commands
|
|
||||||
- `--quiet`: suppress success stdout and routine status stderr
|
|
||||||
|
|
||||||
Failure behavior:
|
|
||||||
|
|
||||||
- For flag/config/pre-run errors, stdout is empty and the command returns an
|
|
||||||
error.
|
|
||||||
- For completed actions that produce an inspectable failure result, default
|
|
||||||
output may still include a JSON failure summary before returning nonzero.
|
|
||||||
- With `--quiet`, failure diagnostics should remain concise and actionable on
|
|
||||||
stderr through the existing top-level error path; routine success summaries
|
|
||||||
stay suppressed.
|
|
||||||
|
|
||||||
### Inspection Commands
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- `weatherreporter inspect reports`
|
|
||||||
- `weatherreporter inspect metadata RUN_ID`
|
|
||||||
- `weatherreporter inspect modules RUN_ID`
|
|
||||||
- `weatherreporter inspect data-package RUN_ID`
|
|
||||||
- `weatherreporter inspect prior RUN_ID`
|
|
||||||
- `weatherreporter inspect sources RUN_ID`
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
- stdout: requested JSON data
|
|
||||||
- stderr: none on success
|
|
||||||
- `--quiet`: not accepted unless a future inspection command has auxiliary
|
|
||||||
status output to suppress
|
|
||||||
|
|
||||||
Inspection commands are already data-oriented. Their stdout payload should stay
|
|
||||||
focused on the requested data rather than being hidden by quiet mode.
|
|
||||||
|
|
||||||
## Target Action Summary Shape
|
|
||||||
|
|
||||||
Action command JSON should be small, stable, and path-oriented. It should expose
|
|
||||||
what an operator needs to find artifacts, inspect a run, and understand
|
|
||||||
notification status.
|
|
||||||
|
|
||||||
### Generate Summary
|
|
||||||
|
|
||||||
Target shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"command": "generate",
|
|
||||||
"reportId": "today",
|
|
||||||
"reportName": "Today Report",
|
|
||||||
"promptId": "weather.today_generated_text",
|
|
||||||
"runId": "20260529T100000.000000000Z_today",
|
|
||||||
"status": "succeeded",
|
|
||||||
"generatedAt": "2026-05-29T10:00:00Z",
|
|
||||||
"validPeriod": {},
|
|
||||||
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T100000.000000000Z_today.md",
|
|
||||||
"outputPath": "./today.md",
|
|
||||||
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T100000.000000000Z_today.json",
|
|
||||||
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T100000.000000000Z_today.yaml",
|
|
||||||
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T100000.000000000Z_today.json",
|
|
||||||
"notificationPath": "workspace/notifications/today/2026-05-29/distributor.20260529T100000.000000000Z_today.json",
|
|
||||||
"notification": {
|
|
||||||
"status": "succeeded",
|
|
||||||
"runId": "distributor-run",
|
|
||||||
"pipelineId": "weatherreporter.today",
|
|
||||||
"bundleId": "weatherreporter.home.today",
|
|
||||||
"path": "workspace/notifications/today/2026-05-29/distributor.20260529T100000.000000000Z_today.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Omit absent optional paths with `omitempty`.
|
|
||||||
- Include generated-text artifact paths only for report modes that produce them.
|
|
||||||
- Include notification fields only when notification was attempted.
|
|
||||||
- Keep module snapshot contents, data package contents, raw generated text,
|
|
||||||
render result bodies, and full notification adapter payloads out of the CLI
|
|
||||||
summary.
|
|
||||||
|
|
||||||
### Batch Summary
|
|
||||||
|
|
||||||
The current `BatchResult` shape is close to the target and should remain the
|
|
||||||
basis for `run` output. The target update is to make the summary explicitly
|
|
||||||
command-like and align status semantics with generate output:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"command": "run",
|
|
||||||
"batch": "morning",
|
|
||||||
"status": "succeeded",
|
|
||||||
"startedAt": "2026-05-29T10:00:00Z",
|
|
||||||
"finishedAt": "2026-05-29T10:01:00Z",
|
|
||||||
"total": 3,
|
|
||||||
"succeeded": 3,
|
|
||||||
"failed": 0,
|
|
||||||
"notification": {},
|
|
||||||
"reports": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Keep per-report items compact and path-oriented.
|
|
||||||
- Keep batch notification status at the top level.
|
|
||||||
- Preserve nonzero exit behavior when one or more reports fail.
|
|
||||||
- Preserve the existing behavior that batch report failures do not prevent the
|
|
||||||
JSON summary from being available in default output mode.
|
|
||||||
|
|
||||||
## Quiet Mode
|
|
||||||
|
|
||||||
`--quiet` should be available on action commands:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
weatherreporter generate today --quiet
|
|
||||||
weatherreporter run morning --quiet
|
|
||||||
```
|
|
||||||
|
|
||||||
Quiet mode means:
|
|
||||||
|
|
||||||
- no stdout on successful action commands
|
|
||||||
- no routine status lines on stderr on successful action commands
|
|
||||||
- errors still return nonzero and are still reported by the top-level CLI error
|
|
||||||
path
|
|
||||||
- inspection output is not suppressed
|
|
||||||
|
|
||||||
Quiet mode does not mean:
|
|
||||||
|
|
||||||
- skipping artifact writes
|
|
||||||
- skipping distributor notification
|
|
||||||
- changing JSON shape when JSON is emitted
|
|
||||||
- hiding errors
|
|
||||||
|
|
||||||
Future action commands should opt into quiet mode by using the centralized
|
|
||||||
action-output writer rather than implementing their own flag or writer logic.
|
|
||||||
|
|
||||||
## Intended Code Structure
|
|
||||||
|
|
||||||
`internal/cli` should own a small output layer that future commands can reuse.
|
|
||||||
The output layer should make the consistent path the easiest path.
|
|
||||||
|
|
||||||
Target files:
|
|
||||||
|
|
||||||
- `internal/cli/root.go`: command routing and flag parsing
|
|
||||||
- `internal/cli/output.go`: stdout/stderr writers, quiet-mode handling, and
|
|
||||||
output category helpers
|
|
||||||
- `internal/cli/result.go`: CLI-safe summary structs and conversion helpers
|
|
||||||
|
|
||||||
Target app-layer shape:
|
|
||||||
|
|
||||||
- Add a detailed generate entry point that returns the generated report result.
|
|
||||||
- Keep `app.Generate(ctx, GenerateRequest) error` as a convenience wrapper for
|
|
||||||
callers that do not need CLI output.
|
|
||||||
- Keep `app.RunBatchDetailed(ctx, BatchRequest) (*BatchResult, error)` as the
|
|
||||||
batch command result source.
|
|
||||||
|
|
||||||
Target CLI output helpers:
|
|
||||||
|
|
||||||
- `writeJSON(io.Writer, any) error`
|
|
||||||
- `writeActionResult(stdout, stderr io.Writer, result actionResult, opts outputOptions) error`
|
|
||||||
- `writeBatchStatus(stderr io.Writer, result *app.BatchResult)`
|
|
||||||
- `writeGenerateStatus(stderr io.Writer, result GenerateSummary)` only if
|
|
||||||
single-report status lines become useful
|
|
||||||
|
|
||||||
The command router should not call `json.NewEncoder` directly outside the
|
|
||||||
central output helpers.
|
|
||||||
|
|
||||||
## Deferred Questions
|
|
||||||
|
|
||||||
None of these are required for the initial harmonization:
|
|
||||||
|
|
||||||
- global `--format` support
|
|
||||||
- NDJSON progress streams
|
|
||||||
- human-readable success output
|
|
||||||
- machine-readable error envelopes on stderr
|
|
||||||
- making inspection commands use a common envelope
|
|
||||||
|
|
||||||
These should remain deferred until there is a real consumer need.
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
# CLI Output Implementation Plan
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document is the staged implementation plan for
|
|
||||||
[cli.md](cli.md). It is written for an LLM coding agent that will implement the
|
|
||||||
CLI output harmonization in order.
|
|
||||||
|
|
||||||
The feature is complete when `generate`, `run`, and `inspect` have consistent
|
|
||||||
stdout/stderr behavior, action commands support `--quiet`, CLI output logic is
|
|
||||||
centralized in `internal/cli`, and maintained docs describe the implemented
|
|
||||||
contract.
|
|
||||||
|
|
||||||
## Ground Rules
|
|
||||||
|
|
||||||
- Review `docs/policy/architecture.md`, `docs/policy/development.md`, and
|
|
||||||
`docs/policy/documentation.md` before editing code.
|
|
||||||
- Keep application orchestration and domain behavior in `internal/app`.
|
|
||||||
- Keep CLI presentation, output summaries, stdout/stderr policy, and quiet-mode
|
|
||||||
behavior in `internal/cli`.
|
|
||||||
- Do not serialize full `app.ReportResult` values directly to CLI stdout.
|
|
||||||
- Do not add `--quiet` to inspection commands in this pass.
|
|
||||||
- Do not add global `--format`, NDJSON progress, human-readable success output,
|
|
||||||
or machine-readable error envelopes.
|
|
||||||
- Preserve current command semantics except where this plan explicitly changes
|
|
||||||
output behavior.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- CLI-safe summary structs live in `internal/cli/result.go`, not
|
|
||||||
`internal/app`.
|
|
||||||
- Add `app.GenerateDetailed(ctx, GenerateRequest) (*ReportResult, error)`.
|
|
||||||
- Keep `app.Generate(ctx, GenerateRequest) error` as a wrapper around
|
|
||||||
`GenerateDetailed`.
|
|
||||||
- Keep `app.RunBatchDetailed(ctx, BatchRequest) (*BatchResult, error)` as the
|
|
||||||
app-layer batch source.
|
|
||||||
- Add `command` and `status` fields in CLI summary structs, not in
|
|
||||||
`app.BatchResult`.
|
|
||||||
- `BatchSummary.status` is `failed` when any report failed or the top-level
|
|
||||||
batch notification status is `failed`; otherwise it is `succeeded`.
|
|
||||||
- `GenerateSummary.status` is `succeeded` for a successful generated report and
|
|
||||||
`failed` only when a non-nil `ReportResult` is returned with an error after
|
|
||||||
inspectable artifacts exist.
|
|
||||||
- Default action-command output may include a failure JSON summary when the app
|
|
||||||
layer returns a non-nil result with an error.
|
|
||||||
- Quiet mode suppresses successful action-command stdout and routine stderr. It
|
|
||||||
does not hide returned errors.
|
|
||||||
|
|
||||||
## Stage 1: Detailed Generate Result
|
|
||||||
|
|
||||||
Goal: make single-report generation return the same kind of structured result
|
|
||||||
that batch generation already uses internally.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Add `GenerateDetailed(ctx, GenerateRequest) (*ReportResult, error)` in
|
|
||||||
`internal/app`.
|
|
||||||
- Move the current body of `Generate` into `GenerateDetailed`.
|
|
||||||
- Change `Generate` to call `GenerateDetailed` and return only the error.
|
|
||||||
- Ensure `GenerateDetailed` preserves existing behavior for:
|
|
||||||
- collecting weather before resolving/generating;
|
|
||||||
- unknown or unimplemented reports;
|
|
||||||
- explicit Daily date requirements;
|
|
||||||
- optional output copy behavior;
|
|
||||||
- distributor notification behavior.
|
|
||||||
- When `GenerateReport` or generated-template finalization receives a non-empty
|
|
||||||
`finalizeRenderedReportResult` plus an error after managed artifacts exist,
|
|
||||||
return a non-nil `ReportResult` together with that error. This is especially
|
|
||||||
important for notification failures where the report and notification artifact
|
|
||||||
are inspectable.
|
|
||||||
- Do not return partial results for flag/config/pre-run validation failures or
|
|
||||||
failures before a useful run identity exists.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add app tests for `GenerateDetailed` success.
|
|
||||||
- Add an app test showing `Generate` still returns only the underlying error.
|
|
||||||
- Add or adjust an app test for notification failure so `GenerateDetailed`
|
|
||||||
returns a non-nil result with report, metadata, and notification artifact
|
|
||||||
paths while also returning the notification error.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Existing app behavior is preserved for callers of `Generate`.
|
|
||||||
- CLI callers can obtain a rich `ReportResult` from `GenerateDetailed`.
|
|
||||||
|
|
||||||
## Stage 2: CLI Summary Types
|
|
||||||
|
|
||||||
Goal: define small, stable CLI output contracts without exposing full app
|
|
||||||
internals.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Add `internal/cli/result.go`.
|
|
||||||
- Define a `generateSummary` struct with these JSON fields:
|
|
||||||
- `command`
|
|
||||||
- `reportId`
|
|
||||||
- `reportName`
|
|
||||||
- `promptId`
|
|
||||||
- `runId`
|
|
||||||
- `status`
|
|
||||||
- `generatedAt`
|
|
||||||
- `validPeriod`
|
|
||||||
- `reportPath,omitempty`
|
|
||||||
- `outputPath,omitempty`
|
|
||||||
- `metadataPath,omitempty`
|
|
||||||
- `dataPackagePath,omitempty`
|
|
||||||
- `preflightPath,omitempty`
|
|
||||||
- `generatedTextRawPath,omitempty`
|
|
||||||
- `generatedTextResultPath,omitempty`
|
|
||||||
- `generatedTextPath,omitempty`
|
|
||||||
- `renderContextPath,omitempty`
|
|
||||||
- `notificationPath,omitempty`
|
|
||||||
- `notification,omitempty`
|
|
||||||
- `error,omitempty`
|
|
||||||
- Define a `batchSummary` struct with these JSON fields:
|
|
||||||
- `command`
|
|
||||||
- `batch`
|
|
||||||
- `status`
|
|
||||||
- `startedAt`
|
|
||||||
- `finishedAt`
|
|
||||||
- `total`
|
|
||||||
- `succeeded`
|
|
||||||
- `failed`
|
|
||||||
- `notification,omitempty`
|
|
||||||
- `reports`
|
|
||||||
- `error,omitempty`
|
|
||||||
- Reuse existing app result substructures where they are already CLI-safe:
|
|
||||||
`timeutil.Period`, `app.BatchNotificationResult`, and
|
|
||||||
`app.BatchReportResult`.
|
|
||||||
- Add conversion helpers:
|
|
||||||
- `newGenerateSummary(result *app.ReportResult, err error) generateSummary`
|
|
||||||
- `newBatchSummary(result *app.BatchResult) batchSummary`
|
|
||||||
- Do not include module snapshot contents, data package contents, raw generated
|
|
||||||
text bytes, render result bodies, or full distributor adapter payloads.
|
|
||||||
- Keep error strings concise and avoid adding secrets.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add focused unit tests for summary conversion.
|
|
||||||
- Cover generated-text reports, markdown reports, disabled notification, and
|
|
||||||
notification failure with a non-nil result.
|
|
||||||
- Cover batch status derivation for success, report failure, skipped
|
|
||||||
notification, and failed notification.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- CLI summary shapes are explicit and independent of full app result structs.
|
|
||||||
|
|
||||||
## Stage 3: Centralized Output Helpers
|
|
||||||
|
|
||||||
Goal: make the consistent output path the default path for current and future
|
|
||||||
commands.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Add `internal/cli/output.go`.
|
|
||||||
- Move `writeJSON` from `root.go` into `output.go`.
|
|
||||||
- Move `writeRunLogs` from `root.go` into `output.go` and rename it to
|
|
||||||
`writeBatchStatus`.
|
|
||||||
- Add an `outputOptions` struct with at least:
|
|
||||||
- `Quiet bool`
|
|
||||||
- Add a small action output helper such as:
|
|
||||||
`writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error`.
|
|
||||||
- The helper must:
|
|
||||||
- return without writing stdout or routine stderr when `opts.Quiet` is true;
|
|
||||||
- write status before JSON for default action output when a status writer is
|
|
||||||
provided;
|
|
||||||
- use the shared JSON writer for stdout;
|
|
||||||
- tolerate nil stderr when no status output is needed.
|
|
||||||
- Keep inspect commands using `writeJSON` directly because inspection is data
|
|
||||||
output, not quietable action output.
|
|
||||||
- Ensure `root.go` no longer calls `json.NewEncoder` directly.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add unit tests for quiet/default action output helper behavior.
|
|
||||||
- Keep existing batch stderr tests, updated for renamed helpers if needed.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- JSON encoding and action status output are centralized outside command
|
|
||||||
routing.
|
|
||||||
|
|
||||||
## Stage 4: Wire Generate And Run Output
|
|
||||||
|
|
||||||
Goal: make current action commands use the same output contract.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Extend `commonOptions` or action-specific options with `Quiet bool`.
|
|
||||||
- Parse `--quiet` for `generate` and `run`.
|
|
||||||
- Do not parse or accept `--quiet` for `inspect`.
|
|
||||||
- Update `Runner.Run`:
|
|
||||||
- `generate` should call `app.GenerateDetailed`;
|
|
||||||
- when a non-nil result is returned, convert it to `generateSummary`;
|
|
||||||
- write the summary through the centralized action output helper;
|
|
||||||
- if an error is also returned, write default JSON only when a non-nil result
|
|
||||||
exists and quiet is false, then return the error;
|
|
||||||
- if no result is returned, return the error without writing partial JSON.
|
|
||||||
- Update `run` command handling:
|
|
||||||
- convert `*app.BatchResult` to `batchSummary`;
|
|
||||||
- write through the centralized action output helper;
|
|
||||||
- preserve the current behavior that a batch result is emitted in default
|
|
||||||
mode before returning `app.BatchError` for failed reports;
|
|
||||||
- preserve notification-only batch failure behavior.
|
|
||||||
- Keep `inspect` commands unchanged except for using the relocated `writeJSON`.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- `generate today` emits valid JSON on success.
|
|
||||||
- The generate JSON includes `command: "generate"`, `status: "succeeded"`,
|
|
||||||
`reportId`, `runId`, `reportPath`, `metadataPath`, `dataPackagePath`, and
|
|
||||||
`preflightPath`.
|
|
||||||
- Generated-text reports include generated-text artifact paths.
|
|
||||||
- Markdown reports omit generated-text artifact paths.
|
|
||||||
- `generate --quiet` emits no stdout or routine stderr on success.
|
|
||||||
- Generate pre-run errors emit no partial JSON.
|
|
||||||
- Generate notification failure with an inspectable result emits a failure JSON
|
|
||||||
summary in default mode and returns nonzero.
|
|
||||||
- `run morning` and `run evening` still emit JSON summaries by default.
|
|
||||||
- Run JSON includes `command: "run"` and a derived `status`.
|
|
||||||
- `run --quiet` suppresses successful summary/status output.
|
|
||||||
- Failed batch runs still return nonzero and still emit default JSON when quiet
|
|
||||||
is false.
|
|
||||||
- Inspect commands still emit requested JSON and reject `--quiet` as an
|
|
||||||
unexpected flag.
|
|
||||||
- Output tests confirm distributor token values are not printed.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/cli ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Current action commands have consistent default JSON behavior.
|
|
||||||
- Quiet mode is available for action commands and not inspection commands.
|
|
||||||
|
|
||||||
## Stage 5: Documentation
|
|
||||||
|
|
||||||
Goal: document the implemented CLI output contract in maintained docs and make
|
|
||||||
future changes follow the same structure.
|
|
||||||
|
|
||||||
Documentation changes:
|
|
||||||
|
|
||||||
- Update `docs/cli.md`:
|
|
||||||
- document default JSON stdout for `generate`, `run`, and `inspect`;
|
|
||||||
- document compact status stderr for batch commands;
|
|
||||||
- document `--quiet` for `generate` and `run`;
|
|
||||||
- include representative generate and run JSON snippets;
|
|
||||||
- state that inspection commands are not quietable.
|
|
||||||
- Update `docs/operations.md` if cron/operator behavior changes need an
|
|
||||||
operations note.
|
|
||||||
- Add `docs/internal/cli.md` documenting:
|
|
||||||
- command categories;
|
|
||||||
- stdout/stderr rules;
|
|
||||||
- quiet-mode behavior;
|
|
||||||
- summary conversion ownership;
|
|
||||||
- the expected helper path for future commands.
|
|
||||||
- Update `docs/policy/development.md` CLI-change guidance so future CLI
|
|
||||||
commands are expected to use the centralized output helpers and declare an
|
|
||||||
output category.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Update CLI help-output tests for `--quiet`.
|
|
||||||
- Add or update docs-related tests only if this repository already validates the
|
|
||||||
touched docs/examples in tests.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/cli ./internal/app
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Non-roadmap docs describe only implemented behavior.
|
|
||||||
- The roadmap can be removed after implementation if no deferred CLI-output
|
|
||||||
feature remains in it.
|
|
||||||
|
|
||||||
## Final Verification
|
|
||||||
|
|
||||||
Before considering the feature complete, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Also manually verify these command behaviors against test fixtures or a local
|
|
||||||
test config when practical:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
weatherreporter generate today --quiet
|
|
||||||
weatherreporter run morning --quiet
|
|
||||||
weatherreporter inspect reports --limit 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. The decisions above are sufficient for implementation.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, ¬ificationErr) {
|
||||||
|
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)
|
||||||
@@ -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 {
|
||||||
@@ -3496,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"}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
75
internal/cli/output.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type outputOptions struct {
|
||||||
|
Quiet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error {
|
||||||
|
if opts.Quiet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeStatus != nil && stderr != nil {
|
||||||
|
writeStatus(stderr)
|
||||||
|
}
|
||||||
|
return writeJSON(stdout, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(stdout io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
||||||
|
if stderr == nil || result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
notificationFields := ""
|
||||||
|
if item.NotificationStatus != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||||
|
}
|
||||||
|
if item.NotificationRunID != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||||
|
}
|
||||||
|
if item.NotificationError != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||||
|
}
|
||||||
|
if item.Status == "failed" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||||
|
}
|
||||||
|
if result.Notification != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
|
||||||
|
if result.Notification.Reason != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
|
||||||
|
}
|
||||||
|
if result.Notification.RunID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
|
||||||
|
}
|
||||||
|
if result.Notification.PipelineID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
|
||||||
|
}
|
||||||
|
if result.Notification.BundleID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
|
||||||
|
}
|
||||||
|
if result.Notification.Path != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
|
||||||
|
}
|
||||||
|
if result.Notification.Error != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(stderr)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
59
internal/cli/output_test.go
Normal file
59
internal/cli/output_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteActionResultWritesStatusBeforeJSON(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := writeActionResult(&output, &output, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(output.String(), "status line\n") {
|
||||||
|
t.Fatalf("output = %q, want status before JSON", output.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), `"status": "succeeded"`) {
|
||||||
|
t.Fatalf("output missing JSON result:\n%s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteActionResultQuietSuppressesOutput(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
err := writeActionResult(&stdout, &stderr, map[string]string{"status": "succeeded"}, outputOptions{Quiet: true}, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stdout/stderr = %q/%q, want no output", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteActionResultToleratesNilStderr(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
statusCalled := false
|
||||||
|
|
||||||
|
err := writeActionResult(&stdout, nil, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||||
|
statusCalled = true
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if statusCalled {
|
||||||
|
t.Fatal("status writer was called with nil stderr")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"status": "succeeded"`) {
|
||||||
|
t.Fatalf("stdout missing JSON result:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/cli/result.go
Normal file
162
internal/cli/result.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
commandGenerate = "generate"
|
||||||
|
commandRun = "run"
|
||||||
|
|
||||||
|
summaryStatusSucceeded = "succeeded"
|
||||||
|
summaryStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type generateSummary struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
ReportID report.ID `json:"reportId"`
|
||||||
|
ReportName string `json:"reportName"`
|
||||||
|
PromptID string `json:"promptId"`
|
||||||
|
RunID string `json:"runId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
|
OutputPath string `json:"outputPath,omitempty"`
|
||||||
|
MetadataPath string `json:"metadataPath,omitempty"`
|
||||||
|
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||||
|
PreflightPath string `json:"preflightPath,omitempty"`
|
||||||
|
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||||
|
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
||||||
|
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||||
|
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||||
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
|
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateNotificationSummary struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
UploadStatus string `json:"uploadStatus,omitempty"`
|
||||||
|
StatusError string `json:"statusError,omitempty"`
|
||||||
|
RunID string `json:"runId,omitempty"`
|
||||||
|
PipelineID string `json:"pipelineId,omitempty"`
|
||||||
|
BundleID string `json:"bundleId,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchSummary struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Batch app.BatchKind `json:"batch"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||||
|
Reports []app.BatchReportResult `json:"reports"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
||||||
|
summary := generateSummary{Command: commandGenerate}
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := result.Metadata
|
||||||
|
summary.ReportID = metadata.ReportID
|
||||||
|
summary.ReportName = reportName(metadata.ReportID)
|
||||||
|
summary.PromptID = metadata.PromptID
|
||||||
|
summary.RunID = metadata.RunID
|
||||||
|
summary.Status = summaryStatusSucceeded
|
||||||
|
summary.GeneratedAt = metadata.GeneratedAt
|
||||||
|
summary.ValidPeriod = metadata.ValidPeriod
|
||||||
|
summary.ReportPath = result.ReportPath
|
||||||
|
summary.OutputPath = result.OutputPath
|
||||||
|
summary.MetadataPath = result.MetadataPath
|
||||||
|
summary.DataPackagePath = result.DataPackagePath
|
||||||
|
summary.PreflightPath = result.PreflightPath
|
||||||
|
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||||
|
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
|
||||||
|
summary.GeneratedTextPath = result.GeneratedTextPath
|
||||||
|
summary.RenderContextPath = result.RenderContextPath
|
||||||
|
summary.NotificationPath = result.NotificationPath
|
||||||
|
summary.Notification = newGenerateNotificationSummary(result.Notification)
|
||||||
|
if err != nil {
|
||||||
|
summary.Status = summaryStatusFailed
|
||||||
|
summary.Error = err.Error()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
summary := &generateNotificationSummary{
|
||||||
|
Status: result.Status,
|
||||||
|
UploadStatus: result.UploadStatus,
|
||||||
|
StatusError: result.StatusError,
|
||||||
|
RunID: result.RunID,
|
||||||
|
PipelineID: result.PipelineID,
|
||||||
|
BundleID: result.BundleID,
|
||||||
|
IdempotencyKey: result.IdempotencyKey,
|
||||||
|
StartedAt: result.StartedAt,
|
||||||
|
FinishedAt: result.FinishedAt,
|
||||||
|
Error: result.Error,
|
||||||
|
}
|
||||||
|
if !result.AcceptedAt.IsZero() {
|
||||||
|
acceptedAt := result.AcceptedAt
|
||||||
|
summary.AcceptedAt = &acceptedAt
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchSummary(result *app.BatchResult) batchSummary {
|
||||||
|
summary := batchSummary{Command: commandRun}
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Batch = result.Batch
|
||||||
|
summary.Status = batchSummaryStatus(result)
|
||||||
|
summary.StartedAt = result.StartedAt
|
||||||
|
summary.FinishedAt = result.FinishedAt
|
||||||
|
summary.Total = result.Total
|
||||||
|
summary.Succeeded = result.Succeeded
|
||||||
|
summary.Failed = result.Failed
|
||||||
|
summary.Notification = result.Notification
|
||||||
|
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||||
|
if summary.Status == summaryStatusFailed {
|
||||||
|
summary.Error = app.BatchError{Result: result}.Error()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchSummaryStatus(result *app.BatchResult) string {
|
||||||
|
if result == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||||
|
return summaryStatusFailed
|
||||||
|
}
|
||||||
|
return summaryStatusSucceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportName(id report.ID) string {
|
||||||
|
definition, err := report.DefaultRegistry().Lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
|
return definition.Name
|
||||||
|
}
|
||||||
231
internal/cli/result_test.go
Normal file
231
internal/cli/result_test.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
acceptedAt := generatedAt.Add(time.Minute)
|
||||||
|
startedAt := acceptedAt.Add(time.Minute)
|
||||||
|
finishedAt := startedAt.Add(time.Minute)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
|
PreflightPath: "/runs/hourly/preflight.json",
|
||||||
|
ReportPath: "/runs/hourly/report.md",
|
||||||
|
OutputPath: "/copies/hourly.md",
|
||||||
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||||
|
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
|
||||||
|
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||||
|
RenderContextPath: "/runs/hourly/render_context.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
RunID: "20260529T133000Z_hourly",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
Notification: &app.NotificationResult{
|
||||||
|
Status: "succeeded",
|
||||||
|
UploadStatus: "accepted",
|
||||||
|
RunID: "distributor-run",
|
||||||
|
PipelineID: "weatherreporter.hourly",
|
||||||
|
BundleID: "weatherreporter.home.hourly",
|
||||||
|
IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly",
|
||||||
|
AcceptedAt: acceptedAt,
|
||||||
|
StartedAt: &startedAt,
|
||||||
|
FinishedAt: &finishedAt,
|
||||||
|
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
|
if summary.Command != "generate" || summary.Status != "succeeded" {
|
||||||
|
t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status)
|
||||||
|
}
|
||||||
|
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
|
||||||
|
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
|
||||||
|
}
|
||||||
|
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||||
|
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||||
|
}
|
||||||
|
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
|
||||||
|
t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
||||||
|
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/three-day/data_package.yaml",
|
||||||
|
PreflightPath: "/runs/three-day/preflight.json",
|
||||||
|
ReportPath: "/runs/three-day/report.md",
|
||||||
|
OutputPath: "/copies/three-day.md",
|
||||||
|
MetadataPath: "/runs/three-day/metadata.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.ThreeDay,
|
||||||
|
PromptID: "weather.three_day_outlook",
|
||||||
|
RunID: "20260529T133000Z_three_day",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
|
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
|
||||||
|
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
|
||||||
|
}
|
||||||
|
if summary.Notification != nil || summary.NotificationPath != "" {
|
||||||
|
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
|
||||||
|
if strings.Contains(string(data), omitted) {
|
||||||
|
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
|
PreflightPath: "/runs/hourly/preflight.json",
|
||||||
|
ReportPath: "/runs/hourly/report.md",
|
||||||
|
OutputPath: "/copies/hourly.md",
|
||||||
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
RunID: "20260529T133000Z_hourly",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`)
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, err)
|
||||||
|
|
||||||
|
if summary.Status != "failed" || summary.Error != err.Error() {
|
||||||
|
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
|
||||||
|
}
|
||||||
|
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" {
|
||||||
|
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBatchSummaryStatusDerivation(t *testing.T) {
|
||||||
|
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
finishedAt := startedAt.Add(2 * time.Minute)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *app.BatchResult
|
||||||
|
wantStatus string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
FinishedAt: finishedAt,
|
||||||
|
Total: 1,
|
||||||
|
Succeeded: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}},
|
||||||
|
},
|
||||||
|
wantStatus: "succeeded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report failure",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{
|
||||||
|
{ReportID: report.Today, Status: "succeeded"},
|
||||||
|
{ReportID: report.Tomorrow, Status: "failed", Error: "render failed"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch morning failed: 1 of 2 reports failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skipped notification",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchEvening,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}},
|
||||||
|
Notification: &app.BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "one or more reports failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch evening failed: 1 of 2 reports failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed notification",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchEvening,
|
||||||
|
Total: 1,
|
||||||
|
Succeeded: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}},
|
||||||
|
Notification: &app.BatchNotificationResult{
|
||||||
|
Status: "failed",
|
||||||
|
Error: "notify batch evening: upload rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch evening notification failed: notify batch evening: upload rejected",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
summary := newBatchSummary(tt.result)
|
||||||
|
if summary.Command != "run" || summary.Status != tt.wantStatus {
|
||||||
|
t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if summary.Error != tt.wantError {
|
||||||
|
t.Fatalf("error = %q, want %q", summary.Error, tt.wantError)
|
||||||
|
}
|
||||||
|
if len(summary.Reports) != len(tt.result.Reports) {
|
||||||
|
t.Fatalf("reports = %#v, want copied report list", summary.Reports)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSummaryPeriod(start time.Time) timeutil.Period {
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: start,
|
||||||
|
End: start.Add(6 * time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package cli
|
|||||||
|
|
||||||
import (
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
@@ -143,6 +149,20 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|||||||
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) {
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -541,12 +579,12 @@ 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)
|
||||||
@@ -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,8 +645,8 @@ 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")
|
||||||
@@ -630,6 +668,20 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
|||||||
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,8 +709,8 @@ 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")
|
||||||
@@ -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()}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user