Compare commits
14 Commits
15ee4af1a1
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 | |||
| f4f009b904 | |||
| 3c1b753952 | |||
| bdbab48d10 | |||
| 16cc4b3f63 | |||
| 0ef861ed8f | |||
| 6ae7eb44cf | |||
| 8f6aa8aa8b | |||
| b8e889ad13 |
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
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ identify a specific retryable batch attempt.
|
|||||||
|
|
||||||
Rendered report paths must be unique relative paths with `/` separators. They
|
Rendered report paths must be unique relative paths with `/` separators. They
|
||||||
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
||||||
or `.distributor.json`. In a batch upload, uniqueness is checked across every
|
or the reserved distributor sidecar basename, formed from a leading dot plus
|
||||||
|
`distributor.json`. In a batch upload, uniqueness is checked across every
|
||||||
rendered bundle path for every included report before distributor is called.
|
rendered bundle path for every included report before distributor is called.
|
||||||
Managed Markdown report paths are the only upload source files; copies written
|
Managed Markdown report paths are the only upload source files; copies written
|
||||||
with `--out` or `--out-dir` are never uploaded.
|
with `--out` or `--out-dir` are never uploaded.
|
||||||
@@ -184,7 +185,9 @@ the missing-source policy. Source override keys include `observations`,
|
|||||||
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
||||||
|
|
||||||
Workspace subdirectories must be relative paths that stay inside
|
Workspace subdirectories must be relative paths that stay inside
|
||||||
`workspace.root`.
|
`workspace.root`. Managed artifact paths below those directories are grouped by
|
||||||
|
artifact group and valid-period start date; the path template is not
|
||||||
|
configurable.
|
||||||
|
|
||||||
### `dayparts`
|
### `dayparts`
|
||||||
|
|
||||||
@@ -248,7 +251,7 @@ reports:
|
|||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- long_term
|
||||||
- spc_convective_discussion
|
- spc_convective_discussion
|
||||||
- daily_planning
|
- daily_planning
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
@@ -289,7 +292,8 @@ Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
|||||||
combinations, duplicate stanza names, and invalid options fail config loading.
|
combinations, duplicate stanza names, and invalid options fail config loading.
|
||||||
`area_forecast_discussion.options.sections` may contain `product`,
|
`area_forecast_discussion.options.sections` may contain `product`,
|
||||||
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
|
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
|
||||||
includes all available AFD sections.
|
includes all available AFD sections. Default report definitions may choose a
|
||||||
|
smaller report-specific subset, such as daily reports using only `long_term`.
|
||||||
|
|
||||||
The module registry accepts all module IDs documented in
|
The module registry accepts all module IDs documented in
|
||||||
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func SubmitReport(reportPath, summaryPath string) error {
|
|||||||
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
|
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
|
||||||
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
|
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
|
||||||
|
|
||||||
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`.
|
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames such as `manifest.json` and the distributor sidecar basename formed from a leading dot plus `distributor.json`.
|
||||||
|
|
||||||
## Idempotency And Status
|
## Idempotency And Status
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ Invalid paths include:
|
|||||||
- paths containing backslashes;
|
- paths containing backslashes;
|
||||||
- `.` or `..` path segments;
|
- `.` or `..` path segments;
|
||||||
- empty path segments;
|
- empty path segments;
|
||||||
- any basename of `manifest.json` or `.distributor.json`.
|
- any reserved basename, including `manifest.json` and the distributor sidecar
|
||||||
|
basename formed from a leading dot plus `distributor.json`.
|
||||||
|
|
||||||
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
|
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -62,21 +62,22 @@ Workspace subdirectories must be relative paths that stay under
|
|||||||
## Managed Layout
|
## Managed Layout
|
||||||
|
|
||||||
Paths are derived from the resolved report definition's artifact group, the
|
Paths are derived from the resolved report definition's artifact group, the
|
||||||
valid-period start date for dated artifacts, and the RunID.
|
valid-period start date, and the RunID. Filenames put the artifact kind before
|
||||||
|
the RunID.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||||
notifications/batches/<batch>/<YYYY-MM-DD>/<batch_run_id>.distributor.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||||
reports/<artifact_group>/<run_id>.md
|
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Metadata is stored beside module snapshots and links the module snapshot, data
|
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ effective report timezone and is not included in `run morning` or
|
|||||||
When distributor notification is enabled, weatherreporter uploads the managed
|
When distributor notification is enabled, weatherreporter uploads the managed
|
||||||
Markdown report after report rendering succeeds and final metadata is saved.
|
Markdown report after report rendering succeeds and final metadata is saved.
|
||||||
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||||
as the distributor upload source.
|
as the distributor upload source. Generate commands emit a compact JSON summary
|
||||||
|
to stdout by default. Use `--quiet` to suppress successful stdout for cron jobs
|
||||||
|
or other schedulers that only need nonzero exits and external logs.
|
||||||
|
|
||||||
Batch commands:
|
Batch commands:
|
||||||
|
|
||||||
@@ -64,7 +66,8 @@ after every planned report succeeds. If any report fails, the batch upload is
|
|||||||
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
|
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
|
||||||
using report default filenames such as `today.md` and `tomorrow.md`; dynamic
|
using report default filenames such as `today.md` and `tomorrow.md`; dynamic
|
||||||
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
|
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
|
||||||
distributor upload sources.
|
distributor upload sources. Use `--quiet` to suppress successful batch summary
|
||||||
|
and status output; failures still return nonzero.
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
|
|
||||||
@@ -72,143 +75,26 @@ The default workspace root is `workspace`.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
workspace/
|
workspace/
|
||||||
snapshots/
|
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||||
<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||||
<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||||
<run_id>.generated_text.raw.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||||
<run_id>.generated_text.run.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||||
<run_id>.generated_text.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||||
<run_id>.render_context.json
|
|
||||||
today/
|
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||||
YYYY-MM-DD/
|
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||||
<run_id>.generated_text.raw.json
|
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||||
<run_id>.generated_text.run.json
|
|
||||||
<run_id>.generated_text.json
|
|
||||||
<run_id>.render_context.json
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
<run_id>.generated_text.raw.json
|
|
||||||
<run_id>.generated_text.run.json
|
|
||||||
<run_id>.generated_text.json
|
|
||||||
<run_id>.render_context.json
|
|
||||||
tomorrow/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
<run_id>.generated_text.raw.json
|
|
||||||
<run_id>.generated_text.run.json
|
|
||||||
<run_id>.generated_text.json
|
|
||||||
<run_id>.render_context.json
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
data-packages/
|
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
today/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
tomorrow/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
preflight/
|
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
today/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
tomorrow/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
notifications/
|
|
||||||
batches/
|
|
||||||
morning/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<batch_run_id>.distributor.json
|
|
||||||
evening/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<batch_run_id>.distributor.json
|
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
today/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
tomorrow/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
reports/
|
|
||||||
daily/
|
|
||||||
<run_id>.md
|
|
||||||
today/
|
|
||||||
<run_id>.md
|
|
||||||
three-day/
|
|
||||||
<run_id>.md
|
|
||||||
weekend/
|
|
||||||
<run_id>.md
|
|
||||||
hourly/
|
|
||||||
<run_id>.md
|
|
||||||
tomorrow/
|
|
||||||
<run_id>.md
|
|
||||||
storm/
|
|
||||||
<run_id>.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
Managed artifact filenames use the artifact kind and RunID, so repeated runs
|
||||||
period do not overwrite each other.
|
for the same valid period do not overwrite each other. The date directory is
|
||||||
|
the valid-period start date in the effective report timezone. Generated-text
|
||||||
|
artifacts are written only for Daily, Today, Tomorrow, and Hourly reports.
|
||||||
|
|
||||||
## RunID And Metadata
|
## RunID And Metadata
|
||||||
|
|
||||||
@@ -300,9 +186,9 @@ count, but individual report items remain succeeded.
|
|||||||
|
|
||||||
Each notification attempt writes a debug artifact under `notifications/`.
|
Each notification attempt writes a debug artifact under `notifications/`.
|
||||||
Single-report artifacts live under
|
Single-report artifacts live under
|
||||||
`notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json`. Batch
|
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
|
||||||
artifacts live under
|
artifacts live under
|
||||||
`notifications/batches/<batch>/<YYYY-MM-DD>/<batch_run_id>.distributor.json`,
|
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
|
||||||
where the date directory is the batch start date in the effective report
|
where the date directory is the batch start date in the effective report
|
||||||
timezone. The artifact records the rendered pipeline ID, bundle ID,
|
timezone. The artifact records the rendered pipeline ID, bundle ID,
|
||||||
idempotency key, managed source paths, bundle-relative paths, bundle created
|
idempotency key, managed source paths, bundle-relative paths, bundle created
|
||||||
|
|||||||
@@ -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,126 +0,0 @@
|
|||||||
# Distributor Report Paths Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the target behavior for naming Markdown reports inside
|
|
||||||
distributor source bundles.
|
|
||||||
|
|
||||||
Weatherreporter currently uses one application-level
|
|
||||||
`notify.distributor.report_path_templates` list for every report type. That
|
|
||||||
model is too coarse for the intended output layout because different reports
|
|
||||||
need different archive paths, latest paths, and report-specific aliases.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- Remove `notify.distributor.report_path_templates`; do not keep a legacy
|
|
||||||
fallback or compatibility alias.
|
|
||||||
- Report definitions own canonical distributor output path templates.
|
|
||||||
- Configuration may override distributor output path templates per report.
|
|
||||||
- The app layer resolves report-specific distributor bundle paths before
|
|
||||||
calling the distributor adapter.
|
|
||||||
- The distributor adapter continues to receive explicit source-to-bundle file
|
|
||||||
mappings and does not choose report types, render templates, scan
|
|
||||||
workspaces, or apply report routing policy.
|
|
||||||
- Managed Markdown report files remain the only distributor upload sources.
|
|
||||||
Optional local output copies are not uploaded.
|
|
||||||
- Batch distributor uploads, when enabled, use the same report-specific bundle
|
|
||||||
path resolver for each included report.
|
|
||||||
- Non-roadmap documentation must not describe this behavior until it is
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
## Target Configuration Model
|
|
||||||
|
|
||||||
The global path template list is removed from `notify.distributor`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
notify:
|
|
||||||
distributor:
|
|
||||||
enabled: false
|
|
||||||
endpoint: https://distributor.example.com
|
|
||||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
|
||||||
timeout: 30s
|
|
||||||
failure_policy: error
|
|
||||||
pipeline_id_template: "weatherreporter.{report_id}"
|
|
||||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
|
||||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-report overrides live under `reports.<report>.distributor`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
daily:
|
|
||||||
distributor:
|
|
||||||
path_templates:
|
|
||||||
- "daily/{valid_start_date}/{run_id}.md"
|
|
||||||
- "daily/{valid_start_date}/index.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
If a report override is omitted, weatherreporter uses the defaults declared by
|
|
||||||
that report definition.
|
|
||||||
|
|
||||||
## Default Report Paths
|
|
||||||
|
|
||||||
Each generated report maps its managed Markdown source file to one or more
|
|
||||||
bundle-relative distributor paths.
|
|
||||||
|
|
||||||
| Report | Default distributor paths |
|
|
||||||
| --- | --- |
|
|
||||||
| `hourly` | `hourly/index.md` |
|
|
||||||
| `daily` | `daily/{valid_start_date}/{run_id}.md`; `daily/{valid_start_date}/index.md` |
|
|
||||||
| `today` | `daily/{valid_start_date}/{run_id}.md`; `daily/{valid_start_date}/index.md`; `today/index.md` |
|
|
||||||
| `tomorrow` | `daily/{valid_start_date}/{run_id}.md`; `daily/{valid_start_date}/index.md`; `tomorrow/index.md` |
|
|
||||||
| `three_day` | `three-day/{valid_start_date}/{run_id}.md`; `three-day/{valid_start_date}/index.md` |
|
|
||||||
| `weekend` | `weekend/{valid_start_date}/{run_id}.md`; `weekend/{valid_start_date}/index.md` |
|
|
||||||
| `storm` | `storm/{storm_id}/{run_id}.md`; `storm/{storm_id}/index.md` |
|
|
||||||
|
|
||||||
`storm_id` is derived from the resolved storm valid period until a future
|
|
||||||
explicit storm identity source exists.
|
|
||||||
|
|
||||||
## Template Values
|
|
||||||
|
|
||||||
Report path templates keep the existing report template values:
|
|
||||||
|
|
||||||
- `location_id`
|
|
||||||
- `report_id`
|
|
||||||
- `run_id`
|
|
||||||
- `artifact_group`
|
|
||||||
- `batch_output_name`
|
|
||||||
- `valid_start_date`
|
|
||||||
- `valid_end_date`
|
|
||||||
- `valid_start_time`
|
|
||||||
- `valid_end_time`
|
|
||||||
- `valid_start_stamp`
|
|
||||||
- `valid_end_stamp`
|
|
||||||
- `storm_id`
|
|
||||||
|
|
||||||
The initial `storm_id` value is `{valid_start_stamp}-{valid_end_stamp}` in the
|
|
||||||
effective report timezone. It is available for all single-report distributor
|
|
||||||
templates, but it renders as an empty value for non-storm reports.
|
|
||||||
|
|
||||||
Rendered paths must stay unique relative paths using `/` separators. They must
|
|
||||||
not contain backslashes, empty path segments, `.`, `..`, `manifest.json`, or
|
|
||||||
`.distributor.json`.
|
|
||||||
|
|
||||||
## Intended Final State
|
|
||||||
|
|
||||||
`internal/report.Definition` declares distributor path defaults alongside the
|
|
||||||
other report-owned behavior such as report ID, prompt ID, valid-period
|
|
||||||
resolution, module composition, comparison strategy, artifact group, and output
|
|
||||||
name.
|
|
||||||
|
|
||||||
`internal/config` owns per-report override loading and validation. Unknown
|
|
||||||
fields under report config continue to fail validation or YAML unmarshalling.
|
|
||||||
|
|
||||||
`internal/app` resolves bundle paths through one helper used by both
|
|
||||||
single-report notification and batch notification. That helper applies this
|
|
||||||
precedence:
|
|
||||||
|
|
||||||
1. `reports.<report>.distributor.path_templates`, when explicitly configured.
|
|
||||||
2. `report.Definition` distributor path defaults.
|
|
||||||
|
|
||||||
There is no third global fallback.
|
|
||||||
|
|
||||||
The distributor adapter API remains unchanged unless a separate adapter concern
|
|
||||||
requires it. It should still upload the explicit file mappings passed by the
|
|
||||||
app layer.
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
# Distributor Paths Implementation Plan
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document is the staged implementation plan for
|
|
||||||
[distributor-paths.md](distributor-paths.md). It is written for an LLM coding
|
|
||||||
agent that will implement the feature in order.
|
|
||||||
|
|
||||||
The feature is complete only when `notify.distributor.report_path_templates` is
|
|
||||||
removed, report definitions provide distributor path defaults, per-report
|
|
||||||
config overrides work, and single-report plus batch distributor notifications
|
|
||||||
resolve bundle paths through the same report-aware code path.
|
|
||||||
|
|
||||||
## Ground Rules
|
|
||||||
|
|
||||||
- Review `docs/policy/architecture.md`, `docs/policy/development.md`, and
|
|
||||||
`docs/policy/documentation.md` before editing code.
|
|
||||||
- Keep distributor package types confined to `internal/adapters/distributor`.
|
|
||||||
- Do not add a compatibility fallback for
|
|
||||||
`notify.distributor.report_path_templates`.
|
|
||||||
- Do not upload optional `--out` or `--out-dir` copies to distributor.
|
|
||||||
- Keep all non-roadmap documentation changes in the stage that implements the
|
|
||||||
behavior being documented.
|
|
||||||
- Prefer small helpers in existing packages over a new package unless a clear
|
|
||||||
boundary emerges during implementation.
|
|
||||||
|
|
||||||
## Decisions Applied
|
|
||||||
|
|
||||||
- Per-report override key: use
|
|
||||||
`reports.<report>.distributor.path_templates`.
|
|
||||||
- `three_day` and `weekend` get defaults now:
|
|
||||||
`three-day/{valid_start_date}/{run_id}.md`,
|
|
||||||
`three-day/{valid_start_date}/index.md`,
|
|
||||||
`weekend/{valid_start_date}/{run_id}.md`, and
|
|
||||||
`weekend/{valid_start_date}/index.md`.
|
|
||||||
- Initial `storm_id`: derive from the resolved valid period as
|
|
||||||
`{valid_start_stamp}-{valid_end_stamp}` in the effective report timezone.
|
|
||||||
This avoids adding new CLI or upstream API requirements while still producing
|
|
||||||
stable storm paths for the same storm window.
|
|
||||||
- Allow `storm_id` in all single-report distributor templates. It renders empty
|
|
||||||
for non-storm reports.
|
|
||||||
|
|
||||||
## Stage 1: Report Defaults
|
|
||||||
|
|
||||||
Goal: make distributor path defaults part of report definitions without
|
|
||||||
changing runtime behavior yet.
|
|
||||||
|
|
||||||
Code changes:
|
|
||||||
|
|
||||||
- Add `DistributorPathTemplates []string` to `internal/report.Definition`.
|
|
||||||
- Populate defaults in every generated report definition:
|
|
||||||
`daily`, `today`, `tomorrow`, `hourly`, `three_day`, `weekend`, and `storm`.
|
|
||||||
- Ensure registry cloning preserves `DistributorPathTemplates` when module
|
|
||||||
overrides are applied.
|
|
||||||
- Keep this field report-owned; do not reference distributor adapter types from
|
|
||||||
`internal/report`.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add focused report tests that every generated report has at least one
|
|
||||||
distributor path template.
|
|
||||||
- Add table coverage for the exact default templates listed in
|
|
||||||
`docs/roadmap/distributor-paths.md`.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/report
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Report definitions declare all default distributor bundle paths.
|
|
||||||
- No app behavior changes are required in this stage.
|
|
||||||
|
|
||||||
## Stage 2: Config Override Model
|
|
||||||
|
|
||||||
Goal: add per-report distributor path override configuration while preserving
|
|
||||||
the existing runtime path until the app layer is switched in Stage 3.
|
|
||||||
|
|
||||||
Code changes:
|
|
||||||
|
|
||||||
- Add `ReportDistributorConfig` under `internal/config`.
|
|
||||||
- Add `Distributor ReportDistributorConfig` to `ReportConfig`.
|
|
||||||
- Support only this YAML shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
daily:
|
|
||||||
distributor:
|
|
||||||
path_templates:
|
|
||||||
- "daily/{valid_start_date}/{run_id}.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
- Track whether `path_templates` was explicitly set so omitted overrides can
|
|
||||||
fall back to report definition defaults.
|
|
||||||
- Reject unknown fields under both `reports.<report>` and
|
|
||||||
`reports.<report>.distributor`.
|
|
||||||
- Add a config helper that returns normalized overrides by `report.ID`, reusing
|
|
||||||
`report.IDForConfigKey` and duplicate report-key detection.
|
|
||||||
- Validate configured path templates with the same parser and path safety rules
|
|
||||||
used for rendered distributor paths.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add config load/unmarshal tests for per-report distributor overrides.
|
|
||||||
- Add validation tests for unknown fields, duplicate report aliases, unknown
|
|
||||||
template variables, absolute paths, `..`, `manifest.json`, duplicate rendered
|
|
||||||
paths inside one report override, and an explicitly empty override list.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/config
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Config can express per-report distributor path overrides.
|
|
||||||
- Omitted overrides are distinguishable from explicit empty lists.
|
|
||||||
- No runtime notification path selection has been switched yet.
|
|
||||||
|
|
||||||
## Stage 3: Template Rendering
|
|
||||||
|
|
||||||
Goal: generalize distributor template rendering so it no longer hardcodes the
|
|
||||||
old global config field name and can render `storm_id`.
|
|
||||||
|
|
||||||
Code changes:
|
|
||||||
|
|
||||||
- Add `StormID string` to `config.DistributorTemplateValues`.
|
|
||||||
- Add `storm_id` to the allowed variables for single-report distributor
|
|
||||||
templates.
|
|
||||||
- Derive `StormID` in the app-layer template-value builder as
|
|
||||||
`{valid_start_stamp}-{valid_end_stamp}` for `report.Storm`; leave it empty
|
|
||||||
for other reports.
|
|
||||||
- Replace hardcoded error names such as
|
|
||||||
`notify.distributor.report_path_templates[0]` with caller-provided names such
|
|
||||||
as `reports.daily.distributor.path_templates[0]` or
|
|
||||||
`report.daily.distributor_path_templates[0]`.
|
|
||||||
- Keep rendered path validation in `internal/config` unless the implementation
|
|
||||||
reveals a cleaner existing boundary.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add rendering tests for `storm_id`.
|
|
||||||
- Update existing rendering tests so error messages reference the new caller
|
|
||||||
names rather than the removed global config field.
|
|
||||||
- Confirm duplicate path detection still reports the duplicate path.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/config ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Rendering supports all variables in the feature roadmap.
|
|
||||||
- Rendering helpers can be used for both defaults and per-report overrides
|
|
||||||
without naming errors after the removed global field.
|
|
||||||
|
|
||||||
## Stage 4: App Notification Path Resolver
|
|
||||||
|
|
||||||
Goal: switch single-report and batch distributor notification to the
|
|
||||||
report-specific path resolver.
|
|
||||||
|
|
||||||
Code changes:
|
|
||||||
|
|
||||||
- Add one app-layer helper used by both `buildNotificationRequest` and
|
|
||||||
`buildBatchNotificationRequest`.
|
|
||||||
- Helper precedence:
|
|
||||||
1. explicit `reports.<report>.distributor.path_templates`;
|
|
||||||
2. `resolved.Definition.DistributorPathTemplates`.
|
|
||||||
- Return an actionable error if a report has neither an override nor defaults.
|
|
||||||
- Preserve existing single-report identity rendering for `pipeline_id_template`,
|
|
||||||
`bundle_id_template`, and `idempotency_key_template`.
|
|
||||||
- Preserve existing batch identity rendering under `notify.distributor.batch`.
|
|
||||||
- Preserve batch duplicate detection across all rendered bundle paths before
|
|
||||||
calling distributor.
|
|
||||||
- Ensure errors include report ID, RunID, source path where available, and the
|
|
||||||
rendered bundle path when relevant.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Update single-report notification tests for default paths:
|
|
||||||
`hourly`, `daily`, `today`, `tomorrow`, `three_day`, `weekend`, and `storm`
|
|
||||||
where storm generation is currently testable.
|
|
||||||
- Add per-report override precedence tests.
|
|
||||||
- Add batch tests proving each included report uses its own defaults or
|
|
||||||
overrides.
|
|
||||||
- Keep or add a batch duplicate-path test. The current planned batches avoid
|
|
||||||
`today`/`tomorrow` collisions with future dated `daily` reports, but the
|
|
||||||
collision guard must remain explicit for future batch changes.
|
|
||||||
- Confirm notification source paths are still managed Markdown report paths,
|
|
||||||
not output copies.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Distributor upload requests contain report-specific bundle paths.
|
|
||||||
- Single-report and batch notifications use the same path resolution rules.
|
|
||||||
- No distributor adapter API change is required.
|
|
||||||
|
|
||||||
## Stage 5: Remove The Legacy Global Field
|
|
||||||
|
|
||||||
Goal: hard-remove `notify.distributor.report_path_templates` from the codebase.
|
|
||||||
|
|
||||||
Code changes:
|
|
||||||
|
|
||||||
- Remove `ReportPathTemplates` from `DistributorNotifyConfig`.
|
|
||||||
- Remove its default from `internal/config/defaults.go`.
|
|
||||||
- Remove validation that requires or renders the global field.
|
|
||||||
- Add or update `DistributorNotifyConfig.UnmarshalYAML` so unknown fields in
|
|
||||||
`notify.distributor` fail during config parsing. This must explicitly reject
|
|
||||||
the removed `report_path_templates` key instead of silently ignoring it.
|
|
||||||
- Update or remove tests that asserted the old global default.
|
|
||||||
- Search for and remove remaining code references:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg "ReportPathTemplates|report_path_templates"
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add or update config tests proving the global field is no longer accepted.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/config ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- The legacy global path field is gone from structs, defaults, validation,
|
|
||||||
examples, docs, and tests.
|
|
||||||
- There is no compatibility fallback.
|
|
||||||
|
|
||||||
## Stage 6: Documentation And Examples
|
|
||||||
|
|
||||||
Goal: move implemented behavior from roadmap-only docs into maintained user and
|
|
||||||
internal docs.
|
|
||||||
|
|
||||||
Documentation changes:
|
|
||||||
|
|
||||||
- Update `docs/config.md`:
|
|
||||||
- remove `notify.distributor.report_path_templates`;
|
|
||||||
- document `reports.<report>.distributor.path_templates`;
|
|
||||||
- document default path behavior and template variables;
|
|
||||||
- document `storm_id` derivation.
|
|
||||||
- Update `docs/internal/distributor-adapter.md` so it says the app layer
|
|
||||||
resolves report-specific path templates before calling the adapter.
|
|
||||||
- Update `examples/config.yml` to remove the old global field and optionally
|
|
||||||
include one concise per-report override example if useful.
|
|
||||||
- Do not add unimplemented behavior outside `docs/roadmap/`.
|
|
||||||
|
|
||||||
Tests and checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/config ./internal/app ./internal/adapters/distributor
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Completion criteria:
|
|
||||||
|
|
||||||
- Maintained docs and examples match implemented behavior.
|
|
||||||
- Example config still loads in the config test suite.
|
|
||||||
|
|
||||||
## Final Verification
|
|
||||||
|
|
||||||
Before considering the feature complete, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
rg "ReportPathTemplates|report_path_templates" --glob '!docs/roadmap/**'
|
|
||||||
```
|
|
||||||
|
|
||||||
The final `rg` should find no implemented-code, maintained-doc, or example
|
|
||||||
references to the removed global field.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. The prior roadmap questions are resolved above so the implementation can
|
|
||||||
proceed without additional product decisions.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -164,8 +164,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
|||||||
|
|
||||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
PromptID: "weather.hourly_generated_text",
|
PromptID: "weather.hourly_generated_text",
|
||||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
DataPackagePath: "/tmp/data_package.hourly.yaml",
|
||||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
OutputPath: "/tmp/generated_text_raw.hourly.json",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StructuredRun() error = %v", err)
|
t.Fatalf("StructuredRun() error = %v", err)
|
||||||
@@ -176,8 +176,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
|||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.hourly_generated_text",
|
"--prompt", "weather.hourly_generated_text",
|
||||||
"--input", "data_package=/tmp/hourly.data_package.yaml",
|
"--input", "data_package=/tmp/data_package.hourly.yaml",
|
||||||
"--out", "/tmp/hourly.generated_text.raw.json",
|
"--out", "/tmp/generated_text_raw.hourly.json",
|
||||||
}
|
}
|
||||||
if commands.name != "/usr/local/bin/scriptorium" {
|
if commands.name != "/usr/local/bin/scriptorium" {
|
||||||
t.Fatalf("command name = %q, want custom binary", commands.name)
|
t.Fatalf("command name = %q, want custom binary", commands.name)
|
||||||
@@ -199,7 +199,7 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
|||||||
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
|
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
|
||||||
t.Fatalf("result = %#v, want captured output and truncation flags", result)
|
t.Fatalf("result = %#v, want captured output and truncation flags", result)
|
||||||
}
|
}
|
||||||
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
if result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
|
||||||
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
|
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,8 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
|||||||
|
|
||||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
PromptID: "weather.hourly_generated_text",
|
PromptID: "weather.hourly_generated_text",
|
||||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
DataPackagePath: "/tmp/data_package.hourly.yaml",
|
||||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
OutputPath: "/tmp/generated_text_raw.hourly.json",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
||||||
@@ -229,7 +229,7 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
|||||||
if result.ExitCode != 3 {
|
if result.ExitCode != 3 {
|
||||||
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
|
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
|
||||||
}
|
}
|
||||||
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
|
||||||
t.Fatalf("result = %#v, want captured result fields", result)
|
t.Fatalf("result = %#v, want captured result fields", result)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "structured output failed") {
|
if !strings.Contains(err.Error(), "structured output failed") {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if finalizeResultEmpty(finalized) {
|
||||||
return nil, err
|
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 {
|
||||||
|
if finalizeResultEmpty(finalized) {
|
||||||
return nil, err
|
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)
|
||||||
@@ -255,7 +346,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read data package: %v", err)
|
t.Fatalf("read data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
|
if !strings.HasPrefix(filepath.Base(result.DataPackagePath), "data_package.") || !strings.HasSuffix(result.DataPackagePath, ".yaml") {
|
||||||
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
|
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") ||
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") ||
|
||||||
@@ -357,8 +448,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
||||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
if !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||||
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
t.Fatalf("data package missing daily long-term AFD discussion:\n%s", string(data))
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"Short-term AFD narrative for generated report.", "Storms are most likely during the morning."} {
|
||||||
|
if strings.Contains(string(data), omitted) {
|
||||||
|
t.Fatalf("data package contains daily omitted AFD field %q:\n%s", omitted, string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
preflight, err := os.ReadFile(result.PreflightPath)
|
preflight, err := os.ReadFile(result.PreflightPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2548,12 +2644,12 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
|
|||||||
PipelineID: "weatherreporter",
|
PipelineID: "weatherreporter",
|
||||||
BundleID: "weatherreporter.home.evening",
|
BundleID: "weatherreporter.home.evening",
|
||||||
IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening",
|
IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening",
|
||||||
Path: "notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json",
|
Path: "notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json",
|
||||||
IncludedReports: []BatchNotificationReport{
|
IncludedReports: []BatchNotificationReport{
|
||||||
{
|
{
|
||||||
ReportID: report.Tomorrow,
|
ReportID: report.Tomorrow,
|
||||||
RunID: "20260529T233000.000000000Z_tomorrow",
|
RunID: "20260529T233000.000000000Z_tomorrow",
|
||||||
SourcePath: "reports/tomorrow.md",
|
SourcePath: "reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md",
|
||||||
BundlePaths: []string{"tomorrow/index.md"},
|
BundlePaths: []string{"tomorrow/index.md"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2573,10 +2669,10 @@ func TestBatchResultJSONIncludesNotification(t *testing.T) {
|
|||||||
`"pipelineId":"weatherreporter"`,
|
`"pipelineId":"weatherreporter"`,
|
||||||
`"bundleId":"weatherreporter.home.evening"`,
|
`"bundleId":"weatherreporter.home.evening"`,
|
||||||
`"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`,
|
`"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`,
|
||||||
`"path":"notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json"`,
|
`"path":"notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json"`,
|
||||||
`"includedReports":[`,
|
`"includedReports":[`,
|
||||||
`"reportId":"tomorrow"`,
|
`"reportId":"tomorrow"`,
|
||||||
`"sourcePath":"reports/tomorrow.md"`,
|
`"sourcePath":"reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md"`,
|
||||||
`"bundlePaths":["tomorrow/index.md"]`,
|
`"bundlePaths":["tomorrow/index.md"]`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(data), want) {
|
if !strings.Contains(string(data), want) {
|
||||||
@@ -3346,15 +3442,22 @@ func plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch Batc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("planBatchRun() error = %v", err)
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
}
|
}
|
||||||
reportDir := filepath.Join(t.TempDir(), "managed-reports")
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
results := make([]BatchReportResult, 0, len(planned))
|
results := make([]BatchReportResult, 0, len(planned))
|
||||||
for _, item := range planned {
|
for _, item := range planned {
|
||||||
metadata := item.Resolved.Metadata()
|
metadata := item.Resolved.Metadata()
|
||||||
|
paths, err := store.Paths(item.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths(%s) error = %v", item.Resolved.Definition.ID, err)
|
||||||
|
}
|
||||||
results = append(results, BatchReportResult{
|
results = append(results, BatchReportResult{
|
||||||
ReportID: item.Resolved.Definition.ID,
|
ReportID: item.Resolved.Definition.ID,
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
Status: "succeeded",
|
Status: "succeeded",
|
||||||
ReportPath: filepath.Join(reportDir, string(item.Resolved.Definition.ID), metadata.RunID+".md"),
|
ReportPath: paths.RenderedReport,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return planned, results
|
return planned, results
|
||||||
@@ -3489,6 +3592,66 @@ func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.
|
|||||||
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
|
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fakeScriptoriumBinary(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "scriptorium")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
command_name="${1:-}"
|
||||||
|
shift || true
|
||||||
|
prompt=""
|
||||||
|
output=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--prompt)
|
||||||
|
shift
|
||||||
|
prompt="${1:-}"
|
||||||
|
;;
|
||||||
|
--out)
|
||||||
|
shift
|
||||||
|
output="${1:-}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift || true
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$command_name" in
|
||||||
|
render)
|
||||||
|
printf '{"prepared":true}\n'
|
||||||
|
;;
|
||||||
|
run)
|
||||||
|
if [ -z "$output" ]; then
|
||||||
|
printf 'missing output path\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
case "$prompt" in
|
||||||
|
weather.hourly_generated_text)
|
||||||
|
cat > "$output" <<'EOF'
|
||||||
|
{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
cat > "$output" <<'EOF'
|
||||||
|
# Generated Report
|
||||||
|
|
||||||
|
Prepared report body.
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'unknown command: %s\n' "$command_name" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake scriptorium binary: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func validHourlyGeneratedTextJSON() string {
|
func validHourlyGeneratedTextJSON() string {
|
||||||
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +263,19 @@ func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
||||||
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
tests := []struct {
|
||||||
|
id report.ID
|
||||||
|
wantSections string
|
||||||
|
}{
|
||||||
|
{id: report.Daily, wantSections: "long_term"},
|
||||||
|
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := report.DefaultRegistry()
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
|
definition := registry.MustLookup(tt.id)
|
||||||
var found bool
|
var found bool
|
||||||
for _, item := range definition.Modules {
|
for _, item := range definition.Modules {
|
||||||
if item.ID != module.AreaForecastDiscussion {
|
if item.ID != module.AreaForecastDiscussion {
|
||||||
@@ -275,12 +286,33 @@ func TestHourlyDefaultModuleOptions(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||||
}
|
}
|
||||||
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
|
if strings.Join(options.Sections, ",") != tt.wantSections {
|
||||||
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("hourly default modules missing area_forecast_discussion")
|
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)
|
||||||
@@ -80,7 +86,7 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
assertFileContains(t, outPath, "# Daily Report")
|
assertFileContains(t, outPath, "# Daily Report")
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "data_package.*.yaml")
|
||||||
assertFileContains(t, dataPackagePath, "id: storm")
|
assertFileContains(t, dataPackagePath, "id: storm")
|
||||||
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
|
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
|
||||||
}
|
}
|
||||||
@@ -99,10 +105,10 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
assertFileContains(t, outPath, "# Saturday's Weather")
|
assertFileContains(t, outPath, "# Saturday's Weather")
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||||
assertFileContains(t, dataPackagePath, "id: tomorrow")
|
assertFileContains(t, dataPackagePath, "id: tomorrow")
|
||||||
assertFileContains(t, dataPackagePath, "tomorrow_planning:")
|
assertFileContains(t, dataPackagePath, "tomorrow_planning:")
|
||||||
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md")
|
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
|
||||||
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
||||||
t.Fatalf("managed report = %q, want tomorrow report", reportPath)
|
t.Fatalf("managed report = %q, want tomorrow report", reportPath)
|
||||||
}
|
}
|
||||||
@@ -119,8 +125,8 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||||
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md")
|
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
|
||||||
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
||||||
t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
|
t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
|
||||||
}
|
}
|
||||||
@@ -131,7 +137,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|||||||
outPath := fixture.path("three-day.md")
|
outPath := fixture.path("three-day.md")
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
_, err := runTestCommand(t, runner,
|
output, err := runTestCommand(t, runner,
|
||||||
"generate", "three-day",
|
"generate", "three-day",
|
||||||
"--config", fixture.configPath,
|
"--config", fixture.configPath,
|
||||||
"--out", outPath,
|
"--out", outPath,
|
||||||
@@ -140,9 +146,23 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
assertFileContains(t, outPath, "# Daily Report")
|
assertFileContains(t, outPath, "# Daily Report")
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
|
||||||
assertFileContains(t, dataPackagePath, "id: three_day")
|
assertFileContains(t, dataPackagePath, "id: three_day")
|
||||||
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
||||||
|
|
||||||
|
summary := decodeGenerateSummary(t, output.stdout)
|
||||||
|
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.ThreeDay {
|
||||||
|
t.Fatalf("generate summary = %#v, want successful 3-day summary", summary)
|
||||||
|
}
|
||||||
|
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
|
||||||
|
t.Fatalf("summary paths = %#v, want managed artifact paths", summary)
|
||||||
|
}
|
||||||
|
if summary.OutputPath != outPath {
|
||||||
|
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
|
||||||
|
}
|
||||||
|
if summary.GeneratedTextRawPath != "" || summary.GeneratedTextResultPath != "" || summary.GeneratedTextPath != "" || summary.RenderContextPath != "" {
|
||||||
|
t.Fatalf("generated-text paths = %#v, want omitted for markdown report", summary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
||||||
@@ -159,7 +179,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
assertFileContains(t, outPath, "# Daily Report")
|
assertFileContains(t, outPath, "# Daily Report")
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
|
||||||
assertFileContains(t, dataPackagePath, "id: weekend")
|
assertFileContains(t, dataPackagePath, "id: weekend")
|
||||||
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
||||||
}
|
}
|
||||||
@@ -175,11 +195,11 @@ func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||||
@@ -197,9 +217,9 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
|
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary app.BatchResult
|
summary := decodeBatchSummary(t, output.stdout)
|
||||||
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
if summary.Command != "run" || summary.Status != "failed" {
|
||||||
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status)
|
||||||
}
|
}
|
||||||
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
|
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
|
||||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
|
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
|
||||||
@@ -207,8 +227,8 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
|||||||
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
||||||
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
|
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
|
||||||
}
|
}
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
|
func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
|
||||||
@@ -223,7 +243,7 @@ func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
|
|||||||
PipelineID: "weatherreporter",
|
PipelineID: "weatherreporter",
|
||||||
BundleID: "weatherreporter.home.morning",
|
BundleID: "weatherreporter.home.morning",
|
||||||
IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning",
|
IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning",
|
||||||
Path: "/tmp/batch.distributor.json",
|
Path: "/tmp/distributor.batch.json",
|
||||||
IncludedReports: []app.BatchNotificationReport{
|
IncludedReports: []app.BatchNotificationReport{
|
||||||
{ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}},
|
{ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}},
|
||||||
},
|
},
|
||||||
@@ -247,7 +267,7 @@ func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
|
|||||||
if err := writeJSON(&stdout, result); err != nil {
|
if err := writeJSON(&stdout, result); err != nil {
|
||||||
t.Fatalf("writeJSON() error = %v", err)
|
t.Fatalf("writeJSON() error = %v", err)
|
||||||
}
|
}
|
||||||
writeRunLogs(&stderr, result)
|
writeBatchStatus(&stderr, result)
|
||||||
|
|
||||||
var decoded app.BatchResult
|
var decoded app.BatchResult
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
@@ -294,7 +314,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
|||||||
if err := writeJSON(&stdout, result); err != nil {
|
if err := writeJSON(&stdout, result); err != nil {
|
||||||
t.Fatalf("writeJSON() error = %v", err)
|
t.Fatalf("writeJSON() error = %v", err)
|
||||||
}
|
}
|
||||||
writeRunLogs(&stderr, result)
|
writeBatchStatus(&stderr, result)
|
||||||
|
|
||||||
for _, output := range []string{stdout.String(), stderr.String()} {
|
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||||
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
|
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
|
||||||
@@ -306,7 +326,7 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
|
func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) {
|
||||||
result := &app.BatchResult{
|
result := &app.BatchResult{
|
||||||
Batch: app.BatchMorning,
|
Batch: app.BatchMorning,
|
||||||
Total: 2,
|
Total: 2,
|
||||||
@@ -327,7 +347,7 @@ func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
|
|||||||
if err := writeJSON(&stdout, result); err != nil {
|
if err := writeJSON(&stdout, result); err != nil {
|
||||||
t.Fatalf("writeJSON() error = %v", err)
|
t.Fatalf("writeJSON() error = %v", err)
|
||||||
}
|
}
|
||||||
writeRunLogs(&stderr, result)
|
writeBatchStatus(&stderr, result)
|
||||||
|
|
||||||
var decoded app.BatchResult
|
var decoded app.BatchResult
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
@@ -344,7 +364,7 @@ func TestRunLogsIncludeSkippedBatchNotification(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
|
func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
|
||||||
result := &app.BatchResult{
|
result := &app.BatchResult{
|
||||||
Batch: app.BatchEvening,
|
Batch: app.BatchEvening,
|
||||||
Total: 1,
|
Total: 1,
|
||||||
@@ -360,7 +380,7 @@ func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
writeRunLogs(&stderr, result)
|
writeBatchStatus(&stderr, result)
|
||||||
|
|
||||||
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
|
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
|
||||||
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
|
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
|
||||||
@@ -387,9 +407,9 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
var summary app.BatchResult
|
summary := decodeBatchSummary(t, output.stdout)
|
||||||
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
if summary.Command != "run" || summary.Status != "succeeded" {
|
||||||
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
|
||||||
}
|
}
|
||||||
if summary.Total != 1 || summary.Failed != 0 {
|
if summary.Total != 1 || summary.Failed != 0 {
|
||||||
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
|
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
|
||||||
@@ -402,6 +422,24 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) {
|
||||||
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
output, err := runTestCommand(t, runner,
|
||||||
|
"run", "evening",
|
||||||
|
"--config", fixture.configPath,
|
||||||
|
"--quiet",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if output.stdout != "" || output.stderr != "" {
|
||||||
|
t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr)
|
||||||
|
}
|
||||||
|
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
|
func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
var uploadCount int
|
var uploadCount int
|
||||||
@@ -437,9 +475,9 @@ func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary app.BatchResult
|
summary := decodeBatchSummary(t, stdout.String())
|
||||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
if summary.Command != "run" || summary.Status != "succeeded" {
|
||||||
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
|
||||||
}
|
}
|
||||||
if len(summary.Reports) != 1 {
|
if len(summary.Reports) != 1 {
|
||||||
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
||||||
@@ -489,9 +527,9 @@ func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T)
|
|||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var summary app.BatchResult
|
summary := decodeBatchSummary(t, stdout.String())
|
||||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
if summary.Command != "run" || summary.Status != "succeeded" {
|
||||||
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
|
||||||
}
|
}
|
||||||
if len(summary.Reports) != 1 {
|
if len(summary.Reports) != 1 {
|
||||||
t.Fatalf("summary reports = %#v, want one report", summary.Reports)
|
t.Fatalf("summary reports = %#v, want one report", summary.Reports)
|
||||||
@@ -517,11 +555,11 @@ func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml")
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "*.data_package.yaml")
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml")
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "data_package.*.yaml")
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "data_package.*.yaml")
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "data_package.*.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||||
@@ -541,14 +579,14 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
report, err := os.ReadFile(outPath)
|
reportData, err := os.ReadFile(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(report), "# Friday's Weather") {
|
if !strings.Contains(string(reportData), "# Friday's Weather") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(reportData))
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
|
||||||
data, err := os.ReadFile(dataPackagePath)
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
@@ -563,7 +601,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
!strings.Contains(string(data), "timezone: UTC") {
|
!strings.Contains(string(data), "timezone: UTC") {
|
||||||
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
|
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
|
||||||
}
|
}
|
||||||
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")
|
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "render.*.json")
|
||||||
preflight, err := os.ReadFile(preflightPath)
|
preflight, err := os.ReadFile(preflightPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read preflight: %v", err)
|
t.Fatalf("read preflight: %v", err)
|
||||||
@@ -571,11 +609,11 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(preflight), `ok`) {
|
if !strings.Contains(string(preflight), `ok`) {
|
||||||
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
|
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
|
||||||
}
|
}
|
||||||
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "*.md")
|
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md")
|
||||||
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json")
|
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json")
|
||||||
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.json")
|
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text.*.json")
|
||||||
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.render_context.json")
|
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "render_context.*.json")
|
||||||
metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json")
|
metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "metadata.*.json")
|
||||||
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
|
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
|
||||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
|
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
|
||||||
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
|
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
|
||||||
@@ -598,7 +636,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
report, err := os.ReadFile(outPath)
|
reportData, err := os.ReadFile(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
}
|
}
|
||||||
@@ -607,11 +645,11 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
|||||||
"Today starts with showers before improving.",
|
"Today starts with showers before improving.",
|
||||||
"Morning showers should taper as drier air arrives.",
|
"Morning showers should taper as drier air arrives.",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(report), want) {
|
if !strings.Contains(string(reportData), want) {
|
||||||
t.Fatalf("today report output missing %q:\n%s", want, string(report))
|
t.Fatalf("today report output missing %q:\n%s", want, string(reportData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||||
dataPackage, err := os.ReadFile(dataPackagePath)
|
dataPackage, err := os.ReadFile(dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
@@ -621,15 +659,29 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
|||||||
!strings.Contains(string(dataPackage), "today_planning:") {
|
!strings.Contains(string(dataPackage), "today_planning:") {
|
||||||
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
|
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
|
||||||
}
|
}
|
||||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
|
||||||
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json")
|
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text_raw.*.json")
|
||||||
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json")
|
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text.*.json")
|
||||||
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json")
|
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "render_context.*.json")
|
||||||
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "*.md")
|
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "2026-05-29", "report.*.md")
|
||||||
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
|
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
|
||||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
|
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
|
||||||
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
|
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
|
||||||
assertFileContains(t, managedReportPath, "# Today's Weather")
|
assertFileContains(t, managedReportPath, "# Today's Weather")
|
||||||
|
|
||||||
|
summary := decodeGenerateSummary(t, stdout.String())
|
||||||
|
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today {
|
||||||
|
t.Fatalf("generate summary = %#v, want successful Today summary", summary)
|
||||||
|
}
|
||||||
|
if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
|
||||||
|
t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary)
|
||||||
|
}
|
||||||
|
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||||
|
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||||
|
}
|
||||||
|
if summary.OutputPath != outPath {
|
||||||
|
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||||
@@ -647,7 +699,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
report, err := os.ReadFile(outPath)
|
reportData, err := os.ReadFile(outPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
}
|
}
|
||||||
@@ -657,11 +709,11 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
|||||||
"A cold front is moving into the region.",
|
"A cold front is moving into the region.",
|
||||||
"A front will keep the region unsettled.",
|
"A front will keep the region unsettled.",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(report), want) {
|
if !strings.Contains(string(reportData), want) {
|
||||||
t.Fatalf("report output missing %q:\n%s", want, string(report))
|
t.Fatalf("report output missing %q:\n%s", want, string(reportData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "data_package.*.yaml")
|
||||||
dataPackage, err := os.ReadFile(dataPackagePath)
|
dataPackage, err := os.ReadFile(dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
@@ -671,10 +723,10 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
|||||||
!strings.Contains(string(dataPackage), "hourly_forecast:") {
|
!strings.Contains(string(dataPackage), "hourly_forecast:") {
|
||||||
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
|
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
|
||||||
}
|
}
|
||||||
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
|
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text_raw.*.json")
|
||||||
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
|
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json")
|
||||||
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
|
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json")
|
||||||
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "*.md")
|
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md")
|
||||||
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
||||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
||||||
assertFileContains(t, renderContextPath, `"Report": {`)
|
assertFileContains(t, renderContextPath, `"Report": {`)
|
||||||
@@ -685,6 +737,76 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
|||||||
assertFileContains(t, managedReportPath, "# Hourly Report")
|
assertFileContains(t, managedReportPath, "# Hourly Report")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
|
||||||
|
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
|
||||||
|
outPath := fixture.path("today.md")
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
output, err := runTestCommand(t, runner,
|
||||||
|
"generate", "today",
|
||||||
|
"--config", fixture.configPath,
|
||||||
|
"--date", "2026-05-29",
|
||||||
|
"--out", outPath,
|
||||||
|
"--quiet",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if output.stdout != "" || output.stderr != "" {
|
||||||
|
t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr)
|
||||||
|
}
|
||||||
|
assertFileContains(t, outPath, "# Today's Weather")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want required date error")
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want no partial JSON", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
|
||||||
|
server := dailyServer(t)
|
||||||
|
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("unexpected distributor request with unset token: %s", r.URL.Path)
|
||||||
|
}))
|
||||||
|
t.Cleanup(distributorServer.Close)
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||||
|
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
|
||||||
|
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "")
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
output, err := runTestCommand(t, runner,
|
||||||
|
"generate", "three-day",
|
||||||
|
"--config", configPath,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want notification failure")
|
||||||
|
}
|
||||||
|
summary := decodeGenerateSummary(t, output.stdout)
|
||||||
|
if summary.Command != "generate" || summary.Status != "failed" || summary.Error == "" {
|
||||||
|
t.Fatalf("summary = %#v, want failed generate summary", summary)
|
||||||
|
}
|
||||||
|
if !strings.Contains(summary.Error, "token environment variable") {
|
||||||
|
t.Fatalf("summary error = %q, want token environment context", summary.Error)
|
||||||
|
}
|
||||||
|
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.NotificationPath == "" {
|
||||||
|
t.Fatalf("summary paths = %#v, want inspectable report, metadata, and notification paths", summary)
|
||||||
|
}
|
||||||
|
if strings.Contains(output.stdout, "CLI_DISTRIBUTOR_TOKEN_VALUE") || strings.Contains(output.stderr, "CLI_DISTRIBUTOR_TOKEN_VALUE") {
|
||||||
|
t.Fatalf("output contains distributor token value\nstdout=%s\nstderr=%s", output.stdout, output.stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunInspectTodayArtifacts(t *testing.T) {
|
func TestRunInspectTodayArtifacts(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, writeFakeScriptorium)
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
@@ -699,8 +821,8 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run(generate) error = %v", err)
|
t.Fatalf("Run(generate) error = %v", err)
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
|
||||||
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
|
runID := runIDFromDataPackagePath(t, dataPackagePath)
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
|
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
|
||||||
@@ -744,8 +866,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run(generate) error = %v", err)
|
t.Fatalf("Run(generate) error = %v", err)
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
|
||||||
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
|
runID := runIDFromDataPackagePath(t, dataPackagePath)
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
|
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr)
|
||||||
@@ -791,6 +913,22 @@ func TestRunInspectMissingMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunInspectRejectsQuiet(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run(inspect reports --quiet) error = nil, want unexpected flag error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
||||||
|
t.Fatalf("error = %q, want unexpected quiet flag", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
|
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
|
||||||
@@ -1179,6 +1317,24 @@ func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput,
|
|||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeGenerateSummary(t *testing.T, text string) generateSummary {
|
||||||
|
t.Helper()
|
||||||
|
var summary generateSummary
|
||||||
|
if err := json.Unmarshal([]byte(text), &summary); err != nil {
|
||||||
|
t.Fatalf("decode generate summary: %v\n%s", err, text)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBatchSummary(t *testing.T, text string) batchSummary {
|
||||||
|
t.Helper()
|
||||||
|
var summary batchSummary
|
||||||
|
if err := json.Unmarshal([]byte(text), &summary); err != nil {
|
||||||
|
t.Fatalf("decode batch summary: %v\n%s", err, text)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
func dailyServer(t *testing.T) *httptest.Server {
|
func dailyServer(t *testing.T) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1286,6 +1442,16 @@ func noArtifacts(t *testing.T, root string, parts ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runIDFromDataPackagePath(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
base := filepath.Base(path)
|
||||||
|
runID := strings.TrimSuffix(strings.TrimPrefix(base, "data_package."), ".yaml")
|
||||||
|
if runID == base || runID == "" {
|
||||||
|
t.Fatalf("data package path = %q, want data_package.<run_id>.yaml", path)
|
||||||
|
}
|
||||||
|
return runID
|
||||||
|
}
|
||||||
|
|
||||||
func firstLineWithPrefix(text string, prefix string) string {
|
func firstLineWithPrefix(text string, prefix string) string {
|
||||||
for _, line := range strings.Split(text, "\n") {
|
for _, line := range strings.Split(text, "\n") {
|
||||||
if strings.HasPrefix(line, prefix) {
|
if strings.HasPrefix(line, prefix) {
|
||||||
|
|||||||
@@ -202,6 +202,37 @@ func (c *DistributorNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DistributorBatchNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
if value.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("notify distributor batch entry must be a mapping")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(value.Content); i += 2 {
|
||||||
|
key := value.Content[i].Value
|
||||||
|
node := value.Content[i+1]
|
||||||
|
switch key {
|
||||||
|
case "enabled":
|
||||||
|
if err := node.Decode(&c.Enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "pipeline_id_template":
|
||||||
|
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "bundle_id_template":
|
||||||
|
if err := node.Decode(&c.BundleIDTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "idempotency_key_template":
|
||||||
|
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown notify distributor batch field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
|
func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||||
if value.Kind != yaml.MappingNode {
|
if value.Kind != yaml.MappingNode {
|
||||||
return fmt.Errorf("report distributor entry must be a mapping")
|
return fmt.Errorf("report distributor entry must be a mapping")
|
||||||
|
|||||||
@@ -751,6 +751,17 @@ reports:
|
|||||||
`,
|
`,
|
||||||
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
|
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "NonStormStormIDEmptyPathSegment",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
distributor:
|
||||||
|
path_templates:
|
||||||
|
- "daily/{storm_id}/index.md"
|
||||||
|
`,
|
||||||
|
wantErr: "reports.daily.distributor.path_templates[0] must not render empty path segments",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "EmptyOverrideList",
|
name: "EmptyOverrideList",
|
||||||
yaml: `
|
yaml: `
|
||||||
@@ -776,6 +787,23 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) {
|
||||||
|
_, err := LoadFile(writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
distributor:
|
||||||
|
path_templates:
|
||||||
|
- "daily/storm-{storm_id}.md"
|
||||||
|
storm:
|
||||||
|
distributor:
|
||||||
|
path_templates:
|
||||||
|
- "storm/{storm_id}/index.md"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
|
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
|
||||||
yaml := `
|
yaml := `
|
||||||
reports:
|
reports:
|
||||||
@@ -1087,6 +1115,46 @@ notify:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDistributorBatchNotifyRejectsUnknownFields(t *testing.T) {
|
||||||
|
_, err := LoadFile(writeConfig(t, `
|
||||||
|
notify:
|
||||||
|
distributor:
|
||||||
|
batch:
|
||||||
|
paths:
|
||||||
|
- index.md
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LoadFile() error = nil, want unknown distributor batch field error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `unknown notify distributor batch field "paths"`) {
|
||||||
|
t.Fatalf("error = %q, want unknown batch field rejection", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributorBatchNotifyPartialConfigPreservesDefaults(t *testing.T) {
|
||||||
|
cfg, err := LoadFile(writeConfig(t, `
|
||||||
|
notify:
|
||||||
|
distributor:
|
||||||
|
batch:
|
||||||
|
enabled: false
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Batch.Enabled {
|
||||||
|
t.Fatalf("Batch.Enabled = true, want false")
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
|
||||||
|
t.Fatalf("Batch.PipelineIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
|
||||||
|
t.Fatalf("Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
|
||||||
|
t.Fatalf("Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
|
func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
|
||||||
cfg := Defaults()
|
cfg := Defaults()
|
||||||
cfg.Notify.Distributor.Enabled = false
|
cfg.Notify.Distributor.Enabled = false
|
||||||
@@ -1509,7 +1577,7 @@ func TestDistributorReportPathValidation(t *testing.T) {
|
|||||||
{name: "ParentSegment", path: "reports/../daily.md", ok: false},
|
{name: "ParentSegment", path: "reports/../daily.md", ok: false},
|
||||||
{name: "EmptySegment", path: "reports//daily.md", ok: false},
|
{name: "EmptySegment", path: "reports//daily.md", ok: false},
|
||||||
{name: "Manifest", path: "reports/manifest.json", ok: false},
|
{name: "Manifest", path: "reports/manifest.json", ok: false},
|
||||||
{name: "DistributorMetadata", path: "reports/.distributor.json", ok: false},
|
{name: "DistributorMetadata", path: "reports/" + distributorSidecarBasename(), ok: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -1536,7 +1604,7 @@ func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
|
|||||||
{name: "ParentSegment", batchOutputName: "../daily.md"},
|
{name: "ParentSegment", batchOutputName: "../daily.md"},
|
||||||
{name: "EmptySegment", batchOutputName: "reports//daily.md"},
|
{name: "EmptySegment", batchOutputName: "reports//daily.md"},
|
||||||
{name: "Manifest", batchOutputName: "manifest.json"},
|
{name: "Manifest", batchOutputName: "manifest.json"},
|
||||||
{name: "DistributorMetadata", batchOutputName: ".distributor.json"},
|
{name: "DistributorMetadata", batchOutputName: distributorSidecarBasename()},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func ValidateDistributorReportPath(name, path string) error {
|
|||||||
if segment == "." || segment == ".." {
|
if segment == "." || segment == ".." {
|
||||||
return fmt.Errorf("%s must not render . or .. path segments", name)
|
return fmt.Errorf("%s must not render . or .. path segments", name)
|
||||||
}
|
}
|
||||||
if segment == "manifest.json" || segment == ".distributor.json" {
|
if segment == "manifest.json" || segment == distributorSidecarBasename() {
|
||||||
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
|
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +299,10 @@ func ValidateDistributorReportPath(name, path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func distributorSidecarBasename() string {
|
||||||
|
return "." + "distributor.json"
|
||||||
|
}
|
||||||
|
|
||||||
func isDistributorAbsolutePath(path string) bool {
|
func isDistributorAbsolutePath(path string) bool {
|
||||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string,
|
|||||||
if !reportCfg.Distributor.pathTemplatesSet {
|
if !reportCfg.Distributor.pathTemplatesSet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := validateReportDistributorPathTemplates(key, reportCfg.Distributor.PathTemplates); err != nil {
|
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...)
|
overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...)
|
||||||
@@ -121,13 +121,21 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string,
|
|||||||
return overrides, nil
|
return overrides, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateReportDistributorPathTemplates(reportKey string, templates []string) error {
|
func validateReportDistributorPathTemplates(reportKey string, reportID report.ID, templates []string) error {
|
||||||
name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey)
|
name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey)
|
||||||
_, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValues())
|
_, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValuesForReport(reportID))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func sampleDistributorTemplateValues() DistributorTemplateValues {
|
func sampleDistributorTemplateValues() DistributorTemplateValues {
|
||||||
|
return sampleDistributorTemplateValuesForReport(report.Storm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues {
|
||||||
|
stormID := ""
|
||||||
|
if reportID == report.Storm {
|
||||||
|
stormID = "2026-05-29T0000-2026-05-30T0000"
|
||||||
|
}
|
||||||
return DistributorTemplateValues{
|
return DistributorTemplateValues{
|
||||||
LocationID: "location",
|
LocationID: "location",
|
||||||
ReportID: "report",
|
ReportID: "report",
|
||||||
@@ -140,7 +148,7 @@ func sampleDistributorTemplateValues() DistributorTemplateValues {
|
|||||||
ValidEndTime: "0000",
|
ValidEndTime: "0000",
|
||||||
ValidStartStamp: "2026-05-29T0000",
|
ValidStartStamp: "2026-05-29T0000",
|
||||||
ValidEndStamp: "2026-05-30T0000",
|
ValidEndStamp: "2026-05-30T0000",
|
||||||
StormID: "2026-05-29T0000-2026-05-30T0000",
|
StormID: stormID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -83,26 +83,25 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
|||||||
return ArtifactPaths{}, fmt.Errorf("state store is required")
|
return ArtifactPaths{}, fmt.Errorf("state store is required")
|
||||||
}
|
}
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
if metadata.RunID == "" {
|
if err := validatePathSegment("run id", metadata.RunID); err != nil {
|
||||||
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
return ArtifactPaths{}, err
|
||||||
}
|
}
|
||||||
group := resolved.Definition.ArtifactGroup
|
group := resolved.Definition.ArtifactGroup
|
||||||
if group == "" {
|
if group == "" {
|
||||||
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
filenameBase := metadata.RunID
|
|
||||||
return ArtifactPaths{
|
return ArtifactPaths{
|
||||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"),
|
||||||
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"),
|
||||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"),
|
||||||
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
|
Preflight: s.join(s.preflightDir, group, validDate, "render."+metadata.RunID+".json"),
|
||||||
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"),
|
||||||
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
|
RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"),
|
||||||
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"),
|
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"),
|
||||||
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"),
|
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, "generated_text_result."+metadata.RunID+".json"),
|
||||||
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"),
|
GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"),
|
||||||
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"),
|
RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +148,7 @@ func (s *FilesystemStore) BatchDistributorNotificationPath(ref BatchDistributorN
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02")
|
localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02")
|
||||||
return s.join(s.notificationsDir, "batches", ref.Batch, localDate, ref.BatchRunID+".distributor.json"), nil
|
return s.join(s.notificationsDir, "batches", ref.Batch, localDate, "distributor."+ref.BatchRunID+".json"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) SaveBatchDistributorNotification(_ context.Context, ref BatchDistributorNotificationRef, artifact BatchDistributorNotificationArtifact) (string, error) {
|
func (s *FilesystemStore) SaveBatchDistributorNotification(_ context.Context, ref BatchDistributorNotificationRef, artifact BatchDistributorNotificationArtifact) (string, error) {
|
||||||
@@ -272,7 +271,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
|||||||
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
|
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
if entry.IsDir() || !isMetadataFilename(entry.Name()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, entry.Name())
|
path := filepath.Join(dir, entry.Name())
|
||||||
@@ -323,7 +322,7 @@ func (s *FilesystemStore) ListReports(_ context.Context, limit int) ([]ReportRec
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect %q: %w", path, err)
|
return fmt.Errorf("inspect %q: %w", path, err)
|
||||||
}
|
}
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
if entry.IsDir() || !isMetadataFilename(entry.Name()) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
record, err := s.reportRecord(path)
|
record, err := s.reportRecord(path)
|
||||||
@@ -517,6 +516,14 @@ func validatePathSegment(name string, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isMetadataFilename(name string) bool {
|
||||||
|
if !strings.HasPrefix(name, "metadata.") || !strings.HasSuffix(name, ".json") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runID := strings.TrimSuffix(strings.TrimPrefix(name, "metadata."), ".json")
|
||||||
|
return strings.TrimSpace(runID) != "" && !strings.ContainsAny(runID, `/\`) && runID != "." && runID != ".."
|
||||||
|
}
|
||||||
|
|
||||||
func readJSON(path string, target any) error {
|
func readJSON(path string, target any) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.modules.json"),
|
filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||||
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.metadata.json"),
|
filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||||
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.data_package.yaml"),
|
filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"),
|
||||||
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.render.json"),
|
filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||||
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_2026-05-29.distributor.json"),
|
filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"),
|
||||||
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_2026-05-29.md"),
|
filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"),
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(pathsString(paths), want) {
|
if !strings.Contains(pathsString(paths), want) {
|
||||||
t.Fatalf("paths = %#v, want component %q", paths, want)
|
t.Fatalf("paths = %#v, want component %q", paths, want)
|
||||||
@@ -69,6 +69,20 @@ func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPathsRejectRunIDPathSeparators(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
resolved.Definition.ID = report.ID("daily/bad")
|
||||||
|
|
||||||
|
_, err := store.Paths(resolved)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Paths() error = nil, want invalid run id error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run id must not contain path separators") {
|
||||||
|
t.Fatalf("error = %q, want run id path separator context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) {
|
func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
location := mustLoadStateLocation(t, "America/Chicago")
|
location := mustLoadStateLocation(t, "America/Chicago")
|
||||||
@@ -83,7 +97,7 @@ func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BatchDistributorNotificationPath() error = %v", err)
|
t.Fatalf("BatchDistributorNotificationPath() error = %v", err)
|
||||||
}
|
}
|
||||||
want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "20260618T033000.123456789Z_evening.distributor.json")
|
want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "distributor.20260618T033000.123456789Z_evening.json")
|
||||||
if !strings.Contains(path, want) {
|
if !strings.Contains(path, want) {
|
||||||
t.Fatalf("path = %q, want component %q", path, want)
|
t.Fatalf("path = %q, want component %q", path, want)
|
||||||
}
|
}
|
||||||
@@ -118,13 +132,13 @@ func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ReportID: report.Today,
|
ReportID: report.Today,
|
||||||
RunID: "20260617T120000.000000000Z_today",
|
RunID: "20260617T120000.000000000Z_today",
|
||||||
SourcePath: "/workspace/reports/today/20260617T120000.000000000Z_today.md",
|
SourcePath: "/workspace/reports/today/2026-06-17/report.20260617T120000.000000000Z_today.md",
|
||||||
BundlePaths: []string{"2026-06-17/today/report.md"},
|
BundlePaths: []string{"2026-06-17/today/report.md"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ReportID: report.Daily,
|
ReportID: report.Daily,
|
||||||
RunID: "20260617T120000.000000000Z_daily_2026-06-19",
|
RunID: "20260617T120000.000000000Z_daily_2026-06-19",
|
||||||
SourcePath: "/workspace/reports/daily/20260617T120000.000000000Z_daily_2026-06-19.md",
|
SourcePath: "/workspace/reports/daily/2026-06-19/report.20260617T120000.000000000Z_daily_2026-06-19.md",
|
||||||
BundlePaths: []string{"2026-06-19/daily/report.md"},
|
BundlePaths: []string{"2026-06-19/daily/report.md"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -148,7 +162,7 @@ func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveBatchDistributorNotification() error = %v", err)
|
t.Fatalf("SaveBatchDistributorNotification() error = %v", err)
|
||||||
}
|
}
|
||||||
wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "20260617T120000.000000000Z_morning.distributor.json")
|
wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "distributor.20260617T120000.000000000Z_morning.json")
|
||||||
if !strings.Contains(path, wantPath) {
|
if !strings.Contains(path, wantPath) {
|
||||||
t.Fatalf("path = %q, want component %q", path, wantPath)
|
t.Fatalf("path = %q, want component %q", path, wantPath)
|
||||||
}
|
}
|
||||||
@@ -302,15 +316,23 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
|||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
}
|
}
|
||||||
wants := map[string]string{
|
wants := map[string]string{
|
||||||
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"),
|
"ModuleSnapshot": filepath.Join("snapshots", tt.group, tt.validDate, "modules."+tt.runID+".json"),
|
||||||
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"),
|
"Metadata": filepath.Join("snapshots", tt.group, tt.validDate, "metadata."+tt.runID+".json"),
|
||||||
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"),
|
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, "data_package."+tt.runID+".yaml"),
|
||||||
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"),
|
"Preflight": filepath.Join("preflight", tt.group, tt.validDate, "render."+tt.runID+".json"),
|
||||||
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"),
|
"Notification": filepath.Join("notifications", tt.group, tt.validDate, "distributor."+tt.runID+".json"),
|
||||||
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"),
|
"RenderedReport": filepath.Join("reports", tt.group, tt.validDate, "report."+tt.runID+".md"),
|
||||||
|
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_raw."+tt.runID+".json"),
|
||||||
|
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_result."+tt.runID+".json"),
|
||||||
|
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text."+tt.runID+".json"),
|
||||||
|
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, "render_context."+tt.runID+".json"),
|
||||||
}
|
}
|
||||||
got := map[string]string{
|
got := map[string]string{
|
||||||
|
"ModuleSnapshot": paths.ModuleSnapshot,
|
||||||
|
"Metadata": paths.Metadata,
|
||||||
"DataPackage": paths.DataPackage,
|
"DataPackage": paths.DataPackage,
|
||||||
|
"Preflight": paths.Preflight,
|
||||||
|
"Notification": paths.Notification,
|
||||||
"RenderedReport": paths.RenderedReport,
|
"RenderedReport": paths.RenderedReport,
|
||||||
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
||||||
"GeneratedTextResult": paths.GeneratedTextResult,
|
"GeneratedTextResult": paths.GeneratedTextResult,
|
||||||
@@ -610,7 +632,7 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
|||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
}
|
}
|
||||||
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
||||||
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
derivedMetadataPath := filepath.Join(otherDir, "metadata."+resolved.Metadata().RunID+".json")
|
||||||
|
|
||||||
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
||||||
ModuleSnapshot: paths.ModuleSnapshot,
|
ModuleSnapshot: paths.ModuleSnapshot,
|
||||||
@@ -634,11 +656,79 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListReportsDiscoversNewMetadataFilename(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
older := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
newer := resolveTodayAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
|
olderPaths := savePriorMetadata(t, store, older, stateBriefingMetadata(older))
|
||||||
|
newerPaths := savePriorMetadata(t, store, newer, stateBriefingMetadata(newer))
|
||||||
|
|
||||||
|
records, err := store.ListReports(context.Background(), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListReports() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 2 {
|
||||||
|
t.Fatalf("ListReports() len = %d, want 2: %#v", len(records), records)
|
||||||
|
}
|
||||||
|
if records[0].RunID != newer.Metadata().RunID || records[0].MetadataPath != newerPaths.Metadata {
|
||||||
|
t.Fatalf("first record = %#v, want newer metadata path %q", records[0], newerPaths.Metadata)
|
||||||
|
}
|
||||||
|
if records[1].RunID != older.Metadata().RunID || records[1].MetadataPath != olderPaths.Metadata {
|
||||||
|
t.Fatalf("second record = %#v, want older metadata path %q", records[1], olderPaths.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, metadataPath, err := store.LoadMetadataByRunID(context.Background(), older.Metadata().RunID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMetadataByRunID() error = %v", err)
|
||||||
|
}
|
||||||
|
if metadata.RunID != older.Metadata().RunID || metadataPath != olderPaths.Metadata {
|
||||||
|
t.Fatalf("loaded metadata = %#v path %q, want run %q path %q", metadata, metadataPath, older.Metadata().RunID, olderPaths.Metadata)
|
||||||
|
}
|
||||||
|
if want := "metadata." + older.Metadata().RunID + ".json"; filepath.Base(metadataPath) != want {
|
||||||
|
t.Fatalf("metadata filename = %q, want %q", filepath.Base(metadataPath), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReportsIgnoresNonMetadataJSON(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
paths := savePriorMetadata(t, store, resolved, stateBriefingMetadata(resolved))
|
||||||
|
for _, path := range []string{
|
||||||
|
paths.ModuleSnapshot,
|
||||||
|
paths.GeneratedTextRaw,
|
||||||
|
paths.GeneratedTextResult,
|
||||||
|
paths.GeneratedText,
|
||||||
|
paths.RenderContext,
|
||||||
|
filepath.Join(filepath.Dir(paths.Metadata), resolved.Metadata().RunID+"."+"metadata.json"),
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("create non-metadata artifact directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(`{not json`), 0o600); err != nil {
|
||||||
|
t.Fatalf("write non-metadata artifact %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := store.ListReports(context.Background(), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListReports() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("ListReports() len = %d, want only metadata record: %#v", len(records), records)
|
||||||
|
}
|
||||||
|
if records[0].MetadataPath != paths.Metadata {
|
||||||
|
t.Fatalf("MetadataPath = %q, want %q", records[0].MetadataPath, paths.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindPriorSnapshot(t *testing.T) {
|
func TestFindPriorSnapshot(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||||
|
if want := "metadata." + first.Metadata().RunID + ".json"; filepath.Base(paths.Metadata) != want {
|
||||||
|
t.Fatalf("metadata filename = %q, want %q", filepath.Base(paths.Metadata), want)
|
||||||
|
}
|
||||||
|
|
||||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user