87 Commits

Author SHA1 Message Date
53aa0b0a55 Merge remote-tracking branch 'origin/main' 2026-08-13 13:52:21 +00:00
fc8ddada9a Close out the repository audit 2026-08-13 13:52:16 +00:00
13b06039b1 Retire completed audit records 2026-08-13 04:32:28 +00:00
b9080466a2 Document audit record retirement checklist 2026-08-13 04:31:31 +00:00
142f2f92e7 Retire completed comparison roadmaps 2026-08-13 04:28:03 +00:00
88fde0df7f Reconcile internal implementation guides 2026-08-13 04:25:40 +00:00
b985c5faac Consolidate generated text test ownership 2026-08-13 04:22:33 +00:00
d6829af32b Remove unused alert envelope retention 2026-08-13 04:17:05 +00:00
cd7b9aef2b Retire unused module and forecast compatibility exports 2026-08-13 04:15:42 +00:00
c3ebf06bd5 Retire unused weather bundle persistence helpers 2026-08-13 04:12:38 +00:00
7884b9a6c3 Retire dormant prompt compatibility APIs 2026-08-13 04:10:45 +00:00
17468cb8dd Consolidate CLI report date policy 2026-08-13 04:06:37 +00:00
71b7a74d3d Validate fact requirements through briefing vocabulary 2026-08-13 04:02:21 +00:00
2c4c0bbd90 Define briefing fact requirement vocabulary 2026-08-13 03:59:09 +00:00
965f16d7a4 Consolidate Distributor template parsing 2026-08-13 03:55:03 +00:00
fb891fad07 Reduce comparison bundle recognition reads 2026-08-13 03:52:20 +00:00
0516ee148d Fetch independent weather sources concurrently 2026-08-13 03:45:55 +00:00
e6450138c2 Reuse weather API readiness response 2026-08-13 03:38:49 +00:00
57aa27c9de Clean up comparison test workers 2026-08-13 03:34:08 +00:00
166c4ce53b Remove production waits from deterministic tests 2026-08-13 03:32:32 +00:00
78fc461a75 Make tests independent of host state 2026-08-13 03:29:43 +00:00
5e492cf1fb Preserve comparison failures during cancellation 2026-08-13 03:26:11 +00:00
79cba800ee Report comparison cleanup recovery state 2026-08-13 03:18:55 +00:00
302f5aba2d Honor cancellation during comparison replacement 2026-08-13 03:13:17 +00:00
0314a302f1 Preflight comparison transaction names 2026-08-13 03:09:00 +00:00
707db5394c Validate canonical comparison manifests 2026-08-13 03:06:28 +00:00
70cad789ea Preserve batch cancellation outcomes 2026-08-13 03:02:59 +00:00
4b748c2e53 Bound Distributor response diagnostics 2026-08-13 02:55:25 +00:00
0b57d99a97 Validate Distributor endpoints before publication 2026-08-13 02:44:21 +00:00
04b8358965 Harden report output publication 2026-08-13 02:40:29 +00:00
f4e3a6f26c Preflight report output filenames 2026-08-13 02:33:32 +00:00
44ee389334 Reconcile prompt execution provenance 2026-08-13 02:30:40 +00:00
ef2634c2cb Validate generated text catalog before collection 2026-08-13 02:22:09 +00:00
a18d5134c7 Keep generated prose out of Markdown structure 2026-08-13 02:17:11 +00:00
2bd921f247 Validate render identity and daypart fallbacks 2026-08-13 02:11:25 +00:00
360c665a3e Route report projections through prepared identity 2026-08-13 02:00:18 +00:00
e2dd8d0e29 Establish prepared metadata identity 2026-08-13 01:52:11 +00:00
e520ffb13b Bound generated text content and diagnostics 2026-08-13 01:47:47 +00:00
f8beed04cf Enforce generated text report identity 2026-08-13 01:36:56 +00:00
44af91cadf Secure prompt debug filesystem writes 2026-08-13 01:28:49 +00:00
a38d291f63 Harden prompt debug redaction 2026-08-13 01:19:35 +00:00
27849813db Refresh SPC outlook definition sources 2026-08-13 01:12:47 +00:00
41b86109e3 Correct daypart identity and display handling 2026-08-13 01:08:20 +00:00
13829cc65c Centralize daypart key canonicalization 2026-08-13 01:00:54 +00:00
daf0c7efd7 Correct derived briefing weather semantics 2026-08-13 00:58:04 +00:00
9b4e53702b Normalize briefing module options and weather stories 2026-08-13 00:53:31 +00:00
730929e2ed Validate precipitation probabilities and ice wording 2026-08-13 00:48:40 +00:00
8e49ba88c7 Preserve metric forecast units and overnight alerts 2026-08-13 00:45:08 +00:00
8fafacf921 Preserve civil daypart clocks across DST 2026-08-13 00:39:46 +00:00
8bb7307f22 Validate hourly forecast time bounds 2026-08-13 00:37:43 +00:00
c515529b3a Bound Weather API response diagnostics 2026-08-13 00:35:38 +00:00
3c1ebab289 Validate Weather API endpoints and retries 2026-08-13 00:32:22 +00:00
2d956f7315 Strengthen CLI action preflight and coverage 2026-08-13 00:28:31 +00:00
4d5a1d9709 Cancel actions on process interrupts 2026-08-13 00:24:16 +00:00
1d3ea64541 Require nonblank notification identities 2026-08-13 00:21:20 +00:00
706086e3de Apply configuration secrets atomically 2026-08-13 00:18:20 +00:00
26a681e0b1 Validate configuration source keys and overrides 2026-08-13 00:15:26 +00:00
5139c1a586 Correct curated prompt package contracts 2026-08-13 00:11:31 +00:00
0b869af75e Add an implementation plan to address the audit findings 2026-08-13 00:00:59 +00:00
c3eeb298f0 Close the audit and add the remediation roadmap 2026-08-12 18:09:28 +00:00
6945306a2f Consolidate and triage the audit findings 2026-08-12 18:01:17 +00:00
4f52555389 Complete the Stage 24 documentation audit 2026-08-12 17:52:46 +00:00
fa19452dec Complete the Stage 23 refactoring audit 2026-08-12 17:43:35 +00:00
91e7e5f321 Complete the Stage 22 efficiency audit 2026-08-12 17:33:30 +00:00
4bb3913276 Complete the Stage 21 test durability audit 2026-08-12 17:25:42 +00:00
ab571cd8ab Complete the Stage 20 test risk audit 2026-08-12 17:18:36 +00:00
798e6f11c5 Complete the Stage 19 test hygiene audit 2026-08-12 17:13:57 +00:00
c49c50bc8d Complete the Stage 18 comparison execution audit 2026-08-12 17:05:03 +00:00
d328a1daa6 Record Stage 17 comparison publication audit 2026-08-12 16:58:42 +00:00
7ae3820e12 Record Stage 16 batch and Distributor audit 2026-08-12 16:52:02 +00:00
a4ef76f17a Record Stage 15 output publication audit 2026-08-12 16:45:47 +00:00
5ed1e264fc Record Stage 14 application preparation audit 2026-08-12 16:39:22 +00:00
c025afcd1a Record the Stage 13 rendering audit 2026-08-12 16:31:34 +00:00
2b06541ef8 Record the Stage 12 generated text audit 2026-08-12 16:24:13 +00:00
d92ff0ef48 Record Stage 11 Promptkit security audit 2026-08-12 16:17:19 +00:00
880ad710ae Record Stage 10 prompt boundary audit 2026-08-12 16:11:55 +00:00
edde330390 Complete the Stage 9 briefing audit 2026-08-12 16:03:43 +00:00
ae52606772 Record Stage 8 module audit findings 2026-08-12 15:56:41 +00:00
8a323d5574 Record Stage 7 derivation audit findings 2026-08-12 15:52:05 +00:00
cfb64ded34 Complete the Stage 6 weather data audit 2026-08-12 15:45:10 +00:00
5ed448df11 Complete the Stage 5 CLI audit 2026-08-12 15:35:13 +00:00
725c1420dd Complete configuration and secrets audit 2026-08-12 15:24:20 +00:00
e5250bd6cb Complete report identity and time audit 2026-08-12 15:13:57 +00:00
00fe0c3e96 Record the architecture audit findings 2026-08-12 15:08:34 +00:00
6d2c097657 Establish the repository audit baseline 2026-08-12 15:03:01 +00:00
e7c7262404 Add audit workflow plan 2026-08-12 14:52:26 +00:00
151c536cb9 Add comparison diagnostics to the future roadmap 2026-08-12 14:46:17 +00:00
160 changed files with 8370 additions and 3502 deletions

View File

@@ -3,14 +3,29 @@ package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
)
func main() {
if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
if err := runCommand(os.Args[1:], os.Stdout, os.Stderr, cli.Run); err != nil {
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
os.Exit(1)
}
}
func runCommand(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error) error {
return runCommandWithSignalContext(args, stdout, stderr, runner, signal.NotifyContext)
}
type signalContextFunc func(context.Context, ...os.Signal) (context.Context, context.CancelFunc)
func runCommandWithSignalContext(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error, signalContext signalContextFunc) error {
ctx, stop := signalContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return runner(ctx, args, stdout, stderr)
}

View File

@@ -0,0 +1,36 @@
package main
import (
"context"
"errors"
"io"
"os"
"syscall"
"testing"
)
func TestRunCommandBuildsCancelableSignalContext(t *testing.T) {
var signals []os.Signal
stopped := false
signalContext := func(parent context.Context, requested ...os.Signal) (context.Context, context.CancelFunc) {
signals = append([]os.Signal(nil), requested...)
ctx, cancel := context.WithCancel(parent)
cancel()
return ctx, func() {
stopped = true
}
}
err := runCommandWithSignalContext(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
return ctx.Err()
}, signalContext)
if !errors.Is(err, context.Canceled) {
t.Fatalf("runCommandWithSignalContext() error = %v, want context cancellation", err)
}
if len(signals) != 2 || signals[0] != os.Interrupt || signals[1] != syscall.SIGTERM {
t.Fatalf("requested signals = %#v, want Interrupt and SIGTERM", signals)
}
if !stopped {
t.Fatal("signal context stop function was not called")
}
}

View File

@@ -0,0 +1,58 @@
//go:build unix
package main
import (
"context"
"errors"
"io"
"os"
"syscall"
"testing"
"time"
)
func TestRunCommandCancelsActionContextOnSignal(t *testing.T) {
for _, tt := range []struct {
name string
signal os.Signal
}{
{name: "Interrupt", signal: os.Interrupt},
{name: "Terminate", signal: syscall.SIGTERM},
} {
t.Run(tt.name, func(t *testing.T) {
started := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- runCommand(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
close(started)
<-ctx.Done()
return ctx.Err()
})
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("runner did not receive an action context")
}
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
if err := process.Signal(tt.signal); err != nil {
t.Fatalf("Signal(%v) error = %v", tt.signal, err)
}
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("runCommand() error = %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("interrupt did not cancel the action context")
}
})
}
}

View File

@@ -68,6 +68,13 @@ failure before publication leaves an existing destination unchanged. A
notification failure occurs after publication, so the newly written output
remains available.
`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that
cancellation reach the action before exiting; when the action has a result, it
emits the usual failed summary and exits nonzero. A canceled batch retains any
reports that were already published, marks interrupted and unstarted reports
as `canceled`, skips batch notification, and identifies cancellation separately
from report failures.
Action commands (`generate`, `run`, and `compare`) write a JSON summary to
stdout unless `--quiet` is set. `run` also writes compact per-report and batch
status lines to stderr. A pre-run error, such as an invalid flag, missing
@@ -115,11 +122,15 @@ The `total`, `succeeded`, and `failed` counters describe report items only, so
a failed batch notification can leave `failed` at `0` while the top-level
notification and action status are `failed`.
When cancellation stops a batch, the summary also includes a nonzero
`canceled` count. Canceled reports have `"status": "canceled"`; they are not
included in `failed`, and the action still has failed status and exits nonzero.
Without `--quiet`, batch status lines use this form:
```text
report=today status=succeeded output="/srv/weather/reports/today.md"
batch=morning total=2 succeeded=2 failed=0
batch=morning total=2 succeeded=2 failed=0 canceled=0
```
### Compare Summary
@@ -143,16 +154,18 @@ when available. The safe error includes only a category and message: aggregate
and unclassified application failures use `application`; cancellation uses
`canceled`; deadlines use `deadline_exceeded`; prompt execution uses its
published Promptkit category; destination failures use `destination_<kind>`;
and committed cleanup failures use `publication_cleanup`. It does not expose
and committed cleanup failures use `publication_cleanup` with a message that
states whether a complete prior bundle, partial remnants, or no prior bundle
remains, or that recovery state could not be inspected. It does not expose
provider diagnostics, filesystem causes, or recovery paths. See the
[comparison bundle contract](integrations/comparison-bundle.md) for durable
artifact fields and failure invariants.
If the bundle is published but cleanup of its replaced prior bundle fails, the
summary still includes the published artifact paths and has status `failed`.
Its JSON error is `publication_cleanup` with the message `comparison published
but cleanup did not complete`; the returned command error identifies the
retained backup path for operator recovery.
Its JSON error is `publication_cleanup`; the returned command error identifies
a recovery path only when cleanup left a sibling behind. Only a reported
complete prior bundle is a rollback artifact.
## Flag Reference
@@ -163,11 +176,11 @@ retained backup path for operator recovery.
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
| `--tz NAME` | `generate`, `run`, `compare` | Override `weather_api.timezone` for this command. |
| `--out PATH` | every `generate` command | Write the report to this complete file destination instead of the configured or current-directory default. |
| `--llm-debug-dir PATH` | every `generate`, `run`, and `compare` command | Write requested sensitive prompt diagnostics under this absolute path. |
| `--llm-debug-dir PATH` | every `generate`, `run`, and `compare` command | On Unix hosts, write requested sensitive prompt diagnostics under this absolute path. Other hosts fail closed when the flag is requested. |
| `--profile PROFILE` | `compare` | Select one explicit profile. Repeat at least twice with distinct, nonblank IDs. |
| `--out-dir PATH` | `run morning`, `run evening`, `compare` | Write batch reports beneath this directory, or select the exact comparison directory. |
| `--replace` | `compare` | Authorize replacement of a recognized nonempty comparison bundle. |
| `--quiet` | `generate`, `run`, `compare` | Suppress successful action output and routine batch status output. |
| `--quiet` | `generate`, `run`, `compare` | Suppress all action summaries and routine batch status output. |
| `--date YYYY-MM-DD` | `generate daily`, `generate today`, `compare daily`, `compare today` | Required for Daily; optional for Today. |
Distributor notification is configured through `notify.distributor`; there are

View File

@@ -45,7 +45,7 @@ All omitted fields use their built-in defaults.
| Field | Default | Rules |
| --- | --- | --- |
| `base_url` | empty | Absolute Weather API URL. Required for collection and generation. |
| `base_url` | empty | Absolute HTTP(S) Weather API URL. Required for collection and generation. |
| `timeout` | `10s` | Must be greater than zero. |
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
@@ -53,7 +53,11 @@ All omitted fields use their built-in defaults.
| `format` | `json` | Required and must be `json`. |
Timezone values may be IANA names, configured aliases such as `Chicago` and
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
`Stl`, US timezone abbreviations, or signed UTC offsets such as `-5`, `+0930`,
and `+09:30`. Numeric offsets require a sign, one or two hour digits, and an
optional two-digit minute component with or without a colon. Hours must be
from `00` through `23`, minutes from `00` through `59`, so the largest accepted
offset magnitude is `23:59`.
### `location`
@@ -72,8 +76,10 @@ The prompt-facing location timezone is derived from the effective
### `secrets`
`secrets.directory` defaults to empty, which disables secret loading. When it
is set, every regular file directly in that directory is loaded after the file
and command-line overrides. A file basename must match
is set, every regular file directly in that directory is staged after the file
and command-line overrides, then applied only after the complete configuration
has validated successfully. A rejected load leaves the existing environment
unchanged. A file basename must match
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
file contents replace any existing value. One trailing LF or CRLF is removed.
@@ -114,7 +120,7 @@ Distributor notification is disabled by default. Its fields are:
| Field | Default | Rules when notification is enabled |
| --- | --- | --- |
| `enabled` | `false` | Activates Distributor notification validation. |
| `endpoint` | `https://distributor.example.com` | Must be an absolute URL. |
| `endpoint` | `https://distributor.example.com` | Must be an absolute HTTP(S) base URL with a host and no userinfo, query, or fragment. A path prefix is allowed. |
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
| `timeout` | `30s` | Must be greater than zero. |
| `failure_policy` | `error` | Must be `error`. |
@@ -129,6 +135,14 @@ Distributor notification is disabled by default. Its fields are:
The upload token is read from the environment variable named by `token_env`.
Use `secrets.directory` when a file-backed secret is appropriate.
When notification is enabled, Weatherreporter validates the Distributor endpoint
before prompt inspection, weather collection, or output publication. Use an
HTTP(S) base URL such as `https://distributor.example.com/archive`; do not put
credentials, a query string, or a fragment in the endpoint.
When notification is enabled, each rendered single-report pipeline ID, bundle
ID, and idempotency key must contain at least one non-whitespace character.
Single-report bundle templates accept `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`,
@@ -161,9 +175,10 @@ output selection, and failure handling.
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
`none`. `missing_source.sources` optionally overrides that policy by source.
Hourly forecast data is required for generated reports. Supported optional
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
`weather_story`, and `spc_convective_outlooks`.
Hourly forecast data is required for generated reports and cannot have a
source-specific policy. Supported optional source keys are `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`; any other key is rejected.
### `promptkit`
@@ -173,6 +188,8 @@ key is rejected with a migration error; it is not translated or ignored.
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
individual `generate`, `run`, or `compare` command when explicitly needed.
See [optional prompt debug capture](operations.md#optional-prompt-debug-capture)
for platform availability, security, and retention requirements.
| Field | Default | Rules |
| --- | --- | --- |
@@ -213,13 +230,21 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use
(`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon`
(`15:00``17:00`), and `evening` (`17:00``24:00`).
Names remain display text, but each name must have a distinct canonical
identity. Canonicalization trims whitespace, lowercases letters, and collapses
punctuation and whitespace to underscores; for example, `Morning`,
`morning!`, and `morning` conflict. Planning recognizes the canonical
identities `morning`, `afternoon`, `evening`, and `overnight` regardless of
their display capitalization or punctuation.
### `reports`
`reports` optionally overrides a report's ordered deterministic modules and
Distributor path templates. Omit a report entry to retain its defaults.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
and underscores are equivalent.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
lookup.
Each report entry can contain:

View File

@@ -66,7 +66,8 @@ contiguous from one, profile IDs are distinct and nonblank, and
`succeeded + failed == total`.
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
a unique Markdown `reportPath`, and no `error`. A failed result has
a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for
its position, total, and logical profile ID, and no `error`. A failed result has
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
`category` and `message`. Its validation status is absent, `failed`, or
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
@@ -81,10 +82,20 @@ larger workspace.
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
the current version, schema, file set, file types, relative-path rules, and
data-package digest. It rejects unknown manifest fields, multiple JSON values,
extra entries, symlinks, and future or otherwise unsupported versions. Treat a
bundle that fails recognition as an ordinary directory, not as a compatible
bundle.
data-package digest. JSON field names are case-sensitive canonical names and a
field may appear only once in each manifest object. It rejects unknown,
case-variant, or duplicate fields; multiple JSON values; extra entries;
symlinks; and future or otherwise unsupported versions. Treat a bundle that
fails recognition as an ordinary directory, not as a compatible bundle.
When replacing a recognized bundle, cancellation observed before the new
bundle is installed preserves the prior bundle rather than committing the
replacement.
Cleanup of a prior bundle occurs only after its replacement is committed and
does not affect the new bundle's compatibility. A cleanup error may identify a
complete recovery bundle, partial remnants, no remaining sibling, or an
uninspectable state; this operational state is not recorded in the manifest.
The manifest contains safe operational provenance, but `data-package.yml` and
the generated Markdown can contain sensitive weather or location context. Do

View File

@@ -8,8 +8,10 @@ and [operations guide](../../operations.md).
## Upload Admission
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
posts a gzip-compressed source bundle to:
Weatherreporter uses an absolute HTTP(S) endpoint with a host as a base URL.
It allows a path prefix but rejects userinfo, query strings, and fragments
before local report work begins. The client posts a gzip-compressed source
bundle to:
```text
POST /v1/pipelines/<pipeline_id>/upload
@@ -23,11 +25,16 @@ A successful response is `202 Accepted` with JSON containing `run_id` and
`status`. Acceptance means Distributor staged and validated the source bundle;
it does not mean downstream destinations have published it.
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
source-file mapping before calling Distributor. It reads the bearer token from
The adapter requires nonblank pipeline ID, bundle ID, and idempotency key, plus
at least one source-file mapping, before calling Distributor. It reads the bearer token from
the configured environment variable and redacts that value from errors. Request
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
Weatherreporter reads at most 1 MiB from each Distributor response. An
oversized response fails notification with a stable local diagnostic. Normal
Weatherreporter results retain upload and status identity but do not repeat
Distributor response bodies, status reports, or remote error text.
## Idempotency
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be

View File

@@ -6,10 +6,14 @@ attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
## Client And Upload
The adapter constructs the client with the configured endpoint, bearer token,
and an HTTP client whose timeout is the configured Distributor timeout. It
passes no custom retry options, so the pinned client's defaults apply: three
attempts, 100 ms base delay, and one-second maximum delay.
The adapter constructs the client with the prevalidated HTTP(S) endpoint,
bearer token, and an HTTP client whose timeout is the configured Distributor
timeout. The endpoint may include a path prefix but never userinfo, a query, or
a fragment. It passes no custom retry options, so the pinned client's defaults
apply: three attempts, 100 ms base delay, and one-second maximum delay.
The adapter bounds every response to 1 MiB before handing it to the pinned
client. A response above that boundary is rejected as a local overflow rather
than decoding or retaining a prefix.
For each notification, Weatherreporter calls `UploadFiles` with:
@@ -38,8 +42,9 @@ adapter translates it to its own conflict error without exposing the token.
The adapter then calls `Status` for the accepted run. A terminal `failed`
status is a notification failure. A status lookup failure or a timeout before a
terminal status remains attached to the otherwise accepted upload as diagnostic
status information. Polling cadence, final failure handling, and redaction are
internal behavior documented in the
status information. Normal diagnostics use local status classifications; they
do not expose remote response text or the status report. Polling cadence, final
failure handling, and redaction are internal behavior documented in the
[Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md).

View File

@@ -16,7 +16,7 @@ The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded p
## Selection And Active Execution
Before weather collection, Weatherreporter validates the exact prompt version, output contract, and selected profile. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
Before weather collection, Weatherreporter validates the report's exact generated-text report/schema/template catalog binding, prompt version and hash, output contract, and selected profile. Active profiles must resolve a nonblank backend and model identity. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
1. explicit in-memory profiles used by an embedding consumer or test;
2. the configured `profile_file` or `profile_dir`;
@@ -27,24 +27,34 @@ A source falls through only when the selected ID is absent. Each source supplies
Profiles that require a direct API key are unsupported; a profile that reports `APIKeyEnv` requires a nonblank value in that environment variable. Active results retain the selected logical profile ID and resolved backend and model. Ordinary errors, summaries, logs, and outputs exclude endpoints, credentials, rendered messages, schemas, request bodies, response bodies, and complete parameter maps.
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Before accepting that JSON, Weatherreporter requires exactly one preparation callback and reconciles its prompt/profile/backend/model and rendered/input hashes with the inspected identity and completed result. The callback output contract and completed validation must use the report's expected JSON Schema mode and path. The package contains only reviewed prompt-facing warning summaries, never source transport or provenance details. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
When capture is enabled, its preparation artifact projects a provider endpoint
to its scheme and host and retains only reviewed execution settings. Provider
extras and URL user information, paths, queries, and fragments are omitted.
Capture storage remains confined to the operator-selected debug root; an unsafe
filesystem path causes the requested execution to fail. Host availability and
operator handling are documented in the
[operations guide](../operations.md#optional-prompt-debug-capture).
## Comparison Execution
For `compare`, Weatherreporter validates one exact prompt and every explicitly
selected profile before weather collection. It prepares one deterministic YAML
For `compare`, Weatherreporter validates the report's generated-text catalog
binding, one exact prompt, and every explicitly selected profile before weather
collection. It prepares one deterministic YAML
data package, retains immutable copies of the report inputs, and executes every
profile against the same exact data-package bytes. Each profile remains an
independent Promptkit execution: one provider or validation failure does not
independent Promptkit execution: one provider, provenance, or validation failure does not
stop its peers, while caller cancellation applies to every in-flight execution.
Weatherreporter starts selected profile executions concurrently and does not
add an application-level concurrency limit. Promptkit owns backend capacity and
any profile or backend concurrency policy. The durable comparison output and
any profile or backend concurrency policy. A shared Weatherreporter executor
must safely accept those concurrent `Execute` calls. The durable comparison output and
its compatibility rules are defined by the
[comparison bundle contract](comparison-bundle.md); the user-facing command
contract is in the [CLI reference](../cli.md).
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Promptkit results are accepted only when their raw JSON is at most 64 KiB; the adapter drops larger results before copying them into Weatherreporter's execution values or debug artifacts. The validator also limits total generated prose to 20,000 characters, with 4,000-character summary and timing fields, a 12,000-character Hourly discussion, and at most 12 day-style paragraphs of 4,000 characters each. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
Prompt/profile configuration and the maintained local override example are owned by the [configuration reference](../config.md). Adapter construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).

View File

@@ -9,24 +9,29 @@ are documented in [Weather data internals](../internal/weather-data.md) and
## Base URL And Requests
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
endpoint path to the configured base URL path, so a service hosted under a path
prefix must keep that prefix available. Requests use `GET` and carry the
`weather_api.base_url` must be an absolute HTTP(S) URL. Weatherreporter joins
each endpoint path to the configured base URL path, so a service hosted under a
path prefix must keep that prefix available. Requests use `GET` and carry the
configured timeout on every HTTP attempt.
Every request sends `format` and, except where noted below, `units`. The
configured format must be `json`.
Before retrieving sources, Weatherreporter warms up
`/conditions/current` with the same `format`, `units`, and `precision` query
parameters used for current conditions. The warmup only requires a readable
2xx response; its body is not decoded. Failure after its internal retry budget
stops the fetch before source requests begin.
Before retrieving sources, Weatherreporter requests `/conditions/current` with
the same `format`, `units`, and `precision` query parameters used for current
conditions. After a readable 2xx response, it retains that response for the
normal current-conditions source step rather than making a second identical
request. Failure after the readiness request's internal retry budget stops the
fetch before source requests begin.
## Endpoints And Query Parameters
The adapter makes one source request for each endpoint after a successful
warmup, subject to retry on transient failures.
The adapter makes one source request for each endpoint, subject to retry on
transient failures. A successful readiness request supplies the current
conditions source response. The remaining independent source requests run
concurrently, then their results are processed in the source order shown below.
This keeps source provenance, missing-source policy, and surfaced errors
deterministic regardless of response order.
| Source | Endpoint | Query parameters | Availability |
| --- | --- | --- | --- |
@@ -57,14 +62,15 @@ An absent `data` member is treated as a missing source. For ordinary sources,
`data: null` is also missing. The active-alert exception is listed above: its
explicit `null` payload represents an empty alert result.
Hourly forecast data must be present and contain at least one `period`; a
missing, malformed, or empty hourly product fails collection. The remaining
sources follow the configured missing-source policy. Under `error`, collection
fails; under `warn`, the source is omitted and an inspectable warning is
recorded; under `none`, the source is omitted without a warning. A per-source
policy overrides the default. See [Configuration](../config.md) for policy
settings and [Weather data internals](../internal/weather-data.md) for recorded
source metadata.
Hourly forecast data must be present and contain at least one `period`. Every
hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
after `startTime`; a missing, malformed, empty, or invalidly bounded hourly
product fails collection. The remaining sources follow the configured
missing-source policy. Under `error`, collection fails; under `warn`, the
source is omitted and an inspectable warning is recorded; under `none`, the
source is omitted without a warning. A per-source policy overrides the default.
See [Configuration](../config.md) for policy settings and [Weather data
internals](../internal/weather-data.md) for recorded source metadata.
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
Malformed `data` for an optional source follows its missing-source policy.
@@ -138,9 +144,10 @@ It does not retry other HTTP statuses, malformed envelopes, missing data, or
payload decoding failures. A canceled context also stops an in-progress retry
delay.
The adapter reads at most 10 MiB from one response body. A non-2xx response,
request construction failure, read failure, or decode failure includes endpoint
context in its error.
The adapter accepts response bodies up to 10 MiB and rejects larger bodies
before decoding. A non-2xx response reports its relative endpoint and status,
without including upstream response text. Request construction, response-limit,
read, and decode failures include endpoint context in their errors.
Retry counts and delays are adapter behavior rather than Weather API request
parameters. Do not depend on a particular attempt count when implementing the

View File

@@ -7,24 +7,32 @@ is owned by the [CLI reference](../cli.md) and [operations guide](../operations.
## Single-Report Flow
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. It validates the exact Promptkit prompt and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. Output preflight validates the final filename, permits only an absent or regular final destination, and validates the bounded same-directory temporary form without creating a missing parent. It then validates the report's generated-text catalog binding, exact Promptkit prompt, and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. Only after that write succeeds does single-report notification run.
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit only against the inspected prompt and profile, reconciles the preparation callback and completed result with that identity and the prepared report schema, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` writes the completed Markdown through a same-directory temporary file, rechecks the final destination and context after close and immediately before the atomic rename. Only after that write succeeds does single-report notification run.
Failures return an active partial result with safe identity, profile, warning, validation, debug, and output information when available. After rendering and immediately before publication, the workflow checks for cancellation or deadline expiry. Any failure before publication leaves an existing destination unchanged. A notification failure retains the newly published output.
## Batches
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating prompt and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating generated-text catalog, prompt, and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
Cancellation and deadline expiry stop the sequential loop before another report
starts. Completed report results and published paths remain successful; the
interrupted and unstarted planned reports have `canceled` status and are counted
separately from failed reports. The batch notification result records that
delivery was skipped, and the returned error retains the original context cause
for callers and CLI projection.
## Comparisons
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
and preflights the exact bundle destination before initializing optional prompt
debugging, prompt inspection, or collection. It then inspects the one prompt
and every selected profile, collects once, and delegates shared report
debugging, prompt inspection, or collection. It then validates the report's
generated-text catalog binding, inspects the one prompt and every selected
profile, collects once, and delegates shared report
construction to the prepared-report flow. It does not accept a notifier.
Once the destination is resolved, the partial result retains its absolute
@@ -35,10 +43,13 @@ inspection fails, the partial result retains the resolved prompt ID, version,
and hash. Artifact paths are added only after publication commits.
The comparison execution core starts each inspected profile independently,
keeps results in selection order, and waits for all started work. Independent
profile failures are recorded and do not stop peers. Context cancellation marks
unfinished work and prevents publication. Details of prepared values, execution
and debugging, and publication are documented in [prepared report
keeps results in selection order, and waits for all started work. Every profile
reconciles its callback and completion provenance before its JSON can be
rendered. Independent profile failures are recorded and do not stop peers; a
completed profile failure remains recorded if cancellation happens later.
Context cancellation marks only unfinished or cancellation-terminated work and
prevents publication. Details of
prepared values, execution and debugging, and publication are documented in [prepared report
internals](prepared-report.md), [comparison execution
internals](comparison-execution.md), and [comparison publication
internals](comparison-publication.md).
@@ -47,7 +58,8 @@ When publication has committed its new bundle, application results contain the
absolute manifest, data-package, and successful report paths even if removal of
the previous sibling backup then fails. That cleanup failure is still returned
as an operational error rather than treating the new bundle as unpublished;
the returned error retains the recovery path and underlying filesystem cause.
the returned error identifies the observed recovery state and includes a path
only when cleanup left a sibling behind.
## Boundaries And Verification

View File

@@ -10,7 +10,10 @@ snapshot, construct YAML, invoke Promptkit, or render a report.
Every `ModuleDefinition` declares an ID, stanza name, default option value,
required collected and derived facts, supported report IDs, missing-data
behavior, duplicate policy, builder, and optional prompt exporter.
behavior, duplicate policy, builder, and optional prompt exporter. The
briefing-owned fact-requirement vocabulary supplies each prerequisite's stable
identity, category, and availability predicate; registry construction rejects
unknown requirements and requirements listed under the wrong category.
`BuildModule` first verifies the requested module, report compatibility, and
option shape. It then applies the declared missing-data behavior:
@@ -32,29 +35,60 @@ discussion, and weather story. Derived builders shape daily and daypart
summaries, precipitation timing, outdoor windows, and the report-specific
Daily, Today, and Tomorrow planning values.
The daily summary preserves generic feels-like values as
`apparent_temperature_max_f`; it does not label them as a heat index. Daypart
temperature phrases retain below-zero meaning, including through temperature
trends that cross zero. Outdoor windows add a 25-point risk penalty and an
explicit reason for each snow, ice, or fog indicator. Equal scores retain input
order for both best and worst windows.
The module registry preserves rich values for templates and snapshots while
curating prompt exports where needed. In particular, source warnings are a
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
missing sources, and prompt-safe SPC values omit geometry and other
template-only or source details. The complete module composition is in
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
Alert digests are built from selected alert items and source provenance, not a
provider response envelope.
`area_forecast_discussion` accepts an optional typed section filter. Planning
modules are report-specific: `daily_planning` supports Daily,
Derived daypart-summary maps use the forecast package's canonical daypart
identity and reject any collision instead of replacing an earlier value.
Planning applies the same identity when recognizing morning, afternoon,
evening, and overnight windows; display labels remain separate and preserve
configured text with rune-safe first-letter capitalization.
The embedded SPC background-definition asset records its authoritative sources,
source update dates, and maintainer review schedule. Its categorical
`official_description` values transcribe the [SPC convective-outlook risk
table](https://www.spc.noaa.gov/about/outlooks/); its Conditional Intensity
Group entries follow the [SPC conditional-intensity
reference](https://www.spc.noaa.gov/exper/conditional-intensity-information).
`plain_language` values are Weatherreporter summaries. Weatherreporter
maintainers review the asset annually and whenever either source changes.
`area_forecast_discussion` accepts an optional typed section filter. Accepted
typed option pointers are normalized to the declared value type before builder
execution. Planning modules are report-specific: `daily_planning` supports Daily,
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
## Missing data and boundaries
Optional current conditions, narrative products, discussions, and weather
stories may be omitted. Required derived modules fail when their declared facts
stories may be omitted. A weather story is usable only when it has non-blank
displayable content (title, description, alternate text, or download URL) or a
valid start/end period; otherwise collection applies its optional-source policy
and the module is omitted. Required derived modules fail when their declared facts
are unavailable. Empty alert and outlook runs can still produce checked-empty
modules. SPC discussion is omitted unless a retained categorical outlook meets
the package's severity criterion and matching discussion text exists.
Effective units, timezone, and location context arrive in `ModuleContext` from
configuration and resolved report metadata. Field defaults are owned by
[configuration](../config.md), and prompt-package layout is owned by
[prompt input](prompt-input.md).
`ModuleContext` carries the effective units, timezone, location context, and
prepared identity. Report preparation creates that one `PreparedIdentity` for
the shared report identity, timing, configuration context, and source warnings
before module construction. The metadata module projects its matching fields
from that value and retains its prompt-safe shape. Field defaults are owned by
[configuration](../config.md), and prompt-package layout is owned by [prompt
input](prompt-input.md).
## Verification and invariants

View File

@@ -4,11 +4,17 @@
The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time.
For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
The executable derives its action context from `SIGINT` and `SIGTERM` and
passes it to `Runner.Run`. Signal cancellation therefore uses the same action,
summary, and error paths as other context cancellation.
The CLI dispatches only generation and batch actions. It has no persisted-run or inspection dispatch. Summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. They intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned.
For each `generate`, `run`, or `compare` action, `Runner` constructs one project-owned Promptkit executor after request preflight and configuration loading. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
CLI code owns no report policy, weather collection, output publication, provider execution, or notification policy. Focused checks:
The CLI dispatches generation, batch, and comparison actions. It has no persisted-run or inspection dispatch. Generation and batch summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. Comparison summaries retain their ordered profile results and published bundle paths when available. All summaries intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned unless `--quiet` is set.
CLI code owns report-date flag acceptance and date resolution, but not report
composition, weather collection, output publication, provider execution, or
notification policy. Focused checks:
```sh
go test ./internal/cli

View File

@@ -1,43 +1,36 @@
# Collection Internals
`internal/collect` is the application-facing boundary for collecting the
`internal/collect` is the small application-facing boundary that obtains one
normalized Weather API bundle. The external HTTP contract belongs in the
[Weather API integration guide](../integrations/weatherapi.md); normalized data
semantics belong in [weather-data internals](weather-data.md).
[Weather API integration guide](../integrations/weatherapi.md); normalized
source values belong in [weather-data internals](weather-data.md).
## Contract
`Run` accepts a `context.Context` and a `Request` containing effective
`config.Config`. It constructs the Weather API adapter from that configuration,
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
`Run` receives a context and effective configuration in `Request`. It creates
the Weather API adapter, calls `FetchBundle`, and returns the adapter's
normalized bundle in `Result`. Adapter construction errors are wrapped as
weather-collection setup errors and fetch errors as bundle-collection errors.
The package wraps adapter construction failures as weather-collection setup
errors and fetch failures as bundle-collection errors. It does not retry,
persist, select reports, derive facts, build modules, invoke Promptkit, or
notify Distributor.
The package neither chooses reports nor derives facts, builds modules, invokes
Promptkit, writes files, or sends notifications. Request scheduling, endpoint
retrieval, response limits, and source-level warnings belong to the Weather
API adapter and its integration contract.
## Application Composition
## Application Use
`internal/app` owns the narrow `Collector` interface used by workflow tests;
the production implementation delegates to `collect.Run`. Generation, batch
execution, and explicit bundle fetching all use this boundary. Application
orchestration rejects a nil collector result or a nil bundle before report work
can continue.
`internal/app` owns the `Collector` interface used by report workflows and
tests. Its default implementation delegates to `collect.Run`; callers may
substitute a collector at that boundary. Application orchestration owns
collection timing, reuse across a workflow, and the handling of nil collection
results. See [app orchestration internals](app-orchestration.md) for that
flow.
Single-report generation and a batch each collect once. A batch passes the same
normalized collection to planning and to every report it generates. Collection
failure prevents later workflow work for that request.
## Verification
## Boundaries And Invariants
Focused package tests cover a successful fetch and wrapping failures from
adapter construction and bundle retrieval:
Collection owns adapter creation and retrieval of one normalized bundle. It
must not make report, period, batch, prompt, module, filesystem, or notification
decisions.
- App-facing Weather API collection always passes through this package.
- The returned value is normalized source data, not facts or prompt input.
- Context cancellation is passed to the Weather API adapter.
- Errors retain whether setup or fetching failed.
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
also covered by `internal/app/app_test.go`.
```sh
go test ./internal/collect
```

View File

@@ -9,9 +9,12 @@ selection order even though execution completes in an arbitrary order.
Every profile uses the exact inspected prompt identity and a private copy of
the same prepared data package. Provider, generated-text validation, rendering,
or debug-write failure becomes that profile's safe failed outcome and does not
cancel its peers. The application deliberately imposes no additional semaphore:
Promptkit owns backend capacity. Cancellation or a deadline marks unfinished
outcomes as skipped or failed, joins work, and prevents bundle publication.
cancel its peers. The shared executor must support those concurrent `Execute`
calls. The application deliberately imposes no additional semaphore: Promptkit
owns backend capacity. A profile failure completed before a later cancellation
remains its original safe outcome; cancellation or a deadline marks only
unfinished or cancellation-terminated outcomes as skipped or failed, joins work,
and prevents bundle publication.
When debugging is enabled, each execution receives a deterministic reference
derived from the comparison identity, ordered profile position, and safe
@@ -19,7 +22,9 @@ profile slug. This keeps concurrent captures separate. The debug writer itself
owns secure-root validation and file permissions. It safely creates shared
missing ancestors during concurrent writes, then rejects symlink and non-
directory components. Operational retention and sensitivity are documented in
the [operations guide](../operations.md).
the [operations guide](../operations.md). This secure writer is enabled only on
Unix hosts; comparison fails before execution when another host requests debug
capture.
The output result and its safe errors are converted into the durable contract
only by comparison publication. See [comparison publication

View File

@@ -6,24 +6,41 @@ and only the Markdown files for successful profiles. The durable layout,
schema, and compatibility rules are owned by the [comparison bundle
contract](../integrations/comparison-bundle.md).
Recognition first token-validates the manifest's object fields, rejecting
unknown, case-variant, and duplicate names before decoding its typed schema.
Manifest validation derives each successful report filename from its ordered
position, total profile count, and logical profile ID; logical-bundle and
filesystem validation then require that exact path and file set.
Destination planning is read-only. It requires an exact absolute target that
is neither the filesystem root nor the working directory, rejects unsafe
symlinks and non-directories, accepts a missing or empty directory, and permits
replacement only for a recognized current bundle. Publication rechecks that
authorization immediately before it writes a private sibling staging directory.
For replacement, it moves the prior bundle to a private sibling backup,
reauthorizes that moved entry, and restores it if installing the new bundle
fails.
replacement only for a recognized current bundle. Publication rechecks the
destination namespace and type immediately before it writes a private sibling
staging directory. For replacement, it moves the prior bundle to a private
sibling backup, fully reauthorizes that moved entry, checks for cancellation,
and restores it if cancellation or installing the new bundle prevents
replacement. If guarded restoration fails, the error retains the prior bundle's
recovery path.
Planning also validates the final component and the bounded fixed names used
for private staging and backup siblings. A destination that cannot form those
names is rejected before publication creates a missing parent directory; a
maximum-length valid destination remains usable because transaction siblings do
not incorporate its basename.
The new bundle is committed only after the staged directory has been installed
at the target. From that point its artifact paths are authoritative: a failure
to remove the retained sibling backup does not roll back the new bundle.
Publication returns an inspectable cleanup error with the absolute backup path
and underlying filesystem cause so an operator can recover or remove that
backup manually.
After a cleanup failure, publication inspects the sibling without masking the
original filesystem cause. Its inspectable cleanup result distinguishes a
complete recognized recovery bundle, partial remnants, an absent sibling, or
an uninspectable state. A recovery path is reported only when something
remains; only a complete recognized bundle is suitable for rollback recovery.
The application preflights before prompt inspection and collection, then
preflights again before publication. A cancellation or any failure before the
The application preflights before prompt inspection and collection. Publication
performs its transaction-boundary checks and final moved-destination
authorization before installation. A cancellation or any failure before the
commit leaves the prior destination untouched. Completed bundles include
partial profile results; comparison publication never coordinates Distributor
notification. Operator-facing lifecycle and cleanup are in the

View File

@@ -13,7 +13,9 @@ the token, an optional timeout, and an injectable upstream-client factory.
`New` validates its configuration before creating the adapter. For each upload,
the adapter reads the token from the configured environment variable and builds
the upstream client with that endpoint, token, and an HTTP client whose timeout
matches the local positive timeout.
matches the local positive timeout. Its transport reads at most 1 MiB from any
Distributor response before the pinned client decodes it; an oversized response
is a distinct local failure and does not trigger an extra upload attempt.
The upstream client is an implementation dependency, not a source of
application configuration: retry ownership, pipeline selection, path
@@ -43,20 +45,26 @@ persist notification artifacts.
An accepted upload is followed by one status request. When a timeout is
configured, a nonterminal result is polled until `succeeded` or `failed`, or
until the context ends. The translated `UploadResult` contains the run ID,
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
and remote error details.
status, and `RunStatus`, including pipeline ID and lifecycle timestamps.
Remote response bodies, status reports, and remote error text are not retained
in normal results. HTTP failures retain a local typed status-code and
retryability classification; conflicts retain the local idempotency-conflict
type.
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
the caller can report an accepted-but-unconfirmed delivery. A terminal failed
run returns that result and an error. Upload failures return no result. Upstream
the caller can report an accepted-but-unconfirmed delivery, using a bounded
repository-owned diagnostic rather than remote text. A terminal failed run
returns that result and an error. Upload failures return no result. Upstream
idempotency conflicts become the local `IdempotencyConflictError`, which adds
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
the token.
## Verification
Focused tests cover configuration validation, request mapping, timeouts and
polling, status translation, conflict handling, and token redaction:
Focused tests cover configuration validation, request mapping, response size
boundaries, safe diagnostics, timeouts and polling, status translation, conflict
handling, and token redaction. A local HTTP server exercises the production
upload and status boundary:
```sh
go test ./internal/adapters/distributor

View File

@@ -16,6 +16,9 @@ story, and convective outlook data. Source provenance and warnings are copied
into their own slices so downstream consumers can inspect data completeness
without treating it as an ordinary weather fact.
Alert facts retain individual alert payloads for period selection together with
their copied source provenance; they do not retain a provider response envelope.
A nil bundle produces an empty collected value. Collection itself, missing
source policy, and source hashes are outside this package.
@@ -26,6 +29,8 @@ uses half-open period overlap to select hourly, narrative, daily, and alert
data; it also derives precipitation timing. Convective outlooks are retained
only when their valid interval overlaps the report period, with discussions
kept for represented outlook days. Both collections are sorted deterministically.
It rejects collected hourly data with a precipitation probability outside the
finite 0 through 100 percentage domain before constructing derived facts.
Report identity controls the summary shape:
@@ -37,6 +42,8 @@ Report identity controls the summary shape:
`DaypartSummaries` is collected from the resulting daily summaries.
The detailed grouping, daypart-window, and alert rules are owned by
[forecast derivation](forecast-derivation.md).
Daily alert overlaps remain scoped to the civil day, while overnight daypart
summaries retain alerts that overlap their complete next-day window.
## Missing data and failures

View File

@@ -2,60 +2,46 @@
`internal/forecast` deterministically selects and summarizes normalized
forecast data. It has no transport, filesystem, CLI, subprocess, or report
registry dependency. Its summaries are consumed by
[fact contracts](facts.md) and later module builders.
registry dependency. The report-scoped caller is [fact
contracts](facts.md), which owns the choice of data required by each report.
## Period and daypart semantics
## Daily Derivation
Selections use `timeutil.Period` half-open overlap: a value is selected only
when both intervals share time. `BuildDailySummary` creates one local civil
day; `BuildPeriodDailySummaries` intersects every local civil day with the
requested period, preserving partial first and last days.
`BuildDailySummary` builds one summary for one local civil day. The facts
layer calls it for Daily, Today, and Tomorrow reports; it does not provide a
multi-day or arbitrary-period summary constructor. `timeutil.Period` supplies
the shared half-open overlap rule used while selecting source values.
`ResolveDayparts` converts each configured name, start clock, and end clock
into a local window. An end clock at or before its start clock wraps into the
next civil day. The daypart and timezone defaults are defined in the
[configuration reference](../config.md), not here.
`ResolveDayparts` turns configured local clock ranges into windows. A range
whose end is not after its start continues into the next civil day. The
available daypart and timezone settings are defined in the
[configuration reference](../config.md).
## Deterministic summaries
The summary keeps selected hourly and narrative values, the discussion,
source warnings and provenance, alert overlaps, and one summary for each
resolved daypart. Daypart summaries derive their measurements, conditions,
weather indicators, and precipitation timing from normalized forecast
periods. `BuildPrecipTiming` is also available to the facts layer for a
report's selected hourly periods.
`BuildDailySummary` requires an hourly run with at least one period. It adds
the selected narrative periods, discussion, alert overlaps, source provenance,
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
its selected hourly periods and derives temperature and apparent-temperature
ranges, timed precipitation and wind maxima, dominant and notable conditions,
and weather indicators.
## Boundaries And Failures
Indicators are deterministic checks over normalized values and condition text:
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
wind text are detected from the forecast description. `BuildPrecipTiming`
sorts periods, records the maximum and first precipitation, groups contiguous
periods at or above its package-owned probability threshold, and records
thunder mentions.
Daily-summary construction requires a bundle with hourly forecast data,
valid precipitation probabilities, and valid daypart definitions. Optional
normalized products remain absent when unavailable. Invalid alerts are ignored
while valid overlaps are selected for the relevant day or daypart window.
Alert overlap parsing supports the normalized alert payload's available timing
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
are clipped to the requested period and ordered by alert start time.
Thresholds, text classification, unit normalization, and alert selection are
package implementation rules. Report identity, period selection, and the
resulting derived-fact shape are owned by [fact contracts](facts.md); external
source semantics are owned by [weather-data internals](weather-data.md).
## Missing data and failures
## Verification
Empty selections yield empty summary fields rather than generated prose.
Direct daily or period-summary calls fail when their required bundle, valid
period, hourly data, or daypart definitions are invalid. A nil location uses
UTC when these APIs are called directly. Optional narrative, discussion, and
alerts remain absent when their normalized products are absent.
Forecast thresholds used for brief indicators and precipitation timing are
implementation rules.
## Verification and invariants
Focused tests cover local civil days, clipped periods, daypart resolution,
summary metrics, precipitation windows, threshold helpers, and alert overlap:
Focused `internal/forecast` tests exercise daily and overnight dayparts,
summary derivation, invalid precipitation data, precipitation timing, and
alert overlap handling. `internal/facts` tests cover the report-scoped caller:
```sh
go test ./internal/forecast ./internal/timeutil
go test ./internal/forecast ./internal/facts
```
The package preserves normalized inputs as inspectable structured values and
never decides report identity, delivery, or presentation wording.

View File

@@ -9,9 +9,10 @@ maintainer-facing context fields belong to [report templates](../templates.md).
## Catalog and validation
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
generated text. `LookupDefinition` rejects unknown schema or template IDs and
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
value and canonical normalized JSON, loads its canonical schema through
generated text. `LookupDefinition` requires the exact report, schema, and
template triple and rejects unknown IDs, unsupported pairs, and a pair that
belongs to another report before the run begins. A handler validates and
normalizes raw JSON into a typed value, loads its canonical schema through
`internal/promptassets`, builds a render context, and renders through
`internal/reporttemplate`.
@@ -19,23 +20,44 @@ Daily, Today, and Tomorrow use a day-style value with required trimmed summary
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
and a single trimmed discussion string. Every form also requires the
`precipitation_timing` field; an empty string means there is no supported timing
prose to render. Typed decoding rejects missing required fields and unknown JSON
fields; no general-purpose JSON Schema engine is used at runtime.
prose to render. Typed decoding requires the exact lowercase JSON field names,
rejects missing, duplicate, case-variant, and unknown fields, and checks field
shapes; no general-purpose JSON Schema engine is used at runtime.
The validator accepts at most 64 KiB of raw JSON before it allocates typed
values. Its JSON Schemas and typed checks limit `summary` and
`precipitation_timing` to 4,000 characters each. Hourly
`forecast_discussion` is limited to 12,000 characters. Day-style discussion
accepts at most 12 paragraphs of at most 4,000 characters each. Across all
prose fields, one report may contain at most 20,000 characters. These bounds
apply before trimming, filtering, normalization, and template rendering.
Malformed JSON and field values return short, content-safe errors. They name
only canonical fields where useful and never echo provider values or unknown
field names. The Promptkit adapter also drops an oversized provider result
before copying it into execution or debug state; direct executor implementations
receive the same enforcement in this package.
## Render contexts
The catalog's report-specific builders receive briefing metadata, a rich module
snapshot, collected facts, derived facts, and the matching validated generated
text. They decode the module stanzas needed by the template and build typed
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
metadata and periods, preserves rich module values, and uses ordered slices for
template iteration rather than maps.
The catalog's report-specific builders receive the prepared report identity, a
rich module snapshot, derived facts needed to order dayparts, and the matching
validated generated text. They require the identity's report ID to match the
selected builder. When the optional metadata stanza is present, every shared
identity field must agree with that prepared authority before context
construction continues. Builders then decode the module stanzas needed by the
template and build typed Daily, Today, Tomorrow, or Hourly contexts. Contexts
expose only display-ready report values, generated prose, and module values;
they do not expose complete collected or derived fact bundles. Ordered slices
remain the template iteration surface rather than maps.
Optional source stanzas become nil or fallback context fields. Missing required
stanzas, type-decoding failures, invalid metadata, or a generated-text type
that does not match the chosen handler fail before template execution. Prompt
packages, raw Promptkit output handling, and template asset lookup remain
outside this package.
Optional source stanzas become nil or fallback context fields. Today also
computes whether its ordered dayparts contain a displayable condition so the
template can render either rows or its explicit no-details fallback. Missing
required stanzas, type-decoding failures, conflicting identity values, invalid
metadata, or a generated-text type that does not match the chosen handler fail
before template execution. Prompt packages, raw Promptkit output handling, and
template asset lookup remain outside this package.
## Verification and invariants
@@ -48,5 +70,7 @@ go test ./internal/generatedtext
```
Generated text supplies prose slots only; deterministic weather facts remain in
module and fact values. Every report definition must resolve to exactly one
supported catalog pair.
module and fact values. The renderer applies its plain-text policy to every
generated prose insertion, preserving ordinary text and paragraph breaks while
preventing provider text from creating Markdown or HTML structure. Every report
definition must resolve to exactly one supported catalog pair.

View File

@@ -1,6 +1,6 @@
# Module Contract Internals
`internal/module` defines the stable envelope between report composition,
`internal/module` defines the envelope between report composition,
module builders, in-memory snapshots, templates, and prompt packages. It
does not define a report, execute a builder, or choose prompt-export policy;
those responsibilities belong to [report registry](report-registry.md) and
@@ -13,8 +13,8 @@ Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
otherwise the rich value. This permits custom prompt exports without shrinking
the template value.
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
validates it. Its JSON representation contains IDs, stanza names, and rich values only;
`NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
representation carries a package-owned schema marker, IDs, stanza names, and rich values only;
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
stanza into a caller-supplied type, reporting a missing stanza separately from
a decoding error.
@@ -43,7 +43,8 @@ The registry declares these ordered default compositions:
The only non-empty default option is the AFD section selection. It accepts a
`sections` list; omitted or empty selects all available sections. Report
definitions may narrow it as shown above. Option shape and report compatibility
are validated by the briefing registry.
are validated by the briefing registry. Accepted typed option pointers are
canonicalized to the declared value type before a module builder receives them.
## Rich and prompt-facing values

View File

@@ -1,14 +1,29 @@
# Prepared Report Internals
`internal/app` builds a `preparedReport` after collection and before profile
execution. This is the immutable boundary shared by ordinary report generation
and profile comparison; it is not a durable artifact.
`internal/app` validates the report's generated-text catalog binding during
prompt inspection, before collection, and carries the resulting handler into
`preparedReport` construction after collection. This is the immutable boundary
shared by ordinary report generation and profile comparison; it is not a
durable artifact.
Preparation builds report facts, the configured module snapshot, briefing
metadata, the curated prompt-input package, serialized YAML, and the
generated-text definition. It deep-copies mutable facts, snapshots, metadata,
and data-package bytes before returning them. Consumers receive independent
copies so one execution cannot change another's input or rendering context.
Preparation first establishes one `PreparedIdentity` for the report run, report
and prompt IDs, variant, generation time, units, timezone, valid period,
location, and source warnings. It passes that identity to the configured module
snapshot, curated prompt-input package, serialized YAML, generated-text render
context, and generated-text definition. Each boundary projects only the fields
it needs from that prepared authority.
Preparation deep-copies mutable facts, snapshots, identity, and data-package
bytes before returning them. Consumers receive independent copies so one
execution cannot change another's input or rendering context.
Before accepting generated JSON, the execution boundary reconciles the prepared
report definition, inspected prompt hash and selected profile identity, the one
preparation callback, and the completed Promptkit result. The callback and
completion must agree on prompt, profile, backend, model, and rendered/input
hashes; the callback output and completed validation must name the prepared
report's JSON Schema. A mismatch produces no rendered Markdown and leaves
results with only the inspected safe identity.
Single-report generation executes one prepared profile and publishes its
Markdown. Comparison prepares once, gives every selected profile the same YAML
@@ -16,5 +31,7 @@ bytes, and only then assembles the resulting logical bundle. The prompt-input
shape is owned by [prompt-input internals](prompt-input.md); profile execution
semantics are owned by [Promptkit integration](../integrations/promptkit.md).
Preparation failure has no publication side effects. Tests for this boundary
cover mutation isolation, byte equality, and reuse by both execution paths.
Catalog incompatibility stops prompt inspection before weather collection or
model work. Preparation failure has no publication side effects. Tests for this
boundary cover catalog-preflight ordering, mutation isolation, byte equality,
and reuse by both execution paths.

View File

@@ -1,27 +1,35 @@
# Prompt Input Internals
`internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, loading, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends.
`internal/promptinput` turns prepared report metadata and an ordered module
snapshot into the YAML data package passed to Promptkit. The externally visible
prompt and inline-input contract is owned by the [Promptkit integration
guide](../integrations/promptkit.md); preparation of the inputs is owned by
[prepared report internals](prepared-report.md).
## Package Construction
`Build` produces `weatherreporter.data_package.v4`. It copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and source warnings. Prompt input contains no historical comparison section.
`Build` projects report identity, the report-local current date, source-warning
summaries, and each snapshot output's prompt-facing value into a package. It
does not expose source transport or provenance details. The module snapshot
defines stanza order and selects curated prompt values; the corresponding
module contracts are documented in [module internals](module.md) and [briefing
internals](briefing.md).
Briefing is a flat ordered set of stanza values. `Build` uses each output's `DataPackageValue`, so curated prompt exports take precedence and rich values are used only as a fallback. Prompt exports are selected by the [briefing registry](briefing.md), while the rich-versus-prompt contract is in [module internals](module.md).
`MarshalYAML` validates the package before serializing it. Serialization emits
the metadata stanza first, then groups the remaining recognized stanzas in the
package's fixed category order while preserving snapshot order within a
category. `Validate` enforces the supported schema version, required report
identity and period values, and a nonempty, complete ordered briefing.
## YAML Ordering And Validation
This package does not collect weather, choose an output destination, execute a
provider, or persist data packages. The application passes its in-memory YAML
to the Promptkit adapter as part of prepared report execution.
Serialization keeps `metadata` directly under `briefing`. Every other known stanza is placed in one category and emitted in category order while preserving its original module order:
## Verification
| Category | Current stanzas |
| --- | --- |
| `applicable_risk_products` | alert digest, SPC convective outlooks |
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
| `narrative_products` | narrative forecast, discussions, and weather story |
| `raw_data` | current conditions and hourly forecast |
`LoadYAML` accepts this layout and reconstructs the flat order and values. It rejects misplaced, duplicate, unknown, or uncategorized stanzas. `Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` and `LoadYAML` validate their result. `Save` remains a reusable atomic-file helper for callers that explicitly need one; normal application execution passes marshalled YAML directly to Promptkit.
Focused tests cover construction, curated exports, category ordering, YAML round trips, invalid layout, validation, and atomic saves:
Focused tests cover package construction, report-local dates, validation,
curated snapshot exports, deterministic YAML grouping, and safe source-warning
projection:
```sh
go test ./internal/promptinput

View File

@@ -1,29 +1,37 @@
# Report Registry Internals
`internal/report` owns report identities, valid-period resolution, exact prompt identity and version, output names, default module composition, and Distributor path declarations. Public command syntax belongs in the [CLI reference](../cli.md); configuration aliases and overrides belong in the [configuration reference](../config.md).
`internal/report` owns the in-process registry of report identities and the
resolution of a report's valid period. Command names and configuration aliases
belong to the [CLI reference](../cli.md) and [configuration
reference](../config.md), respectively.
## Definitions And Resolution
## Registry And Resolution
Each `Definition` declares a stable ID and display name, prompt ID and version, template and generated-text schema IDs, valid-period resolver, default output name, Distributor path templates, module list, and fixed batch eligibility. `Resolved` combines a definition with one valid period and run identity.
`DefaultRegistry` supplies the maintained definitions. `Lookup` returns a
definition by its internal ID, while `Resolve` combines it with a request time,
location, and optional date to produce `Resolved`. The result carries the
definition, generation time, timezone, and resolved valid period; its metadata
and output-name helpers keep derived identity values consistent for callers.
| Report ID | Prompt version | Default profile | Period policy | Fixed batch flag | Default output |
| --- | --- | --- | --- | --- | --- |
| `daily` | `2.0.0` | `weather-balanced` | Explicit local civil day | Dynamic Daily inclusion is app-owned | `daily-YYYY-MM-DD.md` |
| `today` | `2.0.0` | `weather-balanced` | Selected or current local civil day | Morning | `today.md` |
| `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
| `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
Definitions carry the internal collaborators needed downstream: prompt and
template identity, module configuration, output naming, Distributor path
templates, and fixed batch eligibility. The external prompt contract is owned
by the [Promptkit integration guide](../integrations/promptkit.md), template
surface by the [report template guide](../templates.md), and published
Distributor paths by the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting.
`WithModuleOverrides` returns an independently cloned registry with replacement
module configuration for recognized report IDs. The application owns batch
planning and data-dependent inclusion; see [app orchestration
internals](app-orchestration.md).
## Collaborators And Boundaries
The registry never collects weather data, parses CLI flags, writes output,
executes Promptkit, or delivers a report.
`DefaultRegistry`, `Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating report identity rules. Registry overrides clone a recognized definition and replace its module list. `DistributorPathTemplates` are consumed by app orchestration; their rendered external bundle-path contract is documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
## Verification
`morning` and `evening` are registry-owned batch names. Fixed flags declare Today and Tomorrow eligibility; app orchestration determines data-dependent Daily membership and the actual batch plan.
The registry never collects weather data, parses CLI flags, writes output, executes Promptkit, or delivers a report.
Focused tests cover definition completeness, command and alias lookup, period resolution, run IDs, output names, composition defaults, and override validation:
Focused tests protect retained report definitions, period resolution, Daily
run-ID disambiguation, and rejection of retired command or configuration names:
```sh
go test ./internal/report

View File

@@ -28,8 +28,13 @@ failures actionable with template or partial context.
Top-level templates decide which shared partials they invoke. The current
partials cover daypart forecast variants, alert digest, and precipitation
timing. Template code receives curated typed contexts rather than raw data
packages, and it must not reimplement weather selection or generated-text
validation.
packages or complete fact bundles, and it must not reimplement weather
selection or generated-text validation. Context construction rejects
report-identity disagreements before template execution. Every generated-prose
insertion uses the `plainText` helper. It retains ordinary prose and paragraph
breaks but renders Markdown/HTML syntax, code indentation, and control
characters as safe text, so the repository templates remain the sole owners of
report structure.
## Boundaries and verification
@@ -38,8 +43,8 @@ text, construct contexts, resolve report definitions, write state, execute
Promptkit, or upload reports. It produces Markdown bytes for application
orchestration to persist.
Focused tests cover template lookup, rendering, partial
behavior, missing keys, and malformed context:
Focused tests cover template lookup, rendering, partial behavior, daypart
fallbacks, missing keys, and malformed context:
```sh
go test ./internal/reporttemplate

View File

@@ -31,6 +31,10 @@ provider endpoint or retry policy from the normalized types. See
[collection](collect.md) for assembly and
[report templates](../templates.md) for the values exposed to authors.
An alert run retains its check time and individual alert payloads for overlap
selection. Its source entry retains provider provenance; the full provider
envelope is not carried into the normalized bundle.
## Source provenance
Every checked source is represented by a `Source` entry. The record identifies
@@ -45,6 +49,10 @@ marked missing only when the adapter's missing-source policy treats the
response or parsing failure as unavailable. The policy itself belongs to the
[configuration reference](../config.md).
Accepted hourly forecast periods always have nonzero start and end times, with
the end after the start. Collection rejects a required hourly product that does
not meet those bounds before it enters downstream derivation.
## Warning semantics
`SourceWarning` has a source name, stable code, severity, explanatory message,

View File

@@ -36,6 +36,20 @@ absolute output path and active profile, backend, model, warnings, validation,
debug, and notification information; see the [CLI reference](cli.md) for its
exact fields.
Weatherreporter validates the final output filename before prompt inspection or
weather collection. A valid long filename is published through a short,
same-directory temporary sibling, so temporary naming does not shorten the
operator-selected destination. A rejected filename does not create a missing
parent directory. The final destination itself must be absent or a regular
file: symlinks, directories, named pipes, sockets, and other special objects
are rejected before prompt inspection or weather collection. The destination is
checked again immediately before the atomic replacement; cancellation or a
deadline at that point leaves the prior report unchanged and skips notification.
`SIGINT` and `SIGTERM` request orderly cancellation of an active action. The
command lets cancellation and related cleanup finish before it exits; use the
usual failed result or error to determine whether an output was published.
## Batch Outputs And Distributor Notification
Run a scheduled batch with an explicit output directory when appropriate:
@@ -55,7 +69,10 @@ final output destination before executing its first report prompt. A destination
collision, such as a directory named `tomorrow.md`, stops the batch before any
report output is created or replaced. After successful validation, each selected
report processes independently and successful outputs remain available if
another report fails.
another report fails. If cancellation or a deadline is observed during the
sequence, Weatherreporter stops before starting another report. It retains
already published files, marks interrupted and unstarted reports as canceled in
the result, and skips batch notification.
When `notify.distributor.enabled` and batch notification are enabled,
Weatherreporter sends one Distributor upload only after every selected output
@@ -64,12 +81,18 @@ files remain at their selected destinations. A batch notification failure also
leaves all successfully published report files in place. Distributor source
files are those operator-owned Markdown outputs; rendered bundle paths and
delivery status appear in the result, not in a local notification receipt.
Remote Distributor response text is not included in command output. Instead,
notification failures use stable local diagnostics while retaining the upload
and status identities needed to investigate delivery with Distributor.
Report counters count report items only. A batch notification failure therefore
returns a failed batch status even when all report counters show success; the
top-level notification result contains the delivery diagnostic.
For a single report, Distributor notification follows the atomic output write.
See the [configuration reference](config.md) for pipeline, bundle,
Enabled notification configuration, including the HTTP(S) endpoint and
templates, is validated before report processing. A malformed endpoint does not
collect weather data, generate a report, publish output, or invoke Distributor.
See the [configuration reference](config.md) for endpoint, pipeline, bundle,
idempotency-key, and per-report path templates.
## Comparison Bundles
@@ -100,9 +123,15 @@ rechecked immediately before an atomic publish. A missing or empty directory
is usable. A nonempty directory can be replaced only when `--replace` is given
and it is recognized as a current Weatherreporter comparison bundle; ordinary
directories, symlinks, and unsafe destinations are rejected. Cancellation and
all failures before publication preserve an existing bundle. Profile failures
are different: the command publishes a complete partial bundle, with failed
profiles represented in the manifest and no Markdown file for those profiles.
all failures before publication preserve an existing bundle, including a
cancellation observed while a replacement is being prepared. If guarded
restoration cannot complete, the error names the retained sibling bundle for
manual recovery. Profile failures are different: the command publishes a
complete partial bundle, with failed profiles represented in the manifest and
no Markdown file for those profiles.
Comparison preflight also checks that private publication siblings can be
formed. An infeasible destination name is rejected before a missing parent
directory is created.
## Local Prompt Profile Override
@@ -137,6 +166,19 @@ remain distinct. Normal output, summaries, and routine logs omit that sensitive
content. Debug capture is never created for an ordinary command without
`--llm-debug-dir`.
Secure prompt debug capture is currently available only on Unix hosts, where
Weatherreporter can keep every traversal and write anchored to opened directory
descriptors without following symbolic links. On other platforms, requesting
`--llm-debug-dir` fails before prompt inspection, weather collection, or
provider execution; ordinary commands without the flag remain available.
Preparation captures retain only the provider endpoint origin and reviewed
execution settings. URL user information, paths, queries, fragments, and
unrecognized provider parameters are omitted.
Capture writes are confined to the requested root and fail if an unsafe
filesystem component prevents secure artifact creation.
If capture creation or writing fails, the affected run fails rather than
silently continuing without the requested diagnostics.
@@ -152,10 +194,13 @@ cancellation and pre-publication errors leave the prior destination unchanged.
If a replacement commits but cleanup of its prior sibling backup fails, the new
bundle remains valid and its artifact paths appear in the failed command
summary. The summary records a safe `publication_cleanup` error, while the
returned command error reports the retained backup path. Preserve that backup
until it has been inspected and cleaned up manually; do not remove the new
bundle to retry that cleanup.
summary. The summary records a safe `publication_cleanup` error that indicates
whether a complete prior bundle remains, only partial remnants remain, or no
prior bundle remains; it also identifies when the sibling cannot be inspected.
The returned command error includes a recovery path only when a sibling remains.
Preserve a complete recognized recovery bundle until it has been inspected and
cleaned up manually; partial remnants are not a rollback artifact. Do not
remove the new bundle to retry cleanup.
Enable explicit debug capture only when content-rich Promptkit diagnostics are
necessary.

View File

@@ -72,6 +72,9 @@ directly.
pre-publication failure, including cancellation observed immediately before
publication, does not replace an existing destination; a notification failure
does not remove a newly published output.
- A single-report final destination is either absent or a regular file.
Symlinks and special filesystem objects are rejected during preflight and
rechecked immediately before the atomic replacement.
- Configuration or explicit CLI input selects that operator-owned destination;
it does not create an application-owned state boundary.
- Comparison bundles are flat, versioned operator outputs. Their guarded
@@ -84,6 +87,8 @@ directly.
outcomes only; a failed batch notification is represented separately at the
batch level.
- Comparison never invokes Distributor notification.
- Profile comparison supports operator review only: it does not score, rank,
select, resample, or replay profile executions.
- Default tests are deterministic, offline, and use Promptkit/provider fakes
rather than live provider calls. See the [testing policy](testing.md).

66
docs/releases/v0.12.0.md Normal file
View File

@@ -0,0 +1,66 @@
# Weatherreporter v0.12.0
This release completes a repository-wide correctness, security, efficiency,
test-durability, and documentation audit.
## Summary
Weatherreporter now applies stricter validation and bounded diagnostics across
its configuration, weather collection, Promptkit, rendering, publication,
comparison, and Distributor boundaries. Report preparation and execution carry
one reconciled identity, independent weather sources are collected
concurrently, and cancellation preserves completed report and comparison
outcomes.
The release also removes obsolete compatibility surfaces and consolidates
duplicated implementation and test policy without changing ordinary report
commands or output identities.
## Compatibility
This release is compatible with `v0.11.0` for ordinary `generate`, `run`, and
`compare` commands, configuration files, report filenames, comparison bundles,
and Distributor integration.
Sensitive prompt-debug capture through `--llm-debug-dir` is now supported only
on Unix hosts. Non-Unix hosts reject an explicit capture request before prompt
inspection, weather collection, or provider execution because the required
handle-relative, no-follow filesystem guarantees are unavailable there.
Several unused internal compatibility exports were removed. They were not part
of the documented CLI, configuration, artifact, or integration contracts.
## Upgrade
No special action is required for ordinary installations. Operators who use
`--llm-debug-dir` on Windows must run that diagnostic workflow on a Unix host.
Review any automation that depended on undocumented internal Go APIs removed by
this release.
## Changes
- Hardened configuration loading, source validation, secrets rollback,
endpoint validation, HTTP diagnostics, generated-text limits, prompt-debug
redaction, output publication, comparison replacement, and Distributor
failure reporting.
- Reconciled inspected, prepared, callback, and completed Promptkit identity
and provenance before accepting generated content.
- Preserved metric values, civil-day and daypart identity, overnight alerts,
precipitation semantics, and Markdown structure across deterministic report
preparation and rendering.
- Collected independent Weather API sources concurrently and reused readiness
data while retaining deterministic normalized results.
- Preserved completed report and comparison failures independently from shared
cancellation, stopped unfinished work, and skipped batch notification after
cancellation or partial report failure.
- Made secure prompt-debug traversal descriptor-relative on Unix and fail
closed elsewhere. See the [operations
guide](../operations.md#optional-prompt-debug-capture).
- Strengthened default test portability and determinism, including
capability-aware symbolic-link fixtures and platform-appropriate process
signal coverage.
- Removed obsolete compatibility helpers, duplicated test ownership, dormant
persistence code, and completed audit and implementation roadmaps.
- Updated the [architecture policy](../policy/architecture.md), [testing
policy](../policy/testing.md), and focused internal guides to describe the
implemented final state.

View File

@@ -136,6 +136,17 @@ Any distributor enhancement should preserve the adapter boundary:
weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior.
## Comparison Profile Diagnostics
Status: Proposed and unimplemented.
Comparison preflight failures could identify the profile being inspected and
preserve a safe, actionable Promptkit cause, such as a duplicate profile ID,
instead of reporting only a generic `profile_load` failure. Any improvement
must continue to omit credentials, endpoints, and other sensitive profile
values. Regression coverage should include a comparison that mixes built-in
and configured-directory profiles and a directory containing duplicate IDs.
## Alternate Runtime Integrations
Status: Proposed and unimplemented.

View File

@@ -1,398 +0,0 @@
# LLM Profile Comparison Implementation Plan
Status: Complete.
## Purpose And Authority
This document is the ordered implementation plan for the accepted [LLM
Profile Comparison Roadmap](profile-comparison.md). The roadmap owns the
feature purpose, policy, scope, and desired end state. This plan records the
completed implementation and records the corrective work completed during
post-implementation review.
All implementation work in this plan is complete. The recorded work leaves the
repository compiling, tested, documented to its implemented boundary, and
internally coherent.
## Implementation Rules
The following rules governed every implementation stage:
- Read `docs/development.md`, the task-specific documents it identifies, all
files under `docs/policy/`, and the feature roadmap before changing code.
- Preserve the existing `generate`, `run`, and `compare` command contracts
except for the explicit comparison corrections defined below.
- Keep Promptkit types and calls behind `internal/adapters/promptkit` and the
dependency-neutral `internal/promptexec` interface.
- Keep comparison artifacts operator-owned and explicit. They are not durable
application state and must never be discovered or consumed implicitly by a
later invocation.
- Preserve comparison's prepare-once, execute-concurrently, order-results-by-
selection, publish-on-profile-failure, and never-notify invariants.
- Do not add a Weatherreporter concurrency limit. Promptkit owns backend
capacity.
- Never expose provider bodies, prompts, schemas, model output, endpoints,
credentials, or arbitrary wrapped error text in normal JSON summaries or
manifests.
- Use deterministic, offline, credential-free tests. Test filesystem safety,
concurrency, and recovery through the narrowest stable behavioral boundary;
do not rely on timing-only sleeps or host permission behavior.
- Run `gofmt` on changed Go files and `git diff --check` in every stage. Run
focused tests while developing and `GOWORK=off go test -count=1 ./...` before
completing each stage. Stages involving concurrency or filesystem mutation
must also run affected packages with `-race`.
- Do not commit, tag, push, or prepare a release unless the implementing prompt
separately requests it.
## Completed Stages
### Stage 1: Comparison Artifact And Naming Contracts
Added the dependency-neutral comparison model, schema version, manifest
validation and encoding, safe errors, deterministic profile filenames,
comparison identities, default directory names, and content hashing.
### Stage 2: Destination Recognition And Transactional Publication
Added read-only destination planning, strict recognition of current comparison
bundles, private sibling staging, guarded replacement, rollback, and atomic
directory publication.
### Stage 3: Ordered Multi-Profile Preflight
Added exact prompt inspection followed by sequential profile inspection before
weather collection, including effective backend, model, and credential checks.
### Stage 4: Immutable Shared Report Preparation
Extracted one immutable prepared-report value so comparison collection,
derivation, module construction, and data-package serialization happen once.
### Stage 5: Profile Execution And In-Memory Rendering
Separated profile-specific Promptkit execution, generated-text validation, and
Markdown rendering from output publication while preserving ordinary report
generation behavior.
### Stage 6: Concurrent Ordered Profile Execution
Added one goroutine per selected profile using one shared executor and one
prepared input, deterministic debug identities, isolated profile failures,
joined cancellation, and selection-ordered results.
### Stage 7: Application-Level Comparison
Added `app.CompareDetailed`, coherent complete and partial bundle construction,
aggregate profile-failure behavior, absolute published paths, and the
application-level guarantee that comparison never notifies Distributor.
### Stage 8: Compare Command Parsing
Added the `compare` command request path, repeatable ordered `--profile`, exact
`--out-dir`, guarded `--replace`, applicable common flags, validation, and one
executor construction per invocation.
### Stage 9: CLI Results And Exit Behavior
Added structured success and failure summaries, quiet-mode suppression,
ordered per-profile results, safe bounded errors, and nonzero exit behavior for
partial or command-level failure.
### Stage 10: Canonical Documentation And Initial Validation
Documented the implemented CLI, operations, Promptkit integration, comparison
bundle, application orchestration, execution, publication, architecture, and
development contracts, then passed the original repository-wide validation
gate.
## Stage 11: Make Concurrent Prompt Debug Creation Race-Safe
### Goal
Ensure concurrent comparison profiles can create their distinct debug runs
under one new report/date directory without spuriously failing or leaving a
test goroutine blocked.
### Work
1. Update `internal/promptdebug.ensureSecureDirectory` so concurrent creation
of the same missing directory is idempotent. If `os.Mkdir` reports that the
path already exists, inspect the path with `Lstat` and accept it only when it
is the expected real directory. Continue to reject symlinks, non-directories,
unsafe modes, and every unrelated filesystem error.
2. Preserve the existing absolute-path, containment, `0700` directory, `0600`
file, and no-symlink guarantees. Do not weaken debug-root validation or make
all `EEXIST` errors successful.
3. Add a focused prompt-debug concurrency regression that starts multiple
writers beneath a shared missing ancestor, joins every goroutine, and
verifies every expected artifact and permission invariant.
4. Make comparison execution test barriers time-bounded and failure-aware. A
callback failure before executor entry must fail the test promptly rather
than leave `waitForProfileStarts` waiting forever.
5. Retain distinct deterministic debug references and profile-local debug
failure behavior.
### Tests And Exit Criteria
- The focused prompt-debug concurrency test passes repeatedly and with the race
detector.
- `TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences`
cannot hang when a profile fails before reaching the fake executor.
- Run, at minimum:
```sh
GOWORK=off go test -count=100 ./internal/promptdebug
GOWORK=off go test -count=100 -run TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences ./internal/app
GOWORK=off go test -race -count=1 ./internal/promptdebug ./internal/app
GOWORK=off go test -count=1 ./...
```
## Stage 12: Make Replacement Authorization Commit-Safe
### Goal
Prevent a destination changed after the final read-only preflight from being
treated as the previously authorized empty directory or recognized bundle and
then deleted during replacement.
### Work
1. Replace `DestinationPlan.Exists` as the publication decision with an
explicit destination-state classification: absent, empty real directory, or
recognized current-schema bundle. Keep `Replace` in the plan so publication
can apply the same authorization policy at commit time.
2. Continue to call `PlanDestination` immediately before publication. For an
absent target, install staging with one rename; a concurrently created
target must cause that rename to fail without modifying the new target.
3. For an existing target, rename that exact filesystem entry to the unique
sibling backup before deleting or installing anything. Classify the moved
backup while it is at its stable backup path and authorize it under the
original replacement policy:
- an empty real directory is allowed with or without `Replace`;
- a recognized current-schema comparison bundle is allowed only with
`Replace`; and
- a file, symlink, unrecognized/nonempty directory, unreadable entry, or
other classification failure is not allowed.
4. Treat this post-move classification as the destructive-action
authorization point. If it fails, restore the moved entry to the target and
return an error without installing staging. If the target has concurrently
reappeared or restoration otherwise fails, retain the backup and return an
actionable joined or typed error that identifies its recovery path; never
delete either entry to force restoration.
5. Install staging only after the moved target has passed authorization. Never
remove a backup that did not pass post-move authorization.
6. Preserve the existing final cancellation linearization rule: cancellation
observed before the rename transaction prevents replacement; after the
transaction starts, finish commit or rollback rather than abandoning it.
7. Add a package-private filesystem-operation seam only if needed for
deterministic tests. Keep the public destination and publication APIs free
of test-only hooks.
### Tests And Exit Criteria
- Deterministically replace an initially accepted destination after final
preflight but before its move with each consequential unauthorized type:
unrelated nonempty directory, regular file, and symlink. Publication must
fail, staging must not become the target, and the moved entry must be restored
or retained at a reported recovery path.
- Cover an initially empty directory whose contents change before its move and
a recognized bundle swapped for an unrecognized directory.
- Retain coverage for absent targets, empty directories, recognized bundle
replacement, cancellation before commit, install failure, successful
rollback, failed rollback, and cleanup of ordinary staging failures.
- Run `GOWORK=off go test -race -count=1 ./internal/comparison` and the
repository-wide standard test command.
## Stage 13: Represent Committed Publication Cleanup Failures Accurately
### Goal
Keep application and CLI results truthful when the new comparison bundle has
been committed but removal of the old sibling backup fails.
### Work
1. Change comparison publication to return a dependency-neutral result as well
as an error:
```go
type PublicationResult struct {
Committed bool
RetainedBackupPath string
}
func Publish(
ctx context.Context,
plan DestinationPlan,
bundle LogicalBundle,
) (PublicationResult, error)
```
2. Define `Committed` as meaning the complete staged bundle is now installed
at the target. Pre-commit, staging, authorization, install, and successful-
rollback failures return `Committed == false`. A successful install returns
`Committed == true` even if later backup cleanup fails.
3. Add a typed post-commit cleanup error that unwraps its filesystem cause and
records the retained backup path for operator recovery. On this error,
return `Committed == true` and the absolute retained backup path. Do not
roll back or remove the newly committed valid bundle merely because old
backup cleanup failed.
4. In `app.CompareDetailed`, populate `ManifestPath`, `DataPackagePath`, and
successful profile `ReportPath` values whenever publication reports
`Committed == true`, before returning any cleanup error.
5. Treat post-commit cleanup failure as a command-level operational failure:
return the non-nil structured result plus an error, produce status `failed`,
and exit nonzero even though the published artifact paths are present. The
ordinary safe JSON error must not contain the raw filesystem cause or backup
path; the wrapped diagnostic returned on stderr may identify the retained
backup for recovery.
6. Keep `RetainedBackupPath` out of the versioned comparison manifest. It
describes an incomplete local transaction cleanup, not the logical bundle.
### Tests And Exit Criteria
- Inject a deterministic backup-removal failure after successful installation
and assert the target is the new recognized bundle, the old bundle remains
at the reported backup, `Committed` is true, and the error is inspectable by
type.
- At the application boundary, assert all committed artifact paths are
absolute and populated while the method still returns an error.
- At the CLI boundary, assert status `failed`, nonzero return, present artifact
paths, and a bounded generic safe error with no raw filesystem detail.
- Retain tests showing every pre-commit or rolled-back failure omits published
artifact paths.
- Run comparison, application, and CLI tests with `-race`, then the
repository-wide standard test command.
## Stage 14: Complete Structured Failure Metadata And Classification
### Goal
Make every non-nil comparison result a reliable description of the attempted
run and preserve useful safe error categories in the top-level CLI summary.
### Work
1. In `app.CompareDetailed`, assign the absolute resolved
`OutputDirectory` immediately after output-directory resolution and before
destination preflight. Do not wait for `PlanDestination` to succeed.
2. Once the initial `ComparisonResult` exists, guarantee that every return path
sets a nonzero UTC `FinishedAt` that is not before `StartedAt`. Use one
centralized finalization path or a defer; do not scatter timestamp writes
across individual failures.
3. Continue to omit manifest, data-package, and report paths until publication
commits. Preserve whatever prompt identity fields have actually been
resolved; never invent a hash or profile result for a phase that did not
run.
4. Update `safeComparisonSummaryError` to use this stable mapping, always
passing messages through `comparison.NewSafeError`:
| Error | Category | Safe message |
| --- | --- | --- |
| aggregate profile failure | `application` | existing bounded aggregate message |
| `context.Canceled` | `canceled` | `comparison canceled` |
| `context.DeadlineExceeded` | `deadline_exceeded` | `comparison deadline exceeded` |
| categorized `promptexec` error | exact `promptexec.CategoryOf` value | `comparison prompt operation failed` |
| `comparison.DestinationError` | `destination_<kind>` | `comparison destination preflight failed` |
| post-commit cleanup error | `publication_cleanup` | `comparison published but cleanup did not complete` |
| any unknown error | `application` | `comparison did not complete` |
5. Apply the most specific mapping before a more general wrapped match. In
particular, detect the post-commit cleanup and destination types before
falling back to a nested filesystem or context cause.
6. Do not copy `DestinationError.Target`, wrapped causes, or arbitrary
`error.Error()` text into normal JSON. Detailed returned errors remain
available on stderr and through Go error inspection.
### Tests And Exit Criteria
- Add application tests for destination-preflight, debug initialization,
prompt-preflight, collection, and preparation failures. Whenever a non-nil
result is returned, assert an absolute output directory, nonzero ordered UTC
timestamps, and omission of unpublished artifact paths.
- Add table-driven CLI tests for every mapping row, including wrapped errors,
and assert that unsafe sentinel text cannot enter serialized output.
- Preserve existing ordered profile-level categories and safe messages.
- Run application and CLI tests with `-race`, then the repository-wide standard
test command.
## Stage 15: Remove Temporary Seams, Reconcile Documentation, And Validate
### Goal
Remove review-discovered maintenance debt, document the corrected implemented
behavior in its canonical owners, and complete the release-equivalent gate.
### Work
1. Remove the unused `Runner.resolveComparison` wrapper. Remove the
test-oriented `Runner.executeComparison` seam if it has no production
caller, and rewrite its remaining coverage through `Runner.Run`,
`resolveComparisonAction`, or another stable behavioral boundary.
2. Remove `comparisonProfileOutcome.err` if production code still does not use
it. Keep raw failures in returned/wrapped errors or explicit internal error
types; do not retain an otherwise dead field solely for private test
assertions.
3. Correct the `internal/comparison` package comment so it describes the
package's actual ownership of both logical comparison contracts and
filesystem destination/publication behavior.
4. Update only the canonical current-state documents affected by Stages 11
through 14:
- `docs/internal/comparison-publication.md` owns post-move authorization,
commit state, rollback, retained backups, and cleanup mechanics;
- `docs/internal/comparison-execution.md` owns concurrency and debug-write
behavior;
- `docs/internal/app-orchestration.md` owns partial results and committed
publication error handling;
- `docs/cli.md` owns structured status, safe category, path, and exit
behavior; and
- `docs/operations.md` owns operator recovery for a retained sibling backup.
Link rather than duplicating complete contracts, and update architecture or
integration documentation only if its existing invariant is inaccurate.
5. Mark this plan `Complete` and restore the feature roadmap's implemented
status after every exit criterion below passes. Retain or remove the two
roadmap documents only according to a later maintainer-directed roadmap
cleanup; do not archive them as a second current-state reference in this
stage.
### Tests And Exit Criteria
- Confirm no production-only helper or field remains solely to support tests,
and no test loses meaningful behavioral coverage during cleanup.
- Verify changed relative links and fenced examples. Search current-state docs
for stale claims about comparison publication, debug behavior, results, or
recovery.
- Run the release-equivalent local gate:
```sh
set -eu
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod; then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
unformatted="$(git ls-files '*.go' | while IFS= read -r file; do gofmt -l "$file"; done)"
test -z "$unformatted"
git diff --check
```
- Run `GOWORK=off go run ./cmd/weatherreporter --help` and
`GOWORK=off go run ./cmd/weatherreporter compare --help` without credentials
or network access, and confirm that help agrees with `docs/cli.md`.
- Inspect the final diff for accidental generated artifacts, secrets,
workspaces, vendored dependencies, release notes, or unrelated changes.
## Open Questions
None. The roadmap and the contracts in Stages 11 through 15 define the
remaining decisions needed to complete the corrective work.

View File

@@ -1,398 +0,0 @@
# LLM Profile Comparison Roadmap
Status: Implemented; retained as the feature roadmap.
## Purpose
Prompt development currently requires separate Weatherreporter invocations to
compare several LLM profiles. Those invocations may collect different weather
snapshots or rebuild inputs at different times, making model output harder to
compare and slowing prompt iteration.
Weatherreporter should provide a first-class `compare` command that resolves
one report, prepares one exact data package, executes the same prompt and data
package concurrently through several explicitly selected Promptkit profiles,
and publishes a self-contained local comparison bundle.
An illustrative invocation is:
```sh
weatherreporter compare daily \
--date 2026-08-24 \
--profile weather-light \
--profile weather-balanced \
--profile weather-deep
```
This is a prompt-development workflow, not an automated model evaluator. Its
output gives a maintainer consistent evidence for human comparison without
assigning scores or selecting a winner.
## Prerequisite
Configurable output directories are implemented. Profile comparison must reuse
the current [configuration reference](../config.md) and [operations
guide](../operations.md) rather than introduce a second destination policy.
## User Intent
The command is intended for deliberate evaluation of multiple profiles,
including sets of eight to twelve candidate models. Concurrency is part of the
feature, not a future optimization. Promptkit should retain ownership of
backend-specific capacity, while Weatherreporter owns comparison-wide
coordination, cancellation, deterministic results, and artifact publication.
Every profile must receive byte-for-byte identical prompt input. Weather data,
derived facts, modules, prompt metadata, and serialized YAML must not be
recollected or rebuilt separately for individual profiles.
Comparison bundles are explicitly requested, operator-owned development
outputs. They are not Weatherreporter state, are never read implicitly by a
later run, and do not weaken the ordinary stateless execution model.
## Command Contract
The command form is:
```text
weatherreporter compare REPORT [options]
```
`REPORT` accepts the implemented generated-text reports: `daily`, `today`,
`tomorrow`, and `hourly`. Report-date behavior matches `generate`: `daily`
requires `--date`, `today` may accept an explicit date or use the current local
date, and the remaining report types retain their existing period policies.
The command accepts the applicable common generation options, including
`--config`, `--units`, `--tz`, `--date`, `--llm-debug-dir`, and `--quiet`, plus:
- repeatable `--profile PROFILE_ID` selections;
- `--out-dir PATH` for the exact comparison-bundle directory; and
- `--replace` to authorize guarded replacement of a recognized existing
comparison bundle.
At least two distinct, nonblank profile IDs are required. Their command-line
order is significant and is preserved in filenames, summaries, and
`comparison.json`. Duplicate profile IDs are rejected rather than silently
deduplicated or executed twice.
Profiles are always explicit for this command. `promptkit.profile` does not add
or replace a comparison selection, but all other effective Promptkit settings,
profile-source precedence, local backend configuration, credential lookup, and
profile overrides remain in force.
The initial feature has no Weatherreporter-specific concurrency flag or
artificial profile-count ceiling. The explicit profile list bounds the
comparison, and Promptkit owns capacity enforcement for each selected backend.
## Preparation And Execution Invariants
A comparison has this logical lifecycle:
1. Parse and validate the report, date, profile list, configuration, output
destination, and replacement authorization.
2. Resolve the report definition, valid period, prompt identity, and default
output name once.
3. Inspect the exact prompt once and preflight every selected profile,
including its effective backend, model, and required credential
availability, before weather collection.
4. Collect weather data exactly once.
5. Build collected and derived facts, the module snapshot, briefing metadata,
and the prompt data package exactly once.
6. Marshal the data package to one immutable YAML byte sequence exactly once.
7. Execute the exact prompt version concurrently for every selected profile,
passing the same immutable YAML bytes to every execution.
8. Validate and render each profile result independently from the shared
deterministic inputs.
9. Assemble results in requested-profile order and publish one coherent
comparison bundle.
This lifecycle describes the required end-state behavior rather than an
implementation-stage sequence.
No profile execution may cause recollection, report re-resolution, module
rebuilding, or data-package remarshalling. Prompt execution may perform
Promptkit-owned validation or repair behavior, but Weatherreporter does not
retry a failed comparison execution independently.
## Concurrency And Cancellation
Weatherreporter starts one execution for each preflighted profile and permits
them to run concurrently through one shared, concurrency-safe Promptkit
executor. Promptkit's engine-local backend pools remain authoritative for
backend concurrency and waiting capacity. Profiles routed to a limited local
backend therefore respect its configured limit, while profiles routed to
other backends may proceed independently.
Weatherreporter must not add a second semaphore that obscures or overrides
Promptkit's backend policy. It must safely coordinate goroutine lifecycles,
result collection, debug callbacks, and output assembly without data races.
One profile failure does not cancel its peers. Provider, capacity, validation,
and rendering failures are recorded for that profile while other executions
continue. Cancellation or deadline expiration of the comparison command is
propagated to every outstanding execution, prevents new publication, and is
joined without leaking goroutines.
Completion order must not affect filenames, manifest order, CLI summaries, or
error aggregation. Those outputs always follow the original `--profile`
order.
## Output Destination
Without `--out-dir`, Weatherreporter derives a comparison directory from the
resolved report's existing default Markdown filename by removing `.md` and
prefixing `comparison-`:
| Report output | Comparison directory |
| --- | --- |
| `today.md` | `comparison-today/` |
| `tomorrow.md` | `comparison-tomorrow/` |
| `hourly.md` | `comparison-hourly/` |
| `daily-2026-08-24.md` | `comparison-daily-2026-08-24/` |
The derived directory is created beneath `output.directory` when configured,
or beneath the present working directory otherwise. An explicit `--out-dir`
is the exact bundle directory, resolves relative to the present working
directory when necessary, and overrides `output.directory` completely.
All destination selection and validation completes before weather collection.
The resolved comparison directory is returned in the command's structured
result.
## Comparison Bundle
A successful three-profile comparison has a flat layout:
```text
comparison-daily-2026-08-24/
├── comparison.json
├── data-package.yml
├── 01-weather-light.md
├── 02-weather-balanced.md
└── 03-weather-deep.md
```
`data-package.yml` contains the exact YAML bytes passed to every Promptkit
execution. It is written once and its SHA-256 digest is recorded in the
manifest.
Each report filename begins with its one-based, zero-padded selection position
and a filesystem-safe representation of the requested logical profile ID. The
safe representation must not permit absolute paths, traversal, separators, or
control characters. The manifest retains the exact case-sensitive profile ID,
so filename normalization never becomes the authority for profile identity.
`comparison.json` is the authoritative index for the bundle. It uses an
explicit schema version and records safe comparison information including:
- comparison identity and start and finish timestamps;
- report ID, resolved valid period, and effective timezone;
- prompt ID, version, and inspected prompt hash;
- the relative data-package filename and SHA-256 digest;
- total, succeeded, and failed profile counts; and
- one ordered result per requested profile containing the exact profile ID,
resolved backend and model, relative report filename when present,
execution and validation status, and safe error information when failed.
The manifest and normal command summary must not contain credentials, provider
request bodies, raw model output, rendered prompts, schemas, provider
endpoints, or other content-rich diagnostics. The explicit data package and
generated reports contain the development material the user requested and
must be handled as operator-owned potentially sensitive output.
## Failure And Publication Policy
Failure before concurrent execution, including invalid profiles, missing
credentials, collection failure, preparation failure, or unsafe destination,
publishes no comparison bundle and performs no model calls where the failure
is discoverable during preflight.
After execution begins, Weatherreporter waits for every non-cancelled profile.
If one or more profiles fail, it still publishes a coherent partial bundle
containing `data-package.yml`, every successfully rendered report, and a
manifest describing all successes and failures. It then returns a non-zero
exit status. A failed profile has no report file unless a future contract
explicitly introduces a separately named diagnostic artifact.
Bundle contents are staged outside the destination and published only after
the manifest is complete. Ordinary publication accepts only an absent or empty
target directory. A nonempty existing directory fails without modification
unless `--replace` is present.
`--replace` may replace only the exact resolved target and must reject broad or
unsafe targets such as a filesystem root, the present working directory, a
symlink, or an unrecognized nonempty directory. A recognized prior bundle must
contain a valid Weatherreporter comparison manifest. Replacement publishes the
new complete or coherent partial bundle as a unit, prevents stale reports from
the prior comparison from surviving, and preserves or restores the prior
bundle if the final replacement operation fails.
An interrupted or cancelled comparison does not replace an existing bundle.
Temporary staging artifacts are cleaned up on ordinary failure and
cancellation without scanning or modifying unrelated directories.
## Prompt Debugging
The existing `--llm-debug-dir` mechanism remains available. Concurrent
comparison executions require distinct, deterministic debug identities that
include the comparison and exact profile selection so callbacks cannot collide
or overwrite another profile's artifacts.
Debug writing must be concurrency-safe and retain the existing permission,
redaction, explicit-opt-in, and path-containment guarantees. Debug artifacts
remain separate from the comparison bundle; the bundle does not implicitly
enable full Promptkit diagnostics.
## Notification Policy
Profile comparisons never invoke Distributor notification, even when
notification is enabled in the effective configuration. Comparison reports
are local development artifacts rather than ordinary report publications.
Adding comparison publication or upload behavior would require a separate
accepted feature scope and explicit operator authorization.
## Architectural End State
Application orchestration exposes a reusable prepared-report boundary that
contains the resolved report, shared collected and derived facts, module
snapshot, briefing metadata, generated-text handler, render inputs, and exact
serialized data package. That boundary is immutable during concurrent profile
execution.
Ordinary `generate` behavior continues to prepare once and execute once.
`compare` prepares once and executes many without duplicating the generation
workflow or calling `GenerateDetailed` in a loop. Shared preparation,
profile-specific Promptkit execution, structured-output validation, rendering,
and artifact publication remain distinct responsibilities.
The Promptkit adapter remains the only owner of dependency-specific types and
engine calls. The CLI owns parsing and user-facing summaries. The configuration
package owns configuration. Application orchestration owns comparison order,
concurrency lifecycle, failure aggregation, and bundle publication. Domain,
prompt-input, generated-text, and template packages retain their existing
deterministic contracts.
## Scope
The completed feature includes:
- the `compare` CLI command for every implemented generated-text report;
- repeatable explicit profile selection and validation;
- configured and CLI output-directory integration through the implemented
destination policy;
- one-time report resolution, collection, deterministic preparation, and YAML
serialization;
- concurrent execution through one Promptkit executor with backend capacity
respected;
- independent validation and rendering with deterministic ordered results;
- the flat, versioned comparison-bundle contract;
- safe filename derivation and data-package hashing;
- coherent partial-result publication and non-zero failure behavior;
- guarded whole-bundle replacement through `--replace`;
- comparison-aware, concurrency-safe optional prompt debugging;
- explicit suppression of Distributor notification;
- structured normal and quiet-mode CLI behavior consistent with existing
commands;
- focused race-safe tests across configuration, CLI, application,
Promptkit-adapter, rendering, and filesystem boundaries; and
- updates to every affected canonical user, operator, architecture,
integration, and internal document.
## Compatibility
The feature is additive. Existing `generate` and `run` commands, report
definitions, profile defaults, configuration, output filenames, notification
behavior, and exit contracts remain unchanged.
The comparison manifest and bundle layout begin as versioned contracts. They
do not become inputs accepted by Weatherreporter, and no backward-compatible
replay or long-term archive guarantee is implied beyond identifying the schema
used to interpret a produced bundle.
## Testing Expectations
Tests should provide durable coverage for:
- report and date parsing consistent with `generate`;
- rejection of fewer than two profiles, blanks, and duplicates;
- inspection of the exact prompt and every profile before collection;
- no collection or model execution after a preflight failure;
- exactly one weather collection and one preparation for several profiles;
- byte-for-byte identical data-package input in every execution;
- observable concurrent execution through a concurrency-safe fake executor;
- respect for Promptkit-owned backend capacity in an assembled adapter test
where that integration adds distinct confidence;
- deterministic filenames, manifest order, summaries, and errors under varied
completion order;
- continuation and coherent partial publication after one profile fails;
- cancellation propagation, goroutine completion, and preservation of an
existing destination;
- destination precedence and each derived default directory;
- safe filename handling for unusual valid profile IDs;
- absent, empty, occupied, symlinked, unsafe, recognized, and unrecognized
replacement targets;
- removal of stale prior report files during authorized whole-bundle
replacement;
- exact package digest and manifest/result consistency;
- concurrency-safe, non-colliding opt-in debug artifacts; and
- absence of Distributor calls for complete and partial comparisons.
Concurrency and replacement behavior require race-enabled and consequential
failure-path coverage. Tests must remain deterministic, offline, credential
free, and independent of real Promptkit providers or machine-specific paths.
## Documentation End State
Once implemented, the [CLI reference](../cli.md) owns command syntax, flags,
summary, and exit behavior. The [operations guide](../operations.md) owns the
bundle lifecycle, replacement procedure, sensitivity guidance, and practical
prompt-comparison workflow. The [architecture policy](../policy/architecture.md)
owns the statelessness, concurrency, notification, and publication invariants.
The [Promptkit integration guide](../integrations/promptkit.md) should describe
the consumer-visible multi-profile execution boundary without duplicating
Promptkit's backend-capacity reference. The versioned manifest and flat bundle
format belong in a focused contract under `docs/integrations/`. App
orchestration, prompt input, generated text, prompt debugging, and bundle
publication mechanics belong in focused documents under `docs/internal/`.
Current-state documentation must not describe profile comparison as available
until the implementation lands.
## Non-Goals
This roadmap does not introduce:
- automatic model scoring, ranking, recommendation, or winner selection;
- semantic or textual diff generation between reports;
- repeated sampling of one profile or statistical evaluation;
- prompt or profile editing through Weatherreporter;
- replaying a saved data package as command input;
- comparing several report types in one command;
- Weatherreporter-owned backend concurrency or queue configuration;
- automatic retries beyond Promptkit's existing execution contract;
- Distributor upload or other external publication;
- comparison history, indexing, retention, cleanup schedules, or implicit
discovery of prior bundles; or
- changes to ordinary report content or normal generation behavior.
Any later automated evaluation, replay, sampling, or publication feature
requires a separate accepted roadmap.
## Completion Criteria
The feature is complete when a maintainer can select several Promptkit
profiles, have them execute concurrently against one exact prepared report
package, and receive a safe, flat, deterministic comparison bundle whose
manifest accurately describes every success and failure. Configured and
explicit destinations must follow the accepted output policy, replacement must
never mix or silently destroy unrelated contents, cancellation and partial
failure must be race-safe, ordinary notification must remain disabled, and all
affected canonical documentation must describe the implemented behavior.
## Open Questions
None. The scope, prerequisites, user intent, and target behavior are defined
above.

View File

@@ -20,8 +20,9 @@ source:
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
The matching schemas and Promptkit definitions are embedded by
`internal/promptassets`. The generated-text catalog pairs each schema ID with
its template ID; keep the matching prompt definition aligned with that pair.
`internal/promptassets`. The generated-text catalog requires each report's
exact schema/template pair; keep the matching prompt definition aligned with
that report-specific triple.
Shared partials are under `internal/reporttemplate/templates/partials/`:
@@ -46,11 +47,18 @@ from rendering.
calculations, source selection, or prompt-input shaping to a template.
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
in generated prose merely to compensate for a template change.
- Render every `.GeneratedText` value through `plainText`. It preserves prose
and paragraph breaks while escaping Markdown and HTML syntax, removing code
indentation, and replacing control characters. Never interpolate generated
prose directly: repository templates alone own headings, lists, links, and
other Markdown structure.
- When changing the generated-prose contract, update the matching prompt,
schema, validator, render context, and template together. The validation and
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
`.Modules.DerivedDaypartSummaries`, which is a map.
`.Modules.DerivedDaypartSummaries`, which is a map. The Today partial uses
`.Modules.HasDaypartDetails` to ensure its heading has either rows or the
explicit no-details fallback.
Minimal optional-value pattern:
@@ -66,7 +74,7 @@ Minimal list pattern:
```gotemplate
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ plainText . }}
{{ end }}
```
@@ -79,6 +87,7 @@ Templates have these helpers in addition to Go template built-ins:
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
| `plainText` | a generated prose string | a readable plain-text rendering that preserves paragraph breaks without allowing dynamic Markdown or HTML structure |
For example, the alert partial uses the first two functions to decide whether
to render the section:
@@ -91,7 +100,7 @@ to render the section:
## Render Context
Every rendered template receives one typed context with these five top-level
Every rendered template receives one typed context with these three top-level
fields:
| Field | Purpose |
@@ -99,12 +108,6 @@ fields:
| `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
| `.Collected` | Normalized upstream facts for advanced use. |
| `.Derived` | Shared calculated facts for advanced use. |
`.Collected` and `.Derived` are available for an exceptional display need, but
they are lower-level contracts. Keep reusable weather derivation in Go and use
the module surface for normal template work.
### Report Metadata
@@ -126,13 +129,15 @@ It is not a source for deterministic weather facts.
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
| --- | --- | --- | --- |
| `.GeneratedText.Summary` | `string` | `string` | Required. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
| `.GeneratedText.Summary` | `string` | `string` | Required; at most 4,000 characters. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; Hourly permits 12,000 characters. Day-style values permit up to 12 paragraphs of 4,000 characters each. |
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field, at most 4,000 characters; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
The JSON schema rejects unknown properties and defines the required fields, but
the schema body and validation behavior are documented in [Generated Text
internals](internal/generatedtext.md).
internals](internal/generatedtext.md). All validated generated prose together
is limited to 20,000 characters, so template edits can rely on a bounded prose
surface.
### Deterministic Module Values
@@ -141,8 +146,9 @@ Module pointers can be nil when their source or policy permits omission.
| Module field | Available in |
| --- | --- |
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
| `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
| `.Modules.HasDaypartDetails` | Today |
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
| `.Modules.TodayPlanning` | Today |
| `.Modules.TomorrowPlanning` | Tomorrow |

7
go.mod
View File

@@ -7,9 +7,8 @@ require gopkg.in/yaml.v3 v3.0.1
require (
gitea.maximumdirect.net/eric/distributor v0.5.0
gitea.maximumdirect.net/eric/promptkit v0.5.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/sys v0.45.0
)
require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/text v0.14.0 // indirect
)
require golang.org/x/text v0.14.0 // indirect

View File

@@ -2,10 +2,12 @@
package distributor
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
@@ -22,6 +24,7 @@ type Client struct {
TokenEnv string
Timeout time.Duration
newUploadClient uploadClientFactory
pollWait func(context.Context, time.Duration) error
}
type UploadRequest struct {
@@ -107,6 +110,10 @@ type runStatus struct {
const statusPollInterval = 250 * time.Millisecond
const maxDistributorResponseBytes int64 = 1 << 20
var errDistributorResponseTooLarge = fmt.Errorf("distributor response exceeds the %d-byte limit", maxDistributorResponseBytes)
func New(cfg config.DistributorNotifyConfig) *Client {
return newClient(cfg, newDistributorUploadClient)
}
@@ -120,6 +127,7 @@ func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory)
TokenEnv: cfg.TokenEnv,
Timeout: cfg.Timeout,
newUploadClient: factory,
pollWait: waitForPoll,
}
}
@@ -201,7 +209,12 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
Status: result.Status,
UploadStatus: result.Status,
}
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
pollWait := c.pollWait
if pollWait == nil {
pollWait = waitForPoll
}
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0, pollWait)
status = sanitizeRunStatus(status)
if status.RunID != "" || status.Status != "" {
uploadResult.RunStatus = &RunStatus{
RunID: status.RunID,
@@ -218,7 +231,7 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
}
}
if statusErr != nil {
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
uploadResult.StatusError = safeDistributorDiagnostic(statusErr, token).Error()
return uploadResult, nil
}
if status.Status == "failed" {
@@ -227,19 +240,15 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
return uploadResult, nil
}
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool, wait func(context.Context, time.Duration) error) (runStatus, error) {
status, err := client.Status(ctx, runID)
if err != nil || terminalRunStatus(status.Status) || !poll {
return status, err
}
for {
timer := time.NewTimer(statusPollInterval)
select {
case <-ctx.Done():
timer.Stop()
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
case <-timer.C:
if err := wait(ctx, statusPollInterval); err != nil {
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, err)
}
next, err := client.Status(ctx, runID)
@@ -253,6 +262,17 @@ func waitForRunStatus(ctx context.Context, client uploadClient, runID string, po
}
}
func waitForPoll(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func terminalRunStatus(status string) bool {
return status == "succeeded" || status == "failed"
}
@@ -261,10 +281,52 @@ type distributorUploadClient struct {
client *distributorupload.Client
}
type boundedResponseTransport struct {
base http.RoundTripper
limit int64
}
func (t boundedResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
response, err := base.RoundTrip(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, t.limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > t.limit {
return nil, errDistributorResponseTooLarge
}
response.Body = io.NopCloser(bytes.NewReader(data))
response.ContentLength = int64(len(data))
return response, nil
}
type RemoteResponseError struct {
StatusCode int
Retryable bool
}
func (e *RemoteResponseError) Error() string {
if e == nil || e.StatusCode == 0 {
return "distributor request failed"
}
return fmt.Sprintf("distributor request failed with HTTP status %d", e.StatusCode)
}
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
httpClient := (*http.Client)(nil)
httpClient := &http.Client{
Transport: boundedResponseTransport{base: http.DefaultTransport, limit: maxDistributorResponseBytes},
}
if timeout > 0 {
httpClient = &http.Client{Timeout: timeout}
httpClient.Timeout = timeout
}
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
Endpoint: endpoint,
@@ -306,7 +368,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
if err != nil {
return runStatus{}, err
}
return runStatus{
return sanitizeRunStatus(runStatus{
RunID: status.RunID,
PipelineID: status.PipelineID,
Status: status.Status,
@@ -315,7 +377,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
FinishedAt: status.FinishedAt,
Report: append(json.RawMessage(nil), status.Report...),
Error: status.Error,
}, nil
}), nil
}
type uploadErrorContext struct {
@@ -331,7 +393,7 @@ type uploadErrorContext struct {
func wrapUploadError(err error, ctx uploadErrorContext) error {
var conflict *distributorupload.IdempotencyConflictError
isConflict := errors.As(err, &conflict)
err = redactToken(err, ctx.Token)
err = safeDistributorDiagnostic(err, ctx.Token)
if isConflict {
return &IdempotencyConflictError{
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
@@ -340,6 +402,31 @@ func wrapUploadError(err error, ctx uploadErrorContext) error {
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
}
func safeDistributorDiagnostic(err error, token string) error {
if err == nil {
return nil
}
if errors.Is(err, errDistributorResponseTooLarge) {
return errDistributorResponseTooLarge
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return redactToken(err, token)
}
var httpErr *distributorupload.HTTPError
if errors.As(err, &httpErr) {
return &RemoteResponseError{StatusCode: httpErr.StatusCode, Retryable: httpErr.Retryable}
}
return errors.New("distributor request failed")
}
func sanitizeRunStatus(status runStatus) runStatus {
status.Report = nil
if status.Error != "" {
status.Error = "distributor reported a failed run"
}
return status
}
func uploadSourcePaths(files []UploadFile) []string {
paths := make([]string, 0, len(files))
for _, file := range files {

View File

@@ -0,0 +1,310 @@
package distributor
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
const oversizedRemoteDiagnostic = "REMOTE-DIAGNOSTIC"
func TestUploadUsesProductionHTTPBoundary(t *testing.T) {
const token = "test-upload-token"
var uploadCalls, statusCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/prefix/v1/pipelines/weather/upload":
uploadCalls++
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
t.Fatalf("authorization = %q", got)
}
if got := r.Header.Get("Idempotency-Key"); got != "bundle-key" {
t.Fatalf("idempotency key = %q", got)
}
if got := r.Header.Get("Content-Type"); got != "application/gzip" {
t.Fatalf("content type = %q", got)
}
verifyUploadedArchive(t, r.Body, "daily/report.md", "report body")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
case r.Method == http.MethodGet && r.URL.Path == "/prefix/runs/run-123":
statusCalls++
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
t.Fatalf("authorization = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":{"detail":"REMOTE-DETAIL"}}`)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := productionClient(t, server.URL+"/prefix", token)
result, err := client.Upload(context.Background(), productionUploadRequest(t))
if err != nil || uploadCalls != 1 || statusCalls != 1 || result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" || result.RunStatus == nil || result.RunStatus.PipelineID != "weather" || len(result.RunStatus.Report) != 0 {
t.Fatalf("result/error/calls = %#v/%v/%d/%d", result, err, uploadCalls, statusCalls)
}
}
func TestUploadClassifiesRemoteHTTPDiagnostics(t *testing.T) {
const token = "test-upload-token"
const remote = oversizedRemoteDiagnostic
for _, tt := range []struct {
name string
handle func(http.ResponseWriter, *http.Request)
check func(t *testing.T, result UploadResult, err error)
}{
{
name: "upload failure",
handle: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"REMOTE-DIAGNOSTIC","retryable":true}`)
},
check: func(t *testing.T, _ UploadResult, err error) {
t.Helper()
var remoteErr *RemoteResponseError
if err == nil || !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest || !remoteErr.Retryable {
t.Fatalf("error = %T %v", err, err)
}
},
},
{
name: "status failure",
handle: func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
return
}
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, remote)
},
check: func(t *testing.T, result UploadResult, err error) {
t.Helper()
if err != nil || result.Status != "accepted" || result.StatusError != "distributor request failed with HTTP status 500" {
t.Fatalf("result/error = %#v/%v", result, err)
}
},
},
{
name: "failed run",
handle: func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
return
}
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"failed","error":"REMOTE-DIAGNOSTIC","report":{"detail":"REMOTE-DIAGNOSTIC"}}`)
},
check: func(t *testing.T, result UploadResult, err error) {
t.Helper()
if err == nil || result.Status != "failed" || result.RunStatus == nil || result.RunStatus.Error != "distributor reported a failed run" || len(result.RunStatus.Report) != 0 {
t.Fatalf("result/error = %#v/%v", result, err)
}
},
},
} {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(tt.handle))
defer server.Close()
result, err := productionClient(t, server.URL, token).Upload(context.Background(), productionUploadRequest(t))
tt.check(t, result, err)
for _, value := range []string{fmt.Sprint(result), fmt.Sprint(err)} {
if strings.Contains(value, remote) || strings.Contains(value, token) {
t.Fatalf("normal diagnostic leaked remote value: %q", value)
}
}
})
}
}
func TestUploadBoundsHTTPResponses(t *testing.T) {
for _, tt := range []struct {
name string
response func(size int) string
statusCode int
statusBody func(size int) string
check func(t *testing.T, result UploadResult, err error, overflow bool)
}{
{
name: "accepted response",
response: func(size int) string {
return paddedJSON(t, `{"run_id":"run-123","status":"accepted","detail":"REMOTE-DIAGNOSTIC"}`, size)
},
statusBody: func(_ int) string {
return `{"run_id":"run-123","status":"succeeded"}`
},
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
t.Helper()
if overflow {
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
t.Fatalf("overflow result/error = %#v/%v", result, err)
}
return
}
if err != nil || result.Status != "succeeded" {
t.Fatalf("bounded result/error = %#v/%v", result, err)
}
},
},
{
name: "status report",
response: func(_ int) string {
return `{"run_id":"run-123","status":"accepted"}`
},
statusBody: func(size int) string { return statusReportBody(t, size) },
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
t.Helper()
if overflow {
if err != nil || result.Status != "accepted" || result.StatusError != errDistributorResponseTooLarge.Error() {
t.Fatalf("overflow result/error = %#v/%v", result, err)
}
return
}
if err != nil || result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
t.Fatalf("bounded result/error = %#v/%v", result, err)
}
},
},
{
name: "error response",
response: func(size int) string { return repeatedToLength(oversizedRemoteDiagnostic, size) },
statusCode: http.StatusBadRequest,
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
t.Helper()
if overflow {
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
t.Fatalf("overflow result/error = %#v/%v", result, err)
}
return
}
var remoteErr *RemoteResponseError
if !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest {
t.Fatalf("bounded result/error = %#v/%v", result, err)
}
},
},
} {
for _, overflow := range []bool{false, true} {
t.Run(tt.name+"/"+map[bool]string{false: "limit", true: "over-limit"}[overflow], func(t *testing.T) {
size := int(maxDistributorResponseBytes)
if overflow {
size++
}
var uploadCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
uploadCalls++
statusCode := tt.statusCode
if statusCode == 0 {
statusCode = http.StatusAccepted
}
w.WriteHeader(statusCode)
_, _ = io.WriteString(w, tt.response(size))
return
}
_, _ = io.WriteString(w, tt.statusBody(size))
}))
defer server.Close()
result, err := productionClient(t, server.URL, "test-upload-token").Upload(context.Background(), productionUploadRequest(t))
tt.check(t, result, err, overflow)
if strings.Contains(fmt.Sprint(result), oversizedRemoteDiagnostic) || strings.Contains(fmt.Sprint(err), oversizedRemoteDiagnostic) {
t.Fatalf("result/error leaked oversized response detail: %#v/%v", result, err)
}
if uploadCalls != 1 {
t.Fatalf("upload calls = %d, want one", uploadCalls)
}
})
}
}
}
func productionClient(t *testing.T, endpoint, token string) *Client {
t.Helper()
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = endpoint
cfg.Timeout = 0
t.Setenv(cfg.TokenEnv, token)
return New(cfg)
}
func productionUploadRequest(t *testing.T) UploadRequest {
t.Helper()
path := filepath.Join(t.TempDir(), "report.md")
if err := os.WriteFile(path, []byte("report body"), 0o600); err != nil {
t.Fatal(err)
}
return UploadRequest{
PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "bundle-key",
Files: []UploadFile{{SourcePath: path, BundlePath: "daily/report.md"}},
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC),
}
}
func verifyUploadedArchive(t *testing.T, body io.Reader, wantPath, wantContents string) {
t.Helper()
reader, err := gzip.NewReader(body)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
archive := tar.NewReader(reader)
for {
header, err := archive.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatal(err)
}
if header.Name != wantPath {
continue
}
contents, err := io.ReadAll(archive)
if err != nil || string(contents) != wantContents {
t.Fatalf("archive file contents/error = %q/%v", contents, err)
}
return
}
t.Fatalf("archive did not contain %q", wantPath)
}
func paddedJSON(t *testing.T, value string, size int) string {
t.Helper()
if len(value) > size {
t.Fatalf("JSON length = %d, exceeds requested size %d", len(value), size)
}
return value + strings.Repeat(" ", size-len(value))
}
func statusReportBody(t *testing.T, size int) string {
t.Helper()
const prefix = `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":"`
const suffix = `"}`
if len(prefix)+len(suffix) > size {
t.Fatalf("status response exceeds requested size %d", size)
}
return prefix + repeatedToLength(oversizedRemoteDiagnostic, size-len(prefix)-len(suffix)) + suffix
}
func repeatedToLength(value string, size int) string {
return strings.Repeat(value, size/len(value)+1)[:size]
}

View File

@@ -45,8 +45,8 @@ func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
t.Fatalf("result = %#v, want accepted run", result)
}
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || len(result.RunStatus.Report) != 0 {
t.Fatalf("RunStatus = %#v, want safe status details", result.RunStatus)
}
if factory.endpoint != cfg.Endpoint {
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
@@ -242,13 +242,14 @@ func TestUploadPollsUntilTerminalStatus(t *testing.T) {
},
}
client := newClient(cfg, factory.newClient)
client.pollWait = func(context.Context, time.Duration) error { return nil }
result, err := client.Upload(context.Background(), validUploadRequest())
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
if result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
t.Fatalf("result = %#v, want terminal succeeded status without remote report", result)
}
if factory.client.statusCalls != 2 {
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
@@ -298,8 +299,8 @@ func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
if err == nil {
t.Fatal("Upload() error = nil, want failed distributor run error")
}
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
t.Fatalf("result = %#v, want failed run status report", result)
if result.RunStatus == nil || result.RunStatus.Status != "failed" || len(result.RunStatus.Report) != 0 || result.RunStatus.Error != "distributor reported a failed run" {
t.Fatalf("result = %#v, want safe failed run status", result)
}
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)

View File

@@ -9,6 +9,7 @@ import (
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
@@ -23,6 +24,7 @@ type Config struct {
}
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
// It supports concurrent Execute calls on the shared executor.
type Adapter struct {
engine *promptkit.Engine
}
@@ -193,6 +195,17 @@ func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.E
value.Validation.SchemaPath,
value.Validation.Errors,
)
rawOutput := []byte(nil)
if len(value.RawOutput) <= generatedtext.MaxGeneratedTextBytes {
rawOutput = []byte(value.RawOutput)
} else {
validation = promptexec.NewValidation(
promptexec.ValidationFailed,
string(value.Validation.Mode),
value.Validation.SchemaPath,
[]string{"generated output exceeds the configured size limit"},
)
}
execution := &promptexec.Execution{
RunID: value.RunID,
PromptID: value.PromptID,
@@ -215,11 +228,11 @@ func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.E
EndedAt: value.EndTime,
Duration: value.Duration,
Validation: validation,
RawOutput: []byte(value.RawOutput),
RawOutput: rawOutput,
}
if captureDebug {
execution.Debug = &promptexec.ExecutionDebug{
RawOutput: append([]byte(nil), value.RawOutput...),
RawOutput: append([]byte(nil), rawOutput...),
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
}
}
@@ -253,7 +266,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
TimeoutSeconds int `json:"timeout_seconds"`
ServiceTier string `json:"service_tier"`
ReasoningEffort string `json:"reasoning_effort"`
ExtraParams map[string]any `json:"extra_params"`
}{
Temperature: value.Temperature,
MaxTokens: value.MaxTokens,
@@ -261,7 +273,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
TimeoutSeconds: value.TimeoutSeconds,
ServiceTier: value.ServiceTier,
ReasoningEffort: value.ReasoningEffort,
ExtraParams: value.ExtraParams,
}
data, _ := json.Marshal(parameters)
return data

View File

@@ -12,6 +12,7 @@ import (
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
@@ -22,6 +23,7 @@ type fakeClient struct {
calls int
requests []promptkit.GenerateRequest
block bool
started chan struct{}
}
type recordingReader struct {
@@ -44,9 +46,13 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
client.calls++
client.requests = append(client.requests, request)
block := client.block
started := client.started
response := client.response
err := client.err
client.mu.Unlock()
if started != nil {
started <- struct{}{}
}
if block {
<-ctx.Done()
return nil, ctx.Err()
@@ -54,6 +60,36 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
return response, err
}
func TestExecuteSupportsConcurrentCalls(t *testing.T) {
client := &fakeClient{response: validResponse(), block: true, started: make(chan struct{}, 2)}
adapter := newTestAdapter(t, client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
executionErrors := make(chan error, 2)
for range 2 {
go func() {
_, err := adapter.Execute(ctx, testExecuteRequest(), nil)
executionErrors <- err
}()
}
for range 2 {
select {
case <-client.started:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent Promptkit calls")
}
}
cancel()
for range 2 {
if err := <-executionErrors; promptexec.CategoryOf(err) != promptexec.Canceled {
t.Fatalf("Execute() error/category = %v/%q", err, promptexec.CategoryOf(err))
}
}
if client.callCount() != 2 {
t.Fatalf("provider calls = %d, want 2", client.callCount())
}
}
func (client *fakeClient) callCount() int {
client.mu.Lock()
defer client.mu.Unlock()
@@ -316,6 +352,30 @@ func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
}
}
func TestMarshalDebugParametersOmitsProviderExtras(t *testing.T) {
const marker = "private-debug-marker"
parameters := string(marshalDebugParameters(promptkit.ExecutionTarget{
Temperature: 0.2,
MaxTokens: 400,
TopP: 0.9,
TimeoutSeconds: 30,
ServiceTier: "flex",
ReasoningEffort: "high",
ExtraParams: map[string]any{
"access-key": marker,
"signature": marker,
},
}))
if strings.Contains(parameters, marker) || strings.Contains(parameters, "extra_params") {
t.Fatalf("debug parameters leaked provider extras: %s", parameters)
}
for _, want := range []string{`"temperature":0.2`, `"max_tokens":400`, `"top_p":0.9`, `"timeout_seconds":30`, `"service_tier":"flex"`, `"reasoning_effort":"high"`} {
if !strings.Contains(parameters, want) {
t.Fatalf("debug parameters missing safe value %q: %s", want, parameters)
}
}
}
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
@@ -340,6 +400,23 @@ func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
}
}
func TestExecuteDropsOversizedGeneratedOutput(t *testing.T) {
client := &fakeClient{response: &promptkit.GenerateResponse{Content: strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1)}}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
request.CaptureDebug = true
result, err := adapter.Execute(context.Background(), request, nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.RawOutput) != 0 || result.Debug == nil || len(result.Debug.RawOutput) != 0 {
t.Fatalf("execution = %#v", result)
}
if len(result.Validation.Diagnostics) != 1 || result.Validation.Diagnostics[0] != "generated output exceeds the configured size limit" {
t.Fatalf("diagnostics = %#v", result.Validation.Diagnostics)
}
}
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
tests := []struct {
name string
@@ -431,6 +508,7 @@ func TestNewValidatesConfiguration(t *testing.T) {
}
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
profiles := testProfileDirectory(t, `id: local-profile
backend: local
model: local-model

View File

@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -14,24 +15,28 @@ import (
"path"
"strconv"
"strings"
"sync"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
currentConditionsEndpoint = "/conditions/current"
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
defaultWarmupEndpoint = "/conditions/current"
defaultWarmupEndpoint = currentConditionsEndpoint
defaultWarmupAttempts = 3
defaultWarmupDelay = time.Second
defaultFetchAttempts = 2
defaultFetchRetryDelay = time.Second
maxResponseBodyBytes = 10 << 20
)
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
type Client struct {
baseURL *url.URL
httpClient *http.Client
@@ -75,6 +80,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
}
if !strings.EqualFold(baseURL.Scheme, "http") && !strings.EqualFold(baseURL.Scheme, "https") {
return nil, fmt.Errorf("weather_api.base_url must use http or https")
}
timeout := cfg.WeatherAPI.Timeout
if timeout <= 0 {
@@ -106,7 +114,8 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
}
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := c.warmup(ctx); err != nil {
warmup, err := c.warmup(ctx)
if err != nil {
return nil, err
}
@@ -114,32 +123,15 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
builder := bundleBuilder{
client: c,
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
if err := builder.fetchObservation(ctx); err != nil {
for _, acquired := range builder.acquireSources(ctx, warmup) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("fetch weather API sources: %w", err)
}
if err := builder.mergeSource(acquired); err != nil {
return nil, err
}
if err := builder.fetchCurrent(ctx); err != nil {
return nil, err
}
if err := builder.fetchHourly(ctx); err != nil {
return nil, err
}
if err := builder.fetchNarrative(ctx); err != nil {
return nil, err
}
if err := builder.fetchAlerts(ctx); err != nil {
return nil, err
}
if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err
}
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil
@@ -148,7 +140,6 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
type bundleBuilder struct {
client *Client
bundle *weatherdata.Bundle
fetchedAt time.Time
}
type sourceRequest struct {
@@ -165,14 +156,81 @@ type fetchedSource struct {
source weatherdata.Source
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
type warmupResponse struct {
endpoint string
requestURL *url.URL
body []byte
fetchedAt time.Time
}
type sourceAcquisition struct {
request sourceRequest
fetched fetchedSource
err error
warmup warmupResponse
usesWarmup bool
}
func (b *bundleBuilder) acquireSources(ctx context.Context, warmup warmupResponse) []sourceAcquisition {
sources := []sourceAcquisition{
{request: sourceRequest{name: config.MissingSourceObservations, endpoint: "/observations", query: queryOptions{precision: true}, missingMessage: "observation data is missing"}},
{request: currentConditionsRequest()},
{request: sourceRequest{name: "hourly", endpoint: "/forecast/hourly", query: queryOptions{precision: true, timezone: true}, missingMessage: "hourly forecast data is missing", required: true, decodeLabel: "hourly forecast"}},
{request: sourceRequest{name: config.MissingSourceNarrative, endpoint: "/forecast/narrative", query: queryOptions{precision: true, timezone: true}, missingMessage: "narrative forecast data is missing"}},
{request: sourceRequest{name: config.MissingSourceAlerts, endpoint: "/alerts/active", query: queryOptions{allowNull: true}, missingMessage: "active alerts data is missing"}},
{request: sourceRequest{name: config.MissingSourceDiscussion, endpoint: "/discussion", query: queryOptions{timezone: true}, missingMessage: "forecast discussion data is missing"}},
{request: sourceRequest{name: config.MissingSourceWeatherStory, endpoint: "/weatherstories/latest", query: queryOptions{omitUnits: true}, missingMessage: "NWS weather story data is missing"}},
{request: sourceRequest{name: sourceSPCConvectiveOutlooks, endpoint: convectiveOutlooksEndpoint, query: queryOptions{timezone: true, omitUnits: true}, missingMessage: "SPC convective outlook data is missing"}},
}
if warmup.endpoint == currentConditionsEndpoint {
sources[1].warmup = warmup
sources[1].usesWarmup = true
}
var group sync.WaitGroup
for i := range sources {
if sources[i].usesWarmup {
continue
}
group.Add(1)
go func(index int) {
defer group.Done()
request := sources[index].request
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
sources[index].fetched = fetchedSource{raw: raw, source: source}
sources[index].err = err
}(i)
}
group.Wait()
return sources
}
func (b *bundleBuilder) mergeSource(acquired sourceAcquisition) error {
switch acquired.request.name {
case config.MissingSourceObservations:
return b.fetchObservation(acquired)
case config.MissingSourceCurrent:
return b.fetchCurrent(acquired)
case "hourly":
return b.fetchHourly(acquired)
case config.MissingSourceNarrative:
return b.fetchNarrative(acquired)
case config.MissingSourceAlerts:
return b.fetchAlerts(acquired)
case config.MissingSourceDiscussion:
return b.fetchDiscussion(acquired)
case config.MissingSourceWeatherStory:
return b.fetchWeatherStory(acquired)
case sourceSPCConvectiveOutlooks:
return b.fetchSPCConvectiveOutlooks(acquired)
default:
return fmt.Errorf("merge unknown weather source %q", acquired.request.name)
}
}
func (b *bundleBuilder) fetchObservation(acquired sourceAcquisition) error {
var observation weatherdata.Observation
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "observations",
endpoint: "/observations",
query: queryOptions{precision: true},
missingMessage: "observation data is missing",
}, &observation)
fetched, ok, err := b.fetchDecodedSource(acquired, &observation)
if err != nil || !ok {
return err
}
@@ -183,14 +241,18 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "current",
endpoint: "/conditions/current",
func currentConditionsRequest() sourceRequest {
return sourceRequest{
name: config.MissingSourceCurrent,
endpoint: currentConditionsEndpoint,
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}, &current)
}
}
func (b *bundleBuilder) fetchCurrent(acquired sourceAcquisition) error {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(acquired, &current)
if err != nil || !ok {
return err
}
@@ -200,16 +262,9 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
func (b *bundleBuilder) fetchHourly(acquired sourceAcquisition) error {
var hourly weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "hourly",
endpoint: "/forecast/hourly",
query: queryOptions{precision: true, timezone: true},
missingMessage: "hourly forecast data is missing",
required: true,
decodeLabel: "hourly forecast",
}, &hourly)
fetched, ok, err := b.fetchDecodedSource(acquired, &hourly)
if err != nil || !ok {
return err
}
@@ -217,6 +272,14 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
}
for i, period := range hourly.Periods {
if !period.HasUsableTimeBounds() {
return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1)
}
if !period.HasValidPrecipitationProbability() {
return fmt.Errorf("hourly forecast from %s has invalid precipitation probability for period %d", source.Endpoint, i+1)
}
}
source.IssuedAt = &hourly.IssuedAt
source.UpdatedAt = hourly.UpdatedAt
b.bundle.Hourly = &hourly
@@ -224,14 +287,9 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
func (b *bundleBuilder) fetchNarrative(acquired sourceAcquisition) error {
var narrative weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "narrative",
endpoint: "/forecast/narrative",
query: queryOptions{precision: true, timezone: true},
missingMessage: "narrative forecast data is missing",
}, &narrative)
fetched, ok, err := b.fetchDecodedSource(acquired, &narrative)
if err != nil || !ok {
return err
}
@@ -243,24 +301,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
if err != nil {
func (b *bundleBuilder) fetchAlerts(acquired sourceAcquisition) error {
fetched, ok, err := b.fetchSource(acquired)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false)
}
raw, source := fetched.raw, fetched.source
if isJSONNull(raw) {
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.bundle.Alerts = &weatherdata.AlertRun{}
b.addSource(source)
return nil
}
var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
return b.handleMalformed(&source, err, acquired.request)
}
alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil {
source.IssuedAt = alerts.AsOf
}
@@ -269,14 +324,9 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
func (b *bundleBuilder) fetchDiscussion(acquired sourceAcquisition) error {
var discussion weatherdata.Discussion
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "discussion",
endpoint: "/discussion",
query: queryOptions{timezone: true},
missingMessage: "forecast discussion data is missing",
}, &discussion)
fetched, ok, err := b.fetchDecodedSource(acquired, &discussion)
if err != nil || !ok {
return err
}
@@ -288,18 +338,16 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
func (b *bundleBuilder) fetchWeatherStory(acquired sourceAcquisition) error {
var story weatherdata.WeatherStory
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "weather_story",
endpoint: "/weatherstories/latest",
query: queryOptions{omitUnits: true},
missingMessage: "NWS weather story data is missing",
}, &story)
fetched, ok, err := b.fetchDecodedSource(acquired, &story)
if err != nil || !ok {
return err
}
source := fetched.source
if !story.HasUsableContent() {
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), acquired.request)
}
if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime
}
@@ -309,14 +357,9 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(acquired sourceAcquisition) error {
var run weatherdata.ConvectiveOutlookRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: sourceSPCConvectiveOutlooks,
endpoint: convectiveOutlooksEndpoint,
query: queryOptions{timezone: true, omitUnits: true},
missingMessage: "SPC convective outlook data is missing",
}, &run)
fetched, ok, err := b.fetchDecodedSource(acquired, &run)
if err != nil || !ok {
return err
}
@@ -332,26 +375,35 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
fetched, ok, err := b.fetchSource(ctx, request)
func (b *bundleBuilder) fetchDecodedSource(acquired sourceAcquisition, target any) (fetchedSource, bool, error) {
fetched, ok, err := b.fetchSource(acquired)
if err != nil || !ok {
return fetchedSource{}, false, err
}
return b.decodeFetchedSource(fetched, acquired.request, target)
}
func (b *bundleBuilder) decodeFetchedSource(fetched fetchedSource, request sourceRequest, target any) (fetchedSource, bool, error) {
if err := decodeSource(fetched.raw, target); err != nil {
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
}
return fetched, true, nil
}
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
func (b *bundleBuilder) fetchSource(acquired sourceAcquisition) (fetchedSource, bool, error) {
if acquired.usesWarmup {
raw, source, err := b.client.decodeSourceResponse(acquired.request.name, acquired.request.endpoint, acquired.request.query, acquired.warmup.requestURL, acquired.warmup.body, acquired.warmup.fetchedAt)
if err != nil {
return fetchedSource{}, false, err
}
if raw == nil {
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
acquired.fetched = fetchedSource{raw: raw, source: source}
} else if acquired.err != nil {
return fetchedSource{}, false, acquired.err
}
return fetchedSource{raw: raw, source: source}, true, nil
if acquired.fetched.raw == nil {
return fetchedSource{}, false, b.handleMissing(&acquired.fetched.source, acquired.request.missingMessage, acquired.request.required)
}
return acquired.fetched, true, nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
@@ -422,7 +474,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
if err != nil {
return nil, weatherdata.Source{}, err
}
return c.decodeSourceResponse(sourceName, endpoint, opts, reqURL, body, c.now())
}
func (c *Client) decodeSourceResponse(sourceName string, endpoint string, opts queryOptions, reqURL *url.URL, body []byte, fetchedAt time.Time) (json.RawMessage, weatherdata.Source, error) {
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
@@ -432,7 +487,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
Name: sourceName,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
FetchedAt: c.now(),
FetchedAt: fetchedAt,
}
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
source.Missing = true
@@ -446,53 +501,40 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func (c *Client) warmup(ctx context.Context) error {
func (c *Client) warmup(ctx context.Context) (warmupResponse, error) {
endpoint := c.warmupEndpoint
if strings.TrimSpace(endpoint) == "" {
endpoint = defaultWarmupEndpoint
}
attempts := positiveAttemptCount(c.warmupAttempts)
var lastErr error
var lastRetryable bool
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
return warmupResponse{}, fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
if err := c.warmupOnce(ctx, endpoint); err != nil {
reqURL, body, err := c.warmupOnce(ctx, endpoint)
if err != nil {
lastErr = err
lastRetryable = isRetryableRequestError(err)
} else {
return nil
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
}
if attempt == attempts {
if !lastRetryable || attempt == attempts {
break
}
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
return warmupResponse{}, fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
if !lastRetryable {
return warmupResponse{}, lastErr
}
return warmupResponse{}, fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
func (c *Client) warmupOnce(ctx context.Context, endpoint string) (*url.URL, []byte, error) {
return c.fetchHTTPOnce(ctx, endpoint, queryOptions{precision: true})
}
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
@@ -539,16 +581,12 @@ func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryO
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
body, err := readResponseBody(resp.Body)
if err != nil {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
return reqURL, nil, responseReadError(ctx, endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
err := fmt.Errorf("fetch %s: unexpected HTTP status %d", endpoint, resp.StatusCode)
if isRetryableHTTPStatus(resp.StatusCode) {
return reqURL, nil, retryableRequestError{err: err}
}
@@ -557,6 +595,25 @@ func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryO
return reqURL, body, nil
}
func readResponseBody(body io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, maxResponseBodyBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxResponseBodyBytes {
return nil, errResponseBodyTooLarge
}
return data, nil
}
func responseReadError(ctx context.Context, endpoint string, err error) error {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if errors.Is(err, errResponseBodyTooLarge) || ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
}
type retryableRequestError struct {
err error
}
@@ -659,10 +716,3 @@ func sourceHash(raw json.RawMessage) (string, error) {
sum := sha256.Sum256(compact.Bytes())
return hex.EncodeToString(sum[:]), nil
}
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err)
}
return nil
}

View File

@@ -3,11 +3,13 @@ package weatherapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -15,6 +17,12 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFetchBundleFromFixtures(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
@@ -80,8 +88,8 @@ func TestFetchBundleFromFixtures(t *testing.T) {
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths)+1 {
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
if len(requested) != len(wantPaths) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
}
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
@@ -91,6 +99,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
t.Fatalf("requested paths = %v, want %s", requested, want)
}
}
if got := countPath(requested, currentConditionsEndpoint); got != 1 {
t.Fatalf("conditions/current requests = %d, want 1; requested paths = %v", got, requested)
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
}
@@ -105,6 +116,191 @@ func TestFetchBundleFromFixtures(t *testing.T) {
}
}
func TestFetchBundleMergesConcurrentSourcesInSourceOrder(t *testing.T) {
paths := []string{
"/observations",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
started := make(chan string, len(paths))
release := make(map[string]chan struct{}, len(paths))
for _, path := range paths {
release[path] = make(chan struct{})
}
var releaseOnce sync.Once
releaseAll := func() {
releaseOnce.Do(func() {
for i := len(paths) - 1; i >= 0; i-- {
close(release[paths[i]])
}
})
}
t.Cleanup(releaseAll)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == currentConditionsEndpoint {
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
}
return
}
ready, ok := release[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
started <- r.URL.Path
<-ready
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server.URL+"/", nil)
type fetchResult struct {
bundle *weatherdata.Bundle
err error
}
result := make(chan fetchResult, 1)
go func() {
bundle, err := client.FetchBundle(context.Background())
result <- fetchResult{bundle: bundle, err: err}
}()
seen := make(map[string]bool, len(paths))
for range paths {
select {
case path := <-started:
seen[path] = true
case <-time.After(time.Second):
t.Fatalf("independent requests started = %v, want %v", seen, paths)
}
}
releaseAll()
select {
case got := <-result:
if got.err != nil {
t.Fatalf("FetchBundle() error = %v", got.err)
}
wantSources := []string{
config.MissingSourceObservations,
config.MissingSourceCurrent,
"hourly",
config.MissingSourceNarrative,
config.MissingSourceAlerts,
config.MissingSourceDiscussion,
config.MissingSourceWeatherStory,
sourceSPCConvectiveOutlooks,
}
gotSources := make([]string, 0, len(got.bundle.Sources))
for _, source := range got.bundle.Sources {
gotSources = append(gotSources, source.Name)
}
if strings.Join(gotSources, ",") != strings.Join(wantSources, ",") {
t.Fatalf("source order = %v, want %v", gotSources, wantSources)
}
case <-time.After(time.Second):
t.Fatal("FetchBundle() did not finish after all source responses were released")
}
}
func TestFetchBundleReportsConcurrentFailuresInSourceOrder(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusBadRequest, body: `invalid hourly request`},
"/forecast/narrative": {status: http.StatusBadRequest, body: `invalid narrative request`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want source error")
}
if !strings.Contains(err.Error(), "/forecast/hourly") {
t.Fatalf("error = %q, want the earlier hourly source failure", err.Error())
}
if !containsPath(requested, "/forecast/narrative") {
t.Fatalf("requested paths = %v, want independent narrative request", requested)
}
}
func TestFetchBundleCancelsConcurrentSourceRequests(t *testing.T) {
paths := []string{
"/observations",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
started := make(chan string, len(paths))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == currentConditionsEndpoint {
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
}
return
}
for _, path := range paths {
if r.URL.Path == path {
started <- path
<-r.Context().Done()
return
}
}
http.NotFound(w, r)
}))
defer server.Close()
client := newTestClient(t, server.URL+"/", nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
_, err := client.FetchBundle(ctx)
result <- err
}()
for range paths {
select {
case <-started:
case <-time.After(time.Second):
cancel()
t.Fatal("not all independent requests started before cancellation")
}
}
cancel()
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("FetchBundle() error = %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("FetchBundle() did not return after cancellation")
}
}
func TestFetchBundleRejectsInvalidHourlyPrecipitationProbability(t *testing.T) {
for _, probability := range []string{"-1", "101"} {
t.Run(probability, func(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z","probabilityOfPrecipitationPercent":` + probability + `}]}}`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") {
t.Fatalf("FetchBundle() error = %v, want invalid precipitation probability", err)
}
})
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
@@ -194,8 +390,9 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
}
func TestHTTPErrorIsActionable(t *testing.T) {
const marker = "upstream-secret-marker"
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
"/forecast/hourly": {status: http.StatusBadGateway, body: marker + strings.Repeat("x", 4096)},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
@@ -206,6 +403,9 @@ func TestHTTPErrorIsActionable(t *testing.T) {
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error())
}
if strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
}
}
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
@@ -231,8 +431,8 @@ func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
if bundle.Current == nil {
t.Fatal("Current = nil, want successful fetch after warmup retry")
}
if warmupCalls != 3 {
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
if warmupCalls != 2 {
t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", warmupCalls)
}
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
@@ -265,6 +465,111 @@ func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
}
}
func TestWarmupDoesNotRetryPermanentStatus(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusNotFound, body: `not found`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("FetchBundle() error = %v, want non-retryable warmup status", err)
}
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
t.Fatalf("warmup requests = %d, want 1; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestWarmupErrorDiagnosticsRedactResponseBody(t *testing.T) {
const marker = "upstream-secret-marker"
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusNotFound, body: marker + strings.Repeat("x", 4096)},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want warmup error")
}
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "404") {
t.Fatalf("error = %q, want warmup endpoint and status", err.Error())
}
if strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
}
}
func TestFetchAcceptsResponseAtBodyLimit(t *testing.T) {
body := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes))
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
if _, err := client.FetchBundle(context.Background()); err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
}
func TestFetchRejectsOversizedResponseWithoutRetry(t *testing.T) {
var requested []string
var narrativeCalls int
oversizedBody := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) + "x"
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
narrativeCalls++
_, _ = w.Write([]byte(oversizedBody))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want oversized response error")
}
if !strings.Contains(err.Error(), "/forecast/narrative") || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
t.Fatalf("error = %q, want endpoint and response limit", err.Error())
}
if narrativeCalls != 1 {
t.Fatalf("narrative calls = %d, want no retry", narrativeCalls)
}
if !containsPath(requested, "/alerts/active") {
t.Fatalf("requested paths = %v, want independent source requests despite narrative failure", requested)
}
}
func TestWarmupRejectsOversizedResponseWithoutRetry(t *testing.T) {
var requested []string
oversizedBody := paddedJSON(t, `{"data":{}}`, int(maxResponseBodyBytes)) + "x"
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(oversizedBody))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
client.warmupAttempts = 2
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want oversized warmup response error")
}
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
t.Fatalf("error = %q, want warmup endpoint and response limit", err.Error())
}
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
t.Fatalf("warmup requests = %d, want no retry; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestFetchRetriesRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
@@ -312,6 +617,40 @@ func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
}
}
func TestNewValidatesWeatherAPIBaseURLSchemeWithoutRequests(t *testing.T) {
requests := 0
httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
requests++
return nil, errors.New("unexpected request")
})}
tests := []struct {
name string
baseURL string
wantErr string
}{
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testConfig(tt.baseURL)
_, err := New(cfg, WithHTTPClient(httpClient))
if tt.wantErr == "" {
if err != nil {
t.Fatalf("New() error = %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("New() error = %v, want %q", err, tt.wantErr)
}
})
}
if requests != 0 {
t.Fatalf("HTTP requests = %d, want none", requests)
}
}
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
@@ -351,6 +690,66 @@ func TestRequiredHourlyForecast(t *testing.T) {
}
}
func TestRequiredHourlyForecastValidatesPeriodBounds(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
}{
{
name: "valid period",
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z"}]}}`,
},
{
name: "missing start",
body: `{"data":{"periods":[{"endTime":"2026-05-29T14:00:00Z"}]}}`,
wantErr: true,
},
{
name: "missing end",
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z"}]}}`,
wantErr: true,
},
{
name: "empty range",
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
wantErr: true,
},
{
name: "reversed range",
body: `{"data":{"periods":[{"startTime":"2026-05-29T14:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: tt.body},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "hourly forecast") || !strings.Contains(err.Error(), "time bounds") {
t.Fatalf("FetchBundle() error = %v, want hourly time-bounds failure", err)
}
if got := countPath(requested, "/forecast/hourly"); got != 1 {
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
}
return
}
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 {
t.Fatalf("Hourly = %#v, want accepted hourly period", bundle.Hourly)
}
})
}
}
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
@@ -542,6 +941,44 @@ func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
}
}
func TestEmptyWeatherStoryUsesPolicy(t *testing.T) {
for _, tt := range []struct {
name string
policy config.MissingSourcePolicy
wantErr bool
}{
{name: "warn", policy: config.MissingSourceWarn},
{name: "error", policy: config.MissingSourceError, wantErr: true},
} {
t.Run(tt.name, func(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"weather_story": tt.policy,
})
bundle, err := client.FetchBundle(context.Background())
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "weather story has no usable content") {
t.Fatalf("FetchBundle() error = %v, want unusable weather story error", err)
}
return
}
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.WeatherStory != nil {
t.Fatalf("WeatherStory = %#v, want nil for empty source", bundle.WeatherStory)
}
source := sourceByName(t, bundle.Sources, "weather_story")
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
}
})
}
}
func TestContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
@@ -618,36 +1055,13 @@ func TestHTTPTimeout(t *testing.T) {
}
}
func TestSaveBundle(t *testing.T) {
server := fixtureServer(t, nil, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
if err := SaveBundle(path, bundle); err != nil {
t.Fatalf("SaveBundle() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved bundle: %v", err)
}
if !strings.Contains(string(data), `"hourly"`) {
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
}
}
type handlerOverride struct {
status int
body string
handler http.HandlerFunc
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
fixtures := map[string]string{
var weatherFixtureFiles = map[string]string{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
@@ -656,10 +1070,25 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
}
func serveWeatherFixture(w http.ResponseWriter, r *http.Request) bool {
name, ok := weatherFixtureFiles[r.URL.Path]
if !ok {
return false
}
http.ServeFile(w, r, filepath.Join("testdata", name))
return true
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
var requestedMu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
requestedMu.Lock()
*requested = append(*requested, r.URL.String())
requestedMu.Unlock()
}
if override, ok := overrides[r.URL.Path]; ok {
if override.handler != nil {
@@ -670,12 +1099,9 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
_, _ = w.Write([]byte(override.body))
return
}
name, ok := fixtures[r.URL.Path]
if !ok {
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, filepath.Join("testdata", name))
}))
t.Cleanup(server.Close)
return server
@@ -706,6 +1132,14 @@ func fixedNow() time.Time {
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
}
func paddedJSON(t *testing.T, value string, size int) string {
t.Helper()
if len(value) > size {
t.Fatalf("JSON value length = %d, exceeds requested size %d", len(value), size)
}
return value + strings.Repeat(" ", size-len(value))
}
func containsPath(requested []string, path string) bool {
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {

View File

@@ -3,6 +3,7 @@ package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
@@ -66,6 +67,7 @@ type BatchRequest struct {
type ModuleSnapshotRequest struct {
Config config.Config
Resolved report.Resolved
Identity briefing.PreparedIdentity
}
type ReportFacts struct {
@@ -99,6 +101,7 @@ type BatchResult struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Canceled int `json:"canceled,omitempty"`
Notification *BatchNotificationResult `json:"notification,omitempty"`
Reports []BatchReportResult `json:"reports"`
}
@@ -142,12 +145,19 @@ type BatchReportResult struct {
type BatchError struct {
Result *BatchResult
Cause error
}
func (e BatchError) Error() string {
if e.Result == nil {
return "batch failed"
}
if errors.Is(e.Cause, context.DeadlineExceeded) {
return fmt.Sprintf("batch %s deadline exceeded", e.Result.Batch)
}
if errors.Is(e.Cause, context.Canceled) || e.Result.Canceled > 0 {
return fmt.Sprintf("batch %s canceled", e.Result.Batch)
}
failedReports := batchReportFailures(e.Result)
if batchNotificationFailed(e.Result) && failedReports == 0 {
if e.Result.Notification.Error != "" {
@@ -158,6 +168,10 @@ func (e BatchError) Error() string {
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, failedReports, len(e.Result.Reports))
}
func (e BatchError) Unwrap() error {
return e.Cause
}
func batchNotificationFailed(result *BatchResult) bool {
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
}
@@ -249,6 +263,9 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
return nil, err
}
result := initialReportResult(req, resolved, PromptInspectionResult{})
if err := preflightDistributorNotification(req.Config); err != nil {
return result, err
}
outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, req.Config.Output.Directory, resolved)
if err != nil {
return result, err
@@ -258,6 +275,7 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
if err != nil {
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
}
defer func() { _ = debugWriter.Close() }()
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
Resolved: resolved,
Executor: req.Executor,
@@ -300,6 +318,9 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
return nil, err
}
if err := preflightDistributorNotification(req.Config); err != nil {
return nil, err
}
outputDir, err := resolveOutputDirWithConfigured(req.WorkingDir, req.OutputDir, req.Config.Output.Directory)
if err != nil {
return nil, err
@@ -309,6 +330,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
}
defer func() { _ = debugWriter.Close() }()
candidates, err := batchInspectionCandidates(req, now)
if err != nil {
return nil, err
@@ -335,7 +357,12 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if req.Batch == BatchEvening || req.Batch == BatchMorning {
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, planned := range plannedReports {
var cancellation error
for index, planned := range plannedReports {
if cancellation = batchContextCancellationCause(ctx); cancellation != nil {
appendCanceledBatchReports(result, plannedReports[index:])
break
}
resolved := planned.Resolved
item := batchReportResult(planned)
reportResult, err := generatePromptReport(ctx, promptReportRequest{
@@ -355,26 +382,72 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
copyBatchReportDetails(&item, reportResult)
}
if err != nil {
if reportCancellation := batchReportCancellationCause(err); reportCancellation != nil {
cancellation = reportCancellation
item.Status = "canceled"
result.Canceled++
} else {
item.Status = "failed"
item.Error = err.Error()
result.Failed++
}
} else {
item.Status = "succeeded"
result.Succeeded++
}
result.Reports = append(result.Reports, item)
if cancellation == nil {
cancellation = batchContextCancellationCause(ctx)
}
if cancellation != nil {
appendCanceledBatchReports(result, plannedReports[index+1:])
break
}
}
result.Total = len(result.Reports)
batchNotification := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, req.Notifier)
batchNotification := notifyBatch(batchNotificationInput{
ctx: ctx, cancellation: cancellation, cfg: req.Config, batch: req.Batch,
runID: batchRunID(startedAt, req.Batch), startedAt: startedAt,
result: result, planned: plannedReports, notifier: req.Notifier,
})
if batchNotification != nil {
result.Notification = batchNotification
}
result.FinishedAt = time.Now()
return result, nil
return result, cancellation
}
return nil, fmt.Errorf("run is not implemented")
}
func batchReportCancellationCause(err error) error {
if errors.Is(err, context.Canceled) {
return context.Canceled
}
if errors.Is(err, context.DeadlineExceeded) {
return context.DeadlineExceeded
}
return nil
}
func batchContextCancellationCause(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
func appendCanceledBatchReports(result *BatchResult, plannedReports []plannedBatchReport) {
if result == nil {
return
}
for _, planned := range plannedReports {
item := batchReportResult(planned)
item.Status = "canceled"
result.Reports = append(result.Reports, item)
result.Canceled++
}
}
func copyBatchReportDetails(item *BatchReportResult, result *ReportResult) {
item.LLMDebugPath = result.LLMDebugPath
item.OutputPath = result.OutputPath
@@ -509,6 +582,16 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
}, true
}
func preflightDistributorNotification(cfg config.Config) error {
if !cfg.Notify.Distributor.Enabled {
return nil
}
if err := config.ValidateDistributorEndpoint(cfg.Notify.Distributor.Endpoint); err != nil {
return fmt.Errorf("validate notify.distributor.endpoint: %w", err)
}
return nil
}
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time) (NotificationRequest, error) {
values, err := distributorTemplateValuesForReport(cfg, resolved, runID, filepath.Base(outputPath))
if err != nil {
@@ -631,25 +714,7 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
CreatedAt: req.CreatedAt,
})
notification := &NotificationResult{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
RunID: result.RunID,
Status: result.Status,
UploadStatus: result.UploadStatus,
StatusError: result.StatusError,
}
if result.RunStatus != nil {
if result.RunStatus.PipelineID != "" {
notification.PipelineID = result.RunStatus.PipelineID
}
notification.AcceptedAt = result.RunStatus.AcceptedAt
notification.StartedAt = result.RunStatus.StartedAt
notification.FinishedAt = result.RunStatus.FinishedAt
notification.Report = append([]byte(nil), result.RunStatus.Report...)
notification.Error = result.RunStatus.Error
}
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
if err != nil {
return notification, err
}
@@ -673,7 +738,7 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
RunID: result.RunID,
Status: result.Status,
UploadStatus: result.UploadStatus,
StatusError: result.StatusError,
StatusError: safeDistributorStatusError(result.StatusError),
}
if result.RunStatus != nil {
if result.RunStatus.PipelineID != "" {
@@ -682,12 +747,25 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
notification.AcceptedAt = result.RunStatus.AcceptedAt
notification.StartedAt = result.RunStatus.StartedAt
notification.FinishedAt = result.RunStatus.FinishedAt
notification.Report = append([]byte(nil), result.RunStatus.Report...)
notification.Error = result.RunStatus.Error
notification.Error = safeDistributorRunError(result.RunStatus.Error)
}
return notification
}
func safeDistributorStatusError(value string) string {
if value == "" {
return ""
}
return "distributor status could not be confirmed"
}
func safeDistributorRunError(value string) string {
if value == "" {
return ""
}
return "distributor reported a failed run"
}
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
for _, bundlePath := range bundlePaths {
@@ -727,7 +805,12 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
if err != nil {
return module.Snapshot{}, err
}
identity := req.Identity
if identity.ReportID == "" {
identity = briefing.BuildPreparedIdentity(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
}
moduleContext := briefing.ModuleContext{
Identity: identity,
Resolved: req.Resolved,
Collected: reportFacts.Collected,
Derived: reportFacts.Derived,
@@ -759,16 +842,16 @@ func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected
}
}
func promptMetadata(metadata briefing.Metadata) promptinput.Metadata {
func promptMetadata(identity briefing.PreparedIdentity) promptinput.Metadata {
return promptinput.Metadata{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: metadata.Variant,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod,
SourceWarnings: metadata.SourceWarnings,
RunID: identity.RunID,
ReportID: identity.ReportID,
Variant: identity.Variant,
PromptID: identity.PromptID,
GeneratedAt: identity.GeneratedAt,
Timezone: identity.Timezone,
ValidPeriod: identity.ValidPeriod,
SourceWarnings: identity.SourceWarnings,
}
}

View File

@@ -9,6 +9,7 @@ import (
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
@@ -21,7 +22,7 @@ func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFa
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Canceled != 0 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
@@ -32,6 +33,107 @@ func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFa
}
}
func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{cancelBeforeReturn: cancel}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 0 || result.Canceled != 2 || executor.executeCalls != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/executor/notifier = %#v/%v/%#v/%#v", result, err, executor, notifier)
}
for _, item := range result.Reports {
if item.Status != "canceled" || item.OutputPath != "" {
t.Fatalf("canceled report = %#v", item)
}
}
}
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{
executeErr: errors.New("independent report failure"),
beforeExecute: func(promptexec.ExecuteRequest) {
cancel()
},
}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
t.Fatalf("report results = %#v", result.Reports)
}
}
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
notifier := &generationNotifier{}
result := notifyBatch(batchNotificationInput{
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
notifier: notifier,
})
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
}
}
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
t.Run(cause.Error(), func(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
ctx := &publicationGateContext{Context: context.Background(), err: cause, afterChecks: 4}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
if !errors.Is(err, cause) || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 0 || result.Canceled != 1 || len(result.Reports) != 2 || result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "canceled" || result.Reports[1].OutputPath != "" || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if _, statErr := os.Stat(result.Reports[0].OutputPath); statErr != nil {
t.Fatalf("published report %q: %v", result.Reports[0].OutputPath, statErr)
}
})
}
}
func TestRunBatchPreservesCancellationCause(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
err := RunBatch(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel}, Notifier: &generationNotifier{},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("RunBatch() error = %v", err)
}
}
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
@@ -61,6 +163,29 @@ func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
}
}
func TestRunBatchDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
outputDir := t.TempDir()
cfg := generationDistributorConfig()
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: collector, Executor: executor, Notifier: notifier,
})
if err == nil || result != nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 || notifier.batchCalls != 0 {
t.Fatalf("RunBatchDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
}
entries, readErr := os.ReadDir(outputDir)
if readErr != nil || len(entries) != 0 {
t.Fatalf("output directory entries/error = %v/%v", entries, readErr)
}
}
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(t *testing.T) {
tests := []struct {
name string

View File

@@ -42,46 +42,64 @@ type batchNotifier interface {
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
}
type batchNotificationInput struct {
ctx context.Context
cancellation error
cfg config.Config
batch BatchKind
runID string
startedAt time.Time
result *BatchResult
planned []plannedBatchReport
notifier Notifier
}
func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
}
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) *BatchNotificationResult {
if !cfg.Notify.Distributor.Enabled {
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
if !input.cfg.Notify.Distributor.Enabled {
return nil
}
if !cfg.Notify.Distributor.Batch.Enabled {
if !input.cfg.Notify.Distributor.Batch.Enabled {
return nil
}
if result == nil {
if input.result == nil {
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
}
if result.Failed > 0 {
if input.cancellation != nil || batchContextCancellationCause(input.ctx) != nil || input.result.Canceled > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "batch canceled",
}
}
if input.result.Failed > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
}
}
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
if err != nil {
return failedBatchNotificationResult(batchNotificationRequest{}, err)
}
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
if err != nil {
return failedBatchNotificationResult(req, err)
}
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
wrappedErr := notifyErr
if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", input.batch, input.runID, req.BundleID, notifyErr)
}
batchResult := batchNotificationResult(req, notification)
if wrappedErr != nil {
batchResult.Status = "failed"
batchResult.Error = wrappedErr.Error()
batchResult.Error = safeDistributorNotificationFailure(wrappedErr)
return batchResult
}
return batchResult
@@ -233,7 +251,7 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
notification.IdempotencyKey = result.IdempotencyKey
}
if result.Error != "" {
notification.Error = result.Error
notification.Error = safeDistributorRunError(result.Error)
}
}
if notification.Status == "" {
@@ -246,11 +264,18 @@ func failedBatchNotificationResult(req batchNotificationRequest, err error) *Bat
notification := batchNotificationResult(req, nil)
notification.Status = "failed"
if err != nil {
notification.Error = err.Error()
notification.Error = safeDistributorNotificationFailure(err)
}
return notification
}
func safeDistributorNotificationFailure(err error) string {
if err == nil {
return ""
}
return "distributor notification failed"
}
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
if err != nil {

View File

@@ -110,7 +110,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
return result, err
}
result.OutputDirectory = outputDirectory
_, err = comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
if err != nil {
return result, fmt.Errorf("preflight comparison destination: %w", err)
}
@@ -119,6 +119,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
if err != nil {
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
}
defer func() { _ = debugWriter.Close() }()
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor, LookupEnv: os.LookupEnv,
})
@@ -131,7 +132,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
if err != nil {
return result, err
}
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection})
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection, handler: inspection.handler})
if err != nil {
return result, fmt.Errorf("prepare comparison report: %w", err)
}
@@ -149,10 +150,6 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
if err := bundle.Validate(); err != nil {
return result, fmt.Errorf("build comparison bundle: %w", err)
}
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
if err != nil {
return result, fmt.Errorf("re-preflight comparison destination: %w", err)
}
publication, err := publish(ctx, publicationPlan, bundle)
if publication.Committed {
result.OutputDirectory = publicationPlan.Target

View File

@@ -35,11 +35,21 @@ type comparisonProfileOutcome struct {
Markdown []byte
LLMDebugPath string
Error *comparison.SafeError
canceled bool
}
type comparisonProfileExecutionState uint8
const (
comparisonProfilePending comparisonProfileExecutionState = iota
comparisonProfileRunning
comparisonProfileComplete
)
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
profiles := req.Inspection.Profiles
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
states := make([]comparisonProfileExecutionState, len(profiles))
for index, profile := range profiles {
result.Outcomes[index] = comparisonProfileOutcome{
Position: index + 1,
@@ -54,21 +64,22 @@ func executeComparisonProfiles(ctx context.Context, req comparisonExecutionReque
for index, profile := range profiles {
if err := ctx.Err(); err != nil {
result.Canceled = true
markUnstartedComparisonOutcomes(result.Outcomes[index:], err)
break
}
index, profile := index, profile
states[index] = comparisonProfileRunning
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
states[index] = comparisonProfileComplete
}()
}
waitGroup.Wait()
if err := ctx.Err(); err != nil {
result.Canceled = true
for index := range result.Outcomes {
if result.Outcomes[index].Status != comparison.StatusSucceeded {
if states[index] != comparisonProfileComplete || result.Outcomes[index].canceled {
markCanceledComparisonOutcome(&result.Outcomes[index], err)
}
}
@@ -99,6 +110,7 @@ func executeComparisonProfile(ctx context.Context, req comparisonExecutionReques
outcome.ValidationStatus = execution.ValidationStatus
outcome.LLMDebugPath = execution.LLMDebugPath
if err != nil {
outcome.canceled = cancellationError(err)
safe := comparisonSafeExecutionError(err)
outcome.Error = &safe
return outcome
@@ -119,12 +131,6 @@ func comparisonDebugRunID(comparisonID string, position, profileCount int, profi
return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID))
}
func markUnstartedComparisonOutcomes(outcomes []comparisonProfileOutcome, err error) {
for index := range outcomes {
markCanceledComparisonOutcome(&outcomes[index], err)
}
}
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
outcome.Status = comparison.StatusFailed
outcome.ValidationStatus = promptexec.ValidationSkipped
@@ -134,6 +140,12 @@ func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error)
outcome.Error = &safe
}
func cancellationError(err error) bool {
category := promptexec.CategoryOf(err)
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
category == promptexec.Canceled || category == promptexec.DeadlineExceeded
}
func comparisonSafeExecutionError(err error) comparison.SafeError {
category := promptexec.CategoryOf(err)
if category == "" {

View File

@@ -20,12 +20,9 @@ func TestExecuteComparisonProfilesRunsOrderedProfilesConcurrently(t *testing.T)
prepared, prompt := preparedDailyProfile(t)
profiles := comparisonProfiles(10)
executor := newBarrierExecutor(profiles)
results := make(chan comparisonExecutionResult, 1)
go func() {
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
})
}()
}, executor)
waitForProfileStarts(t, executor, profiles, results)
if executor.maximumInFlight() < 2 {
t.Fatalf("maximum in-flight executions = %d, want overlap", executor.maximumInFlight())
@@ -58,12 +55,9 @@ func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
profiles := comparisonProfiles(3)
executor := newBarrierExecutor(profiles)
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
results := make(chan comparisonExecutionResult, 1)
go func() {
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
})
}()
}, executor)
waitForProfileStarts(t, executor, profiles, results)
for _, profile := range profiles {
executor.release(profile.ProfileID)
@@ -84,12 +78,9 @@ func TestExecuteComparisonProfilesPropagatesCancellationAndJoins(t *testing.T) {
executor := newBarrierExecutor(profiles)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := make(chan comparisonExecutionResult, 1)
go func() {
results <- executeComparisonProfiles(ctx, comparisonExecutionRequest{
results := startComparisonExecution(t, ctx, comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
})
}()
}, executor)
waitForProfileStarts(t, executor, profiles, results)
cancel()
result := <-results
@@ -110,16 +101,16 @@ func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *te
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
}
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
executor := newBarrierExecutor(profiles)
results := make(chan comparisonExecutionResult, 1)
go func() {
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
})
}()
}, executor)
waitForProfileStarts(t, executor, profiles, results)
for _, profile := range profiles {
executor.release(profile.ProfileID)
@@ -148,18 +139,21 @@ type barrierExecutor struct {
releases map[string]chan struct{}
requests map[string]promptexec.ExecuteRequest
errors map[string]error
profiles map[string]ComparisonProfileInspection
inFlight int
maximum int
}
func newBarrierExecutor(profiles []ComparisonProfileInspection) *barrierExecutor {
releases := make(map[string]chan struct{}, len(profiles))
identities := make(map[string]ComparisonProfileInspection, len(profiles))
for _, profile := range profiles {
releases[profile.ProfileID] = make(chan struct{})
identities[profile.ProfileID] = profile
}
return &barrierExecutor{
started: make(chan string, len(profiles)), callbackFailures: make(chan error, len(profiles)), releases: releases,
requests: make(map[string]promptexec.ExecuteRequest, len(profiles)), errors: map[string]error{},
requests: make(map[string]promptexec.ExecuteRequest, len(profiles)), errors: map[string]error{}, profiles: identities,
}
}
@@ -173,7 +167,10 @@ func (e *barrierExecutor) InspectProfile(context.Context, string) (promptexec.Pr
func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", ProfileID: req.ProfileID, BackendID: "backend-" + req.ProfileID, ModelName: "model-" + req.ProfileID, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
e.mu.Lock()
profile := e.profiles[req.ProfileID]
e.mu.Unlock()
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
e.callbackFailures <- err
return nil, err
}
@@ -202,8 +199,8 @@ func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteReq
return nil, err
}
return &promptexec.Execution{
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
ProfileID: req.ProfileID, BackendID: "backend-" + req.ProfileID, ModelName: "model-" + req.ProfileID,
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash,
ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
StartedAt: stamp, EndedAt: stamp, RawOutput: comparisonRawOutput(),
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil),
}, nil
@@ -248,9 +245,32 @@ func (e *barrierExecutor) inFlightCount() int {
return e.inFlight
}
const comparisonExecutionTestTimeout = 5 * time.Second
func startComparisonExecution(t *testing.T, ctx context.Context, request comparisonExecutionRequest, executor *barrierExecutor) <-chan comparisonExecutionResult {
t.Helper()
results := make(chan comparisonExecutionResult, 1)
finished := make(chan struct{})
t.Cleanup(func() {
executor.releaseAll()
timeout := time.NewTimer(comparisonExecutionTestTimeout)
defer timeout.Stop()
select {
case <-finished:
case <-timeout.C:
t.Error("comparison execution workers did not finish after release")
}
})
go func() {
defer close(finished)
results <- executeComparisonProfiles(ctx, request)
}()
return results
}
func waitForProfileStarts(t *testing.T, executor *barrierExecutor, profiles []ComparisonProfileInspection, results <-chan comparisonExecutionResult) {
t.Helper()
timeout := time.NewTimer(5 * time.Second)
timeout := time.NewTimer(comparisonExecutionTestTimeout)
defer timeout.Stop()
seen := map[string]struct{}{}
for range profiles {

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -87,11 +88,24 @@ func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testin
}
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
for _, test := range []struct {
name string
state comparison.BackupRecoveryState
path bool
}{
{name: "complete recovery bundle", state: comparison.BackupRecoveryComplete, path: true},
{name: "partial remnants", state: comparison.BackupRecoveryPartial, path: true},
{name: "absent backup", state: comparison.BackupRecoveryAbsent},
} {
t.Run(test.name, func(t *testing.T) {
bundle := generationBundle(t)
backupPath := filepath.Join(t.TempDir(), ".comparison-daily.backup-retained")
recoveryPath := ""
if test.path {
recoveryPath = filepath.Join(t.TempDir(), ".comparison-daily.backup-recovery")
}
cleanupCause := errors.New("backup cleanup failed")
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
return comparison.PublicationResult{Committed: true, RetainedBackupPath: backupPath}, &comparison.PublicationCleanupError{RetainedBackupPath: backupPath, Err: cleanupCause}
return comparison.PublicationResult{Committed: true, RecoveryState: test.state, RecoveryPath: recoveryPath}, &comparison.PublicationCleanupError{RecoveryState: test.state, RecoveryPath: recoveryPath, Err: cleanupCause}
}
result, err := compareDetailed(context.Background(), ComparisonRequest{
@@ -101,7 +115,7 @@ func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
}, publish)
var cleanupErr *comparison.PublicationCleanupError
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RetainedBackupPath != backupPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RecoveryState != test.state || cleanupErr.RecoveryPath != recoveryPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
}
for _, profile := range result.Results {
@@ -109,6 +123,8 @@ func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T
t.Fatalf("published profile result = %#v", profile)
}
}
})
}
}
func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
@@ -292,6 +308,57 @@ func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
}
}
func TestCompareDetailedPreservesCompletedProfileFailureWhenCanceled(t *testing.T) {
bundle := generationBundle(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
failureStarted := make(chan struct{})
var signalFailure sync.Once
executor := &generationExecutor{
validations: map[string]promptexec.ValidationStatus{"weather-light": promptexec.ValidationFailed},
waitForCancellation: map[string]bool{"weather-deep": true},
beforeExecute: func(request promptexec.ExecuteRequest) {
if request.ProfileID == "weather-light" {
signalFailure.Do(func() { close(failureStarted) })
}
},
}
results := make(chan struct {
result *ComparisonResult
err error
}, 1)
go func() {
result, err := CompareDetailed(ctx, ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
results <- struct {
result *ComparisonResult
err error
}{result: result, err: err}
}()
select {
case <-failureStarted:
cancel()
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the completed profile failure")
}
completed := <-results
if !errors.Is(completed.err, context.Canceled) || completed.result == nil || completed.result.ManifestPath != "" || completed.result.DataPackagePath != "" || completed.result.Succeeded != 0 || completed.result.Failed != 2 {
t.Fatalf("CompareDetailed() result/error = %#v/%v", completed.result, completed.err)
}
failed, canceled := completed.result.Results[0], completed.result.Results[1]
if failed.Error == nil || failed.Error.Category != string(promptexec.ValidationRejected) || failed.ValidationStatus != promptexec.ValidationFailed || failed.ReportPath != "" {
t.Fatalf("completed failure = %#v", failed)
}
if canceled.Error == nil || canceled.Error.Category != string(promptexec.Canceled) || canceled.ValidationStatus != promptexec.ValidationSkipped || canceled.ReportPath != "" {
t.Fatalf("canceled profile = %#v", canceled)
}
}
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
workingDir := t.TempDir()
target := filepath.Join(workingDir, "comparison-output")

View File

@@ -0,0 +1,45 @@
package app
import (
"errors"
"strings"
"testing"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
)
func TestNotificationResultFromUploadExcludesRemoteResponseDetails(t *testing.T) {
const remote = "REMOTE-DIAGNOSTIC"
notification := notificationResultFromUpload("weather", "bundle", "key", distributoradapter.UploadResult{
RunID: "run-123", Status: "failed", UploadStatus: "accepted", StatusError: remote,
RunStatus: &distributoradapter.RunStatus{PipelineID: "weather", Status: "failed", Report: []byte(`{"detail":"REMOTE-DIAGNOSTIC"}`), Error: remote},
})
if notification == nil || notification.StatusError != "distributor status could not be confirmed" || notification.Error != "distributor reported a failed run" || len(notification.Report) != 0 {
t.Fatalf("notification = %#v", notification)
}
if strings.Contains(notification.StatusError, remote) || strings.Contains(notification.Error, remote) {
t.Fatalf("notification includes remote detail: %#v", notification)
}
}
func TestBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
const remote = "REMOTE-DIAGNOSTIC"
notification := batchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, &NotificationResult{Status: "failed", Error: remote})
if notification == nil || notification.Error != "distributor reported a failed run" {
t.Fatalf("notification = %#v", notification)
}
if strings.Contains(notification.Error, remote) {
t.Fatalf("notification includes remote detail: %#v", notification)
}
}
func TestFailedBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
const remote = "REMOTE-DIAGNOSTIC"
notification := failedBatchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, errors.New(remote))
if notification == nil || notification.Error != "distributor notification failed" {
t.Fatalf("notification = %#v", notification)
}
if strings.Contains(notification.Error, remote) {
t.Fatalf("notification includes remote detail: %#v", notification)
}
}

View File

@@ -6,14 +6,17 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -25,6 +28,25 @@ type generationCollector struct {
beforeRun func()
}
type publicationGateContext struct {
context.Context
err error
checks int
afterChecks int
}
func (c *publicationGateContext) Err() error {
c.checks++
afterChecks := c.afterChecks
if afterChecks == 0 {
afterChecks = 2
}
if c.checks >= afterChecks {
return c.err
}
return nil
}
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
if c.beforeRun != nil {
c.beforeRun()
@@ -46,8 +68,14 @@ type generationExecutor struct {
beforeExecute func(promptexec.ExecuteRequest)
cancelBeforeReturn context.CancelFunc
validation promptexec.ValidationStatus
validations map[string]promptexec.ValidationStatus
rawOutput []byte
waitForCancellation map[string]bool
failedPrompt string
skipPreparation bool
preparationCalls int
prepare func(*promptexec.Preparation)
complete func(*promptexec.Execution)
}
var generationExecutorMu sync.Mutex
@@ -71,11 +99,28 @@ func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promp
}
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
}
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
generationExecutorMu.Lock()
skipPreparation := e.skipPreparation
prepare := e.prepare
preparationCalls := e.preparationCalls
generationExecutorMu.Unlock()
if !skipPreparation {
calls := preparationCalls
if calls == 0 {
calls = 1
}
for range calls {
preparation := promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}
if prepare != nil {
prepare(&preparation)
}
if err := callback(preparation, nil); err != nil {
return nil, err
}
}
}
generationExecutorMu.Lock()
e.called = true
e.executeCalls++
@@ -83,13 +128,22 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
profileErr := e.executeErrors[req.ProfileID]
executeErr := e.executeErr
status := e.validation
if profileStatus, ok := e.validations[req.ProfileID]; ok {
status = profileStatus
}
rawOutput := append([]byte(nil), e.rawOutput...)
waitForCancellation := e.waitForCancellation[req.ProfileID]
failedPrompt := e.failedPrompt
cancelBeforeReturn := e.cancelBeforeReturn
complete := e.complete
generationExecutorMu.Unlock()
if beforeExecute != nil {
beforeExecute(req)
}
if waitForCancellation {
<-ctx.Done()
return nil, ctx.Err()
}
if profileErr != nil {
return nil, profileErr
}
@@ -108,7 +162,11 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
if cancelBeforeReturn != nil {
cancelBeforeReturn()
}
return &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}, nil
execution := &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}
if complete != nil {
complete(execution)
}
return execution, nil
}
const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -250,6 +308,48 @@ func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
}
}
func TestGenerateDetailedRejectsOverlongOutputBeforeWork(t *testing.T) {
missingDirectory := filepath.Join(t.TempDir(), "missing")
outputPath := filepath.Join(missingDirectory, strings.Repeat("a", 253)+".md")
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called {
t.Fatalf("GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v", result, err, collector.called, executor)
}
if _, statErr := os.Stat(missingDirectory); !os.IsNotExist(statErr) {
t.Fatalf("missing output directory exists after preflight failure: %v", statErr)
}
}
func TestGenerateDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
cfg := generationDistributorConfig()
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
}
if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
t.Fatalf("output exists after endpoint preflight failure: %v", statErr)
}
}
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
@@ -338,6 +438,41 @@ func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePub
}
}
func TestGenerateDetailedPreservesDestinationWhenContextChangesDuringPublication(t *testing.T) {
for _, tt := range []struct {
name string
err error
category promptexec.ErrorCategory
}{
{name: "canceled", err: context.Canceled, category: promptexec.Canceled},
{name: "deadline", err: context.DeadlineExceeded, category: promptexec.DeadlineExceeded},
} {
t.Run(tt.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx := &publicationGateContext{Context: context.Background(), err: tt.err}
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
bundle := generationBundle(t)
notifier := &generationNotifier{}
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
data, readErr := os.ReadFile(outputPath)
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(outputPath), ".weatherreporter-*.tmp"))
if !errors.Is(err, tt.err) || promptexec.CategoryOf(err) != tt.category || result == nil || result.OutputPath != "" || notifier.calls != 0 || readErr != nil || string(data) != previousReport || globErr != nil || len(matches) != 0 {
t.Fatalf("GenerateDetailed() result/error/output/notification/temp = %#v/%v/%q/%#v/%v/%v", result, err, data, notifier, matches, globErr)
}
})
}
}
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
@@ -360,10 +495,33 @@ func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
if err := os.Mkdir(outputPath, 0o700); err != nil {
t.Fatal(err)
}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}})
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier})
info, statErr := os.Stat(outputPath)
if err == nil || result == nil || statErr != nil || !info.IsDir() {
t.Fatalf("GenerateDetailed() result/error/output-info = %#v/%v/%#v (%v)", result, err, info, statErr)
if err == nil || result == nil || statErr != nil || !info.IsDir() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/output-info/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
}
}
func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) {
dir := t.TempDir()
backing := filepath.Join(dir, "backing.md")
if err := os.WriteFile(backing, []byte("previous report"), 0o600); err != nil {
t.Fatal(err)
}
outputPath := filepath.Join(dir, "daily.md")
testutil.RequireSymlink(t, backing, outputPath)
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier})
info, statErr := os.Lstat(outputPath)
data, readErr := os.ReadFile(backing)
if err == nil || result == nil || statErr != nil || info.Mode()&os.ModeSymlink == 0 || readErr != nil || string(data) != "previous report" || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/output/backing/collector/executor/notifier = %#v/%v/%#v (%v)/%q (%v)/%t/%#v/%#v", result, err, info, statErr, data, readErr, collector.called, executor, notifier)
}
}
@@ -375,6 +533,9 @@ func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil || result == nil || result.LLMDebugPath == "" {
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
@@ -184,10 +185,8 @@ func validateOutputPath(path string) (string, error) {
if filepath.Dir(path) == path {
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
return "", fmt.Errorf("final output path %q is a directory", path)
} else if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
if err := fileutil.ValidateAtomicPath(path); err != nil {
return "", fmt.Errorf("validate final output path %q: %w", path, err)
}
return path, nil
}

View File

@@ -0,0 +1,59 @@
//go:build linux
package app
import (
"context"
"net"
"os"
"path/filepath"
"syscall"
"testing"
)
func TestGenerateDetailedRejectsSpecialOutputBeforeWork(t *testing.T) {
for _, tt := range []struct {
name string
setup func(t *testing.T, path string)
}{
{
name: "named pipe",
setup: func(t *testing.T, path string) {
t.Helper()
if err := syscall.Mkfifo(path, 0o600); err != nil {
t.Fatal(err)
}
},
},
{
name: "socket",
setup: func(t *testing.T, path string) {
t.Helper()
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
},
},
} {
t.Run(tt.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
tt.setup(t, outputPath)
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
})
info, statErr := os.Lstat(outputPath)
if err == nil || result == nil || statErr != nil || info.Mode().IsRegular() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/output/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
}
})
}
}

View File

@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestResolveComparisonOutputDirectory(t *testing.T) {
@@ -46,9 +48,7 @@ func TestResolveComparisonOutputDirectory(t *testing.T) {
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
workingDir := t.TempDir()
dangling := filepath.Join(workingDir, "dangling")
if err := os.Symlink(filepath.Join(workingDir, "missing"), dangling); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
t.Run(filepath.Base(directory), func(t *testing.T) {
@@ -63,9 +63,7 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
workingDir := t.TempDir()
target := t.TempDir()
link := filepath.Join(workingDir, "linked")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, target, link)
directory := filepath.Join(link, "reports")
got, err := resolveOutputDir(workingDir, directory)

View File

@@ -7,6 +7,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
@@ -18,9 +19,9 @@ import (
// executions for one resolved report.
type preparedReport struct {
resolved report.Resolved
reportFacts ReportFacts
derived facts.DerivedFacts
moduleSnapshot module.Snapshot
briefingMetadata briefing.Metadata
identity briefing.PreparedIdentity
sourceWarnings []weatherdata.SourceWarning
dataPackage []byte
handler generatedtext.Handler
@@ -30,6 +31,7 @@ type prepareReportRequest struct {
Config config.Config
Resolved report.Resolved
Collection collect.Result
handler generatedtext.Handler
}
type preparationError struct {
@@ -54,12 +56,13 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil {
return preparedReport{}, &preparationError{operation: "build report facts", err: err}
}
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, reportFacts)
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
identity := briefing.BuildPreparedIdentity(buildContext)
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved, Identity: identity}, reportFacts)
if err != nil {
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
}
metadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot})
if err != nil {
return preparedReport{}, &preparationError{operation: "build data package", err: err}
}
@@ -67,31 +70,26 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
if err != nil {
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
}
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
clonedDerived, err := clonePreparedValue(reportFacts.Derived)
if err != nil {
return preparedReport{}, &preparationError{operation: "lookup generated text catalog", err: err}
}
clonedFacts, err := clonePreparedValue(reportFacts)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared report facts", err: err}
return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
}
clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
}
clonedMetadata, err := clonePreparedValue(metadata)
clonedIdentity, err := clonePreparedValue(identity)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err}
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
}
prepared := preparedReport{
resolved: cloneResolved(req.Resolved),
reportFacts: clonedFacts,
derived: clonedDerived,
moduleSnapshot: clonedSnapshot,
briefingMetadata: clonedMetadata,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...),
identity: clonedIdentity,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
dataPackage: append([]byte(nil), serializedDataPackage...),
handler: handler,
handler: req.handler,
}
return prepared, nil
}
@@ -119,20 +117,20 @@ func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
}
func (p preparedReport) renderInputs() (briefing.Metadata, module.Snapshot, ReportFacts, error) {
metadata, err := clonePreparedValue(p.briefingMetadata)
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
identity, err := clonePreparedValue(p.identity)
if err != nil {
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
}
snapshot, err := clonePreparedValue(p.moduleSnapshot)
if err != nil {
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
}
reportFacts, err := clonePreparedValue(p.reportFacts)
derived, err := clonePreparedValue(p.derived)
if err != nil {
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
}
return metadata, snapshot, reportFacts, nil
return identity, snapshot, derived, nil
}
func clonePreparedValue[T any](value T) (T, error) {

View File

@@ -5,7 +5,11 @@ import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -20,7 +24,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
t.Fatalf("ResolveGenerate() error = %v", err)
}
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}}
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)}
prepared, err := prepareReport(request)
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
@@ -29,20 +33,21 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
if err != nil {
t.Fatalf("second prepareReport() error = %v", err)
}
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) {
t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.briefingMetadata)
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) {
t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity)
}
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalMetadata := prepared.briefingMetadata
originalIdentity := prepared.identity
originalDerived := prepared.derived
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
metadata, snapshot, reportFacts, err := prepared.renderInputs()
identity, snapshot, derived, err := prepared.renderInputs()
if err != nil {
t.Fatalf("renderInputs() error = %v", err)
}
metadata.SourceWarnings = append(metadata.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
snapshot.Outputs = nil
reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation"
derived.PrecipTiming.ThunderMentioned = false
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
if len(bundle.Sources) > 0 {
@@ -52,13 +57,65 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
bundle.Sources[0].Query["mutated"] = "true"
}
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, originalMetadata) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.derived, originalDerived) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
}
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" {
t.Fatal("prepared report facts retain caller-owned weather data")
}
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "consumer mutation" || len(prepared.moduleSnapshot.Outputs) == 0 {
if len(prepared.moduleSnapshot.Outputs) == 0 {
t.Fatal("prepared report values retain consumer mutation")
}
}
func TestPrepareReportProjectsPreparedIdentity(t *testing.T) {
cfg := generationConfig()
bundle := generationBundle(t)
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
}, generationTime("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
}
identity := prepared.identity
renderIdentity, _, _, err := prepared.renderInputs()
if err != nil {
t.Fatalf("renderInputs() error = %v", err)
}
if !reflect.DeepEqual(renderIdentity, identity) {
t.Fatalf("render identity = %#v, want %#v", renderIdentity, identity)
}
prompt := promptMetadata(identity)
if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) {
t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity)
}
moduleMetadata, found, err := module.StanzaValue[briefing.MetadataModule](prepared.moduleSnapshot, "metadata")
if err != nil || !found {
t.Fatalf("metadata stanza = %#v/%t/%v", moduleMetadata, found, err)
}
if moduleMetadata.RunID != identity.RunID || moduleMetadata.ReportID != identity.ReportID || moduleMetadata.Variant != identity.Variant || moduleMetadata.PromptID != identity.PromptID || !moduleMetadata.GeneratedAt.Equal(identity.GeneratedAt) || moduleMetadata.Units != identity.Units || moduleMetadata.Timezone != identity.Timezone || moduleMetadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(moduleMetadata.Location, identity.Location) {
t.Fatalf("module metadata does not match prepared identity: %#v/%#v", moduleMetadata, identity)
}
if len(moduleMetadata.SourceWarnings) != len(identity.SourceWarnings) {
t.Fatalf("module source warnings = %#v, want %#v", moduleMetadata.SourceWarnings, identity.SourceWarnings)
}
for index, warning := range identity.SourceWarnings {
summary := moduleMetadata.SourceWarnings[index]
if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact {
t.Fatalf("module source warning %d = %#v, want %#v", index, summary, warning)
}
}
}
func preparedHandler(t *testing.T, resolved report.Resolved) generatedtext.Handler {
t.Helper()
handler, err := generatedtext.LookupDefinition(resolved.Definition)
if err != nil {
t.Fatalf("LookupDefinition() error = %v", err)
}
return handler
}

View File

@@ -3,7 +3,9 @@ package app
import (
"context"
"fmt"
"reflect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
@@ -48,10 +50,22 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
if req.Executor == nil {
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)}
}
if err := validatePreparedExecutionRequest(req); err != nil {
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
}
callbackFailed := false
preparationCallback := func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
outcome.ProfileID, outcome.BackendID, outcome.ModelName = preparation.ProfileID, preparation.BackendID, preparation.ModelName
preparationCount := 0
var preparation promptexec.Preparation
preparationCallback := func(value promptexec.Preparation, debug *promptexec.PreparationDebug) error {
preparationCount++
if preparationCount != 1 {
return promptProvenanceError()
}
if err := validatePreparationProvenance(req, value); err != nil {
return err
}
preparation = clonePreparation(value)
if req.DebugWriter == nil || !req.DebugWriter.Enabled() {
return nil
}
@@ -59,7 +73,7 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
callbackFailed = true
return promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))
}
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, preparation, debug)
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, value, debug)
if err != nil {
callbackFailed = true
return promptDebugWriteError(err)
@@ -85,8 +99,16 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
if execution == nil {
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)}
}
if preparationCount != 1 {
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: promptProvenanceError()}
}
if err := validateExecutionProvenance(req, preparation, *execution); err != nil {
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
}
outcome.ValidationStatus = execution.Validation.Status
if err := generatedtext.ValidateRawOutput(execution.RawOutput); err != nil {
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
}
if req.DebugWriter != nil && req.DebugWriter.Enabled() {
if req.DebugRef == nil {
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))}
@@ -107,15 +129,15 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)}
}
generatedText, _, err := req.Prepared.handler.Validate(execution.RawOutput)
generatedText, err := req.Prepared.handler.Validate(execution.RawOutput)
if err != nil {
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
}
metadata, snapshot, reportFacts, err := req.Prepared.renderInputs()
identity, snapshot, derived, err := req.Prepared.renderInputs()
if err != nil {
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err}
}
renderContext, err := req.Prepared.handler.BuildRenderContext(metadata, snapshot, reportFacts.Collected, reportFacts.Derived, generatedText)
renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, derived, generatedText)
if err != nil {
return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
}
@@ -125,3 +147,54 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
}
return outcome, rendered, nil
}
func validatePreparedExecutionRequest(req profileExecutionRequest) error {
definition := req.Prepared.resolved.Definition
if definition.PromptID != req.Prompt.PromptID || definition.PromptVersion != req.Prompt.PromptVersion ||
definition.GeneratedTextSchemaID != req.Prepared.handler.SchemaID() {
return promptProvenanceError()
}
if req.Prompt.ProfileID != "" && (req.Prompt.ProfileID != req.Profile.ProfileID || req.Prompt.BackendID != req.Profile.BackendID || req.Prompt.ModelName != req.Profile.ModelName) {
return promptProvenanceError()
}
if req.Prompt.PromptHash == "" || req.Profile.ProfileID == "" || req.Profile.BackendID == "" || req.Profile.ModelName == "" {
return promptProvenanceError()
}
return nil
}
func validatePreparationProvenance(req profileExecutionRequest, preparation promptexec.Preparation) error {
definition := req.Prepared.resolved.Definition
if preparation.PromptID != req.Prompt.PromptID || preparation.PromptVersion != req.Prompt.PromptVersion || preparation.PromptHash != req.Prompt.PromptHash ||
preparation.ProfileID != req.Profile.ProfileID || preparation.BackendID != req.Profile.BackendID || preparation.ModelName != req.Profile.ModelName ||
!validPromptOutput(definition, preparation.Output) {
return promptProvenanceError()
}
return nil
}
func validateExecutionProvenance(req profileExecutionRequest, preparation promptexec.Preparation, execution promptexec.Execution) error {
definition := req.Prepared.resolved.Definition
if execution.PromptID != preparation.PromptID || execution.PromptVersion != preparation.PromptVersion || execution.PromptHash != preparation.PromptHash ||
execution.RenderedPromptHash != preparation.RenderedPromptHash || !reflect.DeepEqual(execution.InputHashes, preparation.InputHashes) ||
execution.ProfileID != preparation.ProfileID || execution.BackendID != preparation.BackendID || execution.ModelName != preparation.ModelName ||
execution.Validation.Mode != "json_schema" || execution.Validation.SchemaPath != definition.GeneratedTextSchemaID+".generated_text.schema.json" {
return promptProvenanceError()
}
return nil
}
func promptProvenanceError() error {
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt execution provenance is inconsistent", nil)
}
func clonePreparation(value promptexec.Preparation) promptexec.Preparation {
if value.InputHashes != nil {
inputHashes := make(map[string]string, len(value.InputHashes))
for name, hash := range value.InputHashes {
inputHashes[name] = hash
}
value.InputHashes = inputHashes
}
return value
}

View File

@@ -5,9 +5,11 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
@@ -35,6 +37,9 @@ func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
@@ -51,6 +56,107 @@ func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
}
}
func TestExecutePreparedProfileBoundsOversizedExecutorOutput(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
marker := "provider-controlled-marker"
executor := &generationExecutor{rawOutput: []byte(strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1) + marker)}
_, _, err := executePreparedProfile(context.Background(), profileExecutionRequest{
Prepared: prepared, Prompt: inspection,
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
Executor: executor,
})
if err == nil || !strings.Contains(err.Error(), "65536-byte limit") {
t.Fatalf("executePreparedProfile() error = %v, want bounded raw size error", err)
}
if len(err.Error()) > 160 || strings.Contains(err.Error(), marker) {
t.Fatalf("ordinary error leaked provider content: %q", err)
}
}
func TestExecutePreparedProfileRejectsInconsistentProvenance(t *testing.T) {
tests := []struct {
name string
mutate func(*preparedReport, *PromptInspectionResult, *promptexec.ProfileInspection, *generationExecutor)
invoked bool
}{
{
name: "prepared definition", mutate: func(prepared *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, _ *generationExecutor) {
prepared.resolved.Definition.PromptVersion = "different-version"
},
},
{
name: "missing callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.skipPreparation = true
}, invoked: true,
},
{
name: "duplicate callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.preparationCalls = 2
},
},
{
name: "callback prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.prepare = func(value *promptexec.Preparation) { value.PromptHash = "different-hash" }
},
},
{
name: "callback output schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.prepare = func(value *promptexec.Preparation) { value.Output.SchemaPath = "other.generated_text.schema.json" }
},
},
{
name: "completed profile", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.ProfileID = "different-profile" }
}, invoked: true,
},
{
name: "completed rendered prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.RenderedPromptHash = "different-rendered-hash" }
}, invoked: true,
},
{
name: "completed input hashes", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.prepare = func(value *promptexec.Preparation) {
value.InputHashes = map[string]string{"data_package": "prepared-hash"}
}
executor.complete = func(value *promptexec.Execution) {
value.InputHashes = map[string]string{"data_package": "completed-hash"}
}
}, invoked: true,
},
{
name: "completed validation mode", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.Validation.Mode = "other" }
}, invoked: true,
},
{
name: "completed validation schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.Validation.SchemaPath = "other.generated_text.schema.json" }
}, invoked: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
profile := promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName}
executor := &generationExecutor{}
tt.mutate(&prepared, &inspection, &profile, executor)
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{Prepared: prepared, Prompt: inspection, Profile: profile, Executor: executor})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 {
t.Fatalf("outcome/rendered/error = %#v/%q/%v", outcome, rendered, err)
}
if outcome.ProfileID != profile.ProfileID || outcome.BackendID != profile.BackendID || outcome.ModelName != profile.ModelName || outcome.ValidationStatus != "" {
t.Fatalf("outcome retained unverified provenance: %#v", outcome)
}
if (executor.executeCalls == 1) != tt.invoked {
t.Fatalf("executor calls = %d, want invoked=%t", executor.executeCalls, tt.invoked)
}
})
}
}
func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult) {
t.Helper()
cfg := generationConfig()
@@ -62,9 +168,9 @@ func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult)
t.Fatalf("ResolveGenerate() error = %v", err)
}
bundle := generationBundle(t)
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}})
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
}
return prepared, PromptInspectionResult{PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion, PromptHash: "prompt-hash", ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
return prepared, PromptInspectionResult{PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion, PromptHash: generationPromptHash, ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
}

View File

@@ -30,7 +30,7 @@ func generatePromptReport(ctx context.Context, req promptReportRequest) (*Report
if result == nil {
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
}
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection})
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection, handler: req.Inspection.handler})
if err != nil {
return result, generatedPreparationError(req.Resolved, result.RunID, err)
}
@@ -86,7 +86,10 @@ func publishPromptReport(ctx context.Context, req promptPublicationRequest) (*Re
if err := publicationContextError(ctx); err != nil {
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", err)
}
if err := fileutil.WriteFileAtomic(req.OutputPath, req.Markdown); err != nil {
if err := fileutil.WriteFileAtomicContext(ctx, req.OutputPath, req.Markdown); err != nil {
if contextErr := publicationContextError(ctx); contextErr != nil {
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", contextErr)
}
return req.Result, err
}
req.Result.OutputPath = req.OutputPath

View File

@@ -7,6 +7,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
@@ -29,6 +30,7 @@ type PromptInspectionResult struct {
ProfileID string
BackendID string
ModelName string
handler generatedtext.Handler
}
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
@@ -56,6 +58,7 @@ type ComparisonInspectionResult struct {
PromptVersion string
PromptHash string
Profiles []ComparisonProfileInspection
handler generatedtext.Handler
}
// ComparisonProfileInspection contains one requested profile's safe effective
@@ -91,6 +94,10 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
profiles := map[string]promptexec.ProfileInspection{}
for _, resolved := range req.Resolved {
definition := resolved.Definition
handler, err := generatedtext.LookupDefinition(definition)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err)
}
inspection, err := inspectPromptContract(ctx, req.Executor, definition)
if err != nil {
return nil, err
@@ -113,6 +120,7 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
results[definition.ID] = PromptInspectionResult{
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
handler: handler,
}
}
return results, nil
@@ -130,6 +138,10 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
}
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return ComparisonInspectionResult{}, comparisonInspectionError("comparison generated-text catalog inspection failed", promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err))
}
inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition)
if err != nil {
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
@@ -139,6 +151,7 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
PromptVersion: inspection.PromptVersion,
PromptHash: inspection.PromptHash,
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
handler: handler,
}
for _, profileID := range req.ProfileIDs {
profile, err := inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
@@ -165,6 +178,9 @@ func inspectPromptContract(ctx context.Context, executor promptexec.Executor, de
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
}
if strings.TrimSpace(inspection.PromptHash) == "" {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return a prompt hash", nil)
}
if !validPromptInput(inspection.Inputs) {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
}
@@ -194,6 +210,9 @@ func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, pro
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
}
}
if strings.TrimSpace(profile.BackendID) == "" || strings.TrimSpace(profile.ModelName) == "" {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return a complete execution identity", nil)
}
return profile, nil
}

View File

@@ -71,6 +71,21 @@ func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "missing prompt hash",
prompt: func() promptexec.PromptInspection {
value := basePrompt
value.PromptHash = ""
return value
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "missing profile backend",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", ModelName: "model"},
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "direct key",
prompt: basePrompt,
@@ -110,9 +125,7 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
first := inspectionResolved(t)
second := first
second.Definition.ID = report.Today
second.Definition.PromptID = "weather.today"
second := inspectionResolvedFor(t, report.Today)
executor := &inspectionExecutor{
prompt: validPromptInspection(first.Definition),
profiles: map[string]promptexec.ProfileInspection{
@@ -132,6 +145,67 @@ func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
}
}
func TestPromptInspectionRejectsIncompatibleGeneratedTextCatalogBeforeExecutorWork(t *testing.T) {
base := inspectionResolved(t)
tests := []struct {
name string
resolved report.Resolved
inspect func(context.Context, report.Resolved, *inspectionExecutor) error
}{
{
name: "single report unknown template",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.TemplateID = "unknown"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectPromptExecution(ctx, PromptInspectionRequest{Resolved: resolved, Executor: executor})
return err
},
},
{
name: "batch known pair for another report",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.GeneratedTextSchemaID = "today"
resolved.Definition.TemplateID = "today"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{Resolved: []report.Resolved{resolved}, Executor: executor})
return err
},
},
{
name: "comparison known pair for another report",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.GeneratedTextSchemaID = "today"
resolved.Definition.TemplateID = "today"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor})
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
executor := &inspectionExecutor{}
err := test.inspect(context.Background(), test.resolved, executor)
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("inspection error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
}
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
t.Fatalf("incompatible catalog performed executor work: prompts %#v profiles %#v executions %d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
})
}
}
func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{
@@ -265,12 +339,16 @@ func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest,
}
func inspectionResolved(t *testing.T) report.Resolved {
return inspectionResolvedFor(t, report.Daily)
}
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
t.Helper()
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
Location: time.UTC,
})
request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
if id == report.Daily {
request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
}
resolved, err := report.DefaultRegistry().Resolve(id, request)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}

View File

@@ -41,12 +41,13 @@ func TestPromptInspectionResolvesEmbeddedAndOverriddenProfilesOffline(t *testing
override, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: writeProfileFile(t, `id: weather-light
endpoint: https://local.example/v1
backend: openrouter
model: local-weather
`)})
if err != nil {
t.Fatalf("New(override) error = %v", err)
}
inspect(t, override, report.Hourly, "", "weather-light", "", "local-weather")
inspect(t, override, report.Hourly, "", "weather-light", "openrouter", "local-weather")
}
func resolvedPromptProfile(t *testing.T, id report.ID) report.Resolved {

View File

@@ -1,32 +1,50 @@
{
"provenance": {
"sources": [
{
"url": "https://www.spc.noaa.gov/about/outlooks/",
"updated_on": "2026-03-03",
"applies_to": "categorical outlook descriptions"
},
{
"url": "https://www.spc.noaa.gov/exper/conditional-intensity-information",
"updated_on": "2026-02-04",
"applies_to": "conditional intensity group descriptions"
}
],
"reviewed_on": "2026-08-13",
"review_owner": "Weatherreporter maintainers",
"review_schedule": "Review annually and whenever SPC updates either referenced page."
},
"definitions": {
"categorical:TSTM": {
"plain_language": "General or non-severe thunderstorms.",
"official_description": "No severe thunderstorms expected.",
"official_description": "Encloses a 10% or higher probability of thunderstorms.",
"relative_level": "0 of 5"
},
"categorical:MRGL": {
"plain_language": "Isolated severe storms possible.",
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
"official_description": "Includes severe storms of either limited organization and longevity or very low coverage.",
"relative_level": "1 of 5"
},
"categorical:SLGT": {
"plain_language": "Scattered severe storms possible.",
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
"official_description": "Implies organized severe thunderstorms are expected, but usually in low coverage with varying levels of intensity.",
"relative_level": "2 of 5"
},
"categorical:ENH": {
"plain_language": "Numerous severe storms possible.",
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
"official_description": "Depicts a greater concentration of organized severe thunderstorms with varying levels of intensity.",
"relative_level": "3 of 5"
},
"categorical:MDT": {
"plain_language": "Widespread severe storms likely.",
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
"official_description": "Indicates potential for widespread severe weather with several tornadoes and/or numerous severe thunderstorms, some of which may be intense.",
"relative_level": "4 of 5"
},
"categorical:HIGH": {
"plain_language": "Major severe outbreak expected.",
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
"official_description": "Suggests a severe weather outbreak is expected from either numerous intense to violent long-track tornadoes or a long-lived derecho system with hurricane-force wind gusts producing widespread damage.",
"relative_level": "5 of 5"
},
"tornado:CIG1": {
@@ -69,4 +87,5 @@
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 2"
}
}
}

View File

@@ -137,7 +137,7 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
}
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", 20)
if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value))
}
@@ -462,9 +462,17 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
for _, tt := range []struct {
name string
options any
}{
{name: "value", options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
{name: "pointer", options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
} {
t.Run(tt.name, func(t *testing.T) {
output, err := registry.BuildModule(ctx, module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
Options: tt.options,
})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
@@ -476,6 +484,37 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want only short_term section", afd)
}
})
}
}
func TestWeatherStoryModuleOmitsEmptyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{OfficeID: "LSX", Priority: true, Order: 1}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted weather story", output)
}
}
func TestWeatherStoryModulePreservesZeroPriorityAndOrder(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{Title: "Rain Chances"}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
story := moduleValue[WeatherStoryModule](t, output)
if !story.Available || story.Priority || story.Order != 0 {
t.Fatalf("WeatherStory = %#v, want available story with zero priority and order", story)
}
}
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
@@ -561,7 +600,7 @@ func testModuleContext() ModuleContext {
hourlyHumidity := 66.0
hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
ctx := ModuleContext{
Resolved: resolved,
Collected: facts.CollectedFacts{
Current: &weatherdata.Current{
@@ -689,6 +728,14 @@ func testModuleContext() ModuleContext {
Timezone: "America/Chicago",
},
}
ctx.Identity = BuildPreparedIdentity(BuildContext{
Resolved: ctx.Resolved,
Bundle: ctx.Collected.Bundle(),
Units: ctx.Units,
Timezone: ctx.Timezone,
Location: ctx.Location,
})
return ctx
}
func moduleValue[T any](t *testing.T, output *module.Output) T {

View File

@@ -16,7 +16,7 @@ type DerivedDailySummaryModule struct {
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
ApparentTemperatureMaxF *int `json:"apparent_temperature_max_f,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"`
}
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
} else {
value.LowTempF = roundedInt(temperature.Min)
}
value.HeatIndexMaxF = roundedInt(apparent.Max)
value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
if narrativePrecipitation != nil {
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"sort"
"strings"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -80,6 +79,9 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
for _, daypart := range ctx.Derived.DaypartSummaries {
key := daypartKey(daypart, prefixDates)
if existing, exists := value[key]; exists {
return nil, fmt.Errorf("daypart summary key %q collides with display name %q", key, existing.DisplayName)
}
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
}
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
@@ -135,7 +137,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
temperature := daypartTemperatureDisplay(daypart)
value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone),
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
DisplayName: capitalizeFirst(strings.TrimSpace(daypart.Name)),
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature),
@@ -148,7 +150,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition,
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
NotableConditions: append([]string(nil), daypart.NotableConditions...),
Snow: daypart.Indicators.Snow,
Ice: daypart.Indicators.Ice,
@@ -162,7 +164,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, hourlyForecastPrecipMentionProbabilityThreshold)
}
if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
@@ -301,18 +303,8 @@ func celsiusToFahrenheit(value float64) float64 {
}
func temperatureBandIndex(value int) int {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
band := 1
switch {
case remainder <= 3:
band = 0
case remainder >= 7:
band = 2
}
decade, remainder := temperatureBandParts(value)
band := temperatureBandQualifierIndex(remainder)
return decade*3 + band
}
@@ -348,29 +340,47 @@ func temperaturePhraseF(value forecast.Range) string {
}
func temperatureBandPhrase(value int) string {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
if value < 0 {
decade, remainder := temperatureBandParts(-value)
qualifier := temperatureBandQualifier(remainder)
if decade == 0 {
return fmt.Sprintf("%s single digits below zero", qualifier)
}
qualifier := "mid"
switch {
case remainder <= 3:
qualifier = "low"
case remainder >= 7:
qualifier = "upper"
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
}
decade, remainder := temperatureBandParts(value)
qualifier := temperatureBandQualifier(remainder)
return fmt.Sprintf("%s %ds", qualifier, decade)
}
func sentenceCase(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
func temperatureBandParts(value int) (int, int) {
decade := value / 10
if value < 0 && value%10 != 0 {
decade--
}
return decade * 10, value - decade*10
}
func temperatureBandQualifierIndex(remainder int) int {
switch {
case remainder <= 3:
return 0
case remainder >= 7:
return 2
default:
return 1
}
}
func temperatureBandQualifier(remainder int) string {
switch temperatureBandQualifierIndex(remainder) {
case 0:
return "low"
case 2:
return "upper"
default:
return "mid"
}
runes := []rune(trimmed)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
@@ -382,7 +392,7 @@ func multipleSummaryDates(summaries []forecast.DailySummary) bool {
}
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
key := normalizedKey(daypart.Name)
key := forecast.CanonicalDaypartKey(daypart.Name)
if key == "" {
key = "unnamed"
}
@@ -391,21 +401,3 @@ func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
}
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
}
func normalizedKey(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
var out strings.Builder
lastUnderscore := false
for _, r := range lower {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
out.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(out.String(), "_")
}

View File

@@ -42,19 +42,22 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
}
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily summary: %v", err)
}
jsonText := string(data)
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "apparent_temperature_max_f"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "heat_index") {
t.Fatalf("daily json = %s, want no heat-index label for generic apparent temperature", jsonText)
}
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
if strings.Contains(jsonText, removed) {
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
@@ -65,6 +68,54 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
}
}
func TestDerivedDailySummaryPreservesApparentTemperatureMeaning(t *testing.T) {
for _, tt := range []struct {
name string
temperature float64
want int
}{
{name: "hot", temperature: 101, want: 101},
{name: "mild", temperature: 63, want: 63},
{name: "below freezing", temperature: -12, want: -12},
} {
t.Run(tt.name, func(t *testing.T) {
value, err := derivedDailySummaryValue(forecast.DailySummary{
Date: "2026-05-29",
Dayparts: []forecast.DaypartSummary{{
ApparentTemperature: forecast.Range{Max: floatPtr(tt.temperature)},
}},
}, forecast.PrecipTiming{}, "America/Chicago")
if err != nil {
t.Fatalf("derivedDailySummaryValue() error = %v", err)
}
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != tt.want {
t.Fatalf("ApparentTemperatureMaxF = %#v, want %d", value.ApparentTemperatureMaxF, tt.want)
}
})
}
}
func TestDerivedDailySummaryLabelsMetricApparentTemperature(t *testing.T) {
start := mustParseModuleTime("2026-05-29T12:00:00-05:00")
period := timeutil.Period{Start: start, End: start.Add(time.Hour)}
daypart := forecast.SummarizeDaypart("afternoon", period, []weatherdata.ForecastPeriod{{
StartTime: period.Start,
EndTime: period.End,
TemperatureC: floatPtr(20),
ApparentTemperatureC: floatPtr(20),
}})
value, err := derivedDailySummaryValue(forecast.DailySummary{
Date: "2026-05-29",
Dayparts: []forecast.DaypartSummary{daypart},
}, forecast.PrecipTiming{}, "America/Chicago")
if err != nil {
t.Fatalf("derivedDailySummaryValue() error = %v", err)
}
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 68 {
t.Fatalf("ApparentTemperatureMaxF = %#v, want converted 68", value.ApparentTemperatureMaxF)
}
}
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
@@ -96,7 +147,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
t.Fatalf("BuildModule(rainy) error = %v", err)
}
rainy := moduleValue[PrecipTimingModule](t, output)
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != 40 || !rainy.ThunderMentioned {
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
}
if len(rainy.PrecipitationWindows) != 2 {
@@ -215,7 +266,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value := precipTimingValue(forecast.PrecipTiming{
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
ProbabilityThreshold: 40,
PrecipitationWindows: []forecast.PrecipitationWindow{
{
Start: now,
@@ -223,7 +274,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
Value: tt.maxPop,
Time: now,
},
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
ProbabilityThreshold: 40,
TextDescriptions: tt.descriptions,
},
},
@@ -304,6 +355,52 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
}
}
func TestDerivedDaypartSummariesRejectCanonicalKeyCollisions(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
first := ctx.Derived.DaypartSummaries[0]
first.Name = "Morning"
second := first
second.Name = "morning!"
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err == nil || !strings.Contains(err.Error(), "collides") {
t.Fatalf("BuildModule() error = %v, want canonical daypart-key collision", err)
}
}
func TestDerivedDaypartSummaryDisplayCapitalizesUnicodeNames(t *testing.T) {
value := derivedDaypartSummaryValue(forecast.DaypartSummary{
Name: "mañana",
DominantCondition: "llovizna",
}, "UTC")
if value.DisplayName != "Mañana" || value.DominantConditionDisplay != "Llovizna" {
t.Fatalf("daypart display = %#v, want rune-safe capitalization", value)
}
}
func TestDerivedDaypartSummariesKeepDistinctUnicodeKeys(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
first := ctx.Derived.DaypartSummaries[0]
first.Name = "mañana"
second := first
second.Name = "manana"
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
if len(value) != 2 || value["mañana"].DisplayName != "Mañana" || value["manana"].DisplayName != "Manana" {
t.Fatalf("daypart summaries = %#v, want distinct Unicode canonical keys", value)
}
}
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
@@ -390,6 +487,20 @@ func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
wantTrend: "steady",
wantSteady: "upper 70s",
},
{
name: "rising across zero",
temps: []float64{-5, 5},
wantTrend: "rising",
wantStart: "mid single digits below zero",
wantEnd: "mid 0s",
},
{
name: "falling across zero",
temps: []float64{5, -5},
wantTrend: "falling",
wantStart: "mid 0s",
wantEnd: "mid single digits below zero",
},
}
for _, test := range tests {
@@ -460,6 +571,20 @@ func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
wantTrend: "steady",
wantSteady: "upper 70s",
},
{
name: "rising across zero",
temps: []float64{-5, 5},
wantTrend: "rising",
wantStart: "mid single digits below zero",
wantEnd: "mid 0s",
},
{
name: "falling across zero",
temps: []float64{5, -5},
wantTrend: "falling",
wantStart: "mid 0s",
wantEnd: "mid single digits below zero",
},
}
for _, test := range tests {
@@ -506,6 +631,11 @@ func TestTemperaturePhraseF(t *testing.T) {
value: forecast.Range{Max: floatPtr(84)},
want: "mid 80s",
},
{
name: "below zero range",
value: forecast.Range{Min: floatPtr(-9), Max: floatPtr(-1)},
want: "upper single digits below zero to low single digits below zero",
},
{
name: "empty",
value: forecast.Range{},
@@ -521,6 +651,130 @@ func TestTemperaturePhraseF(t *testing.T) {
}
}
func TestTemperatureBandIndexPreservesSignedOrder(t *testing.T) {
values := []int{-11, -10, -9, -5, -1, 0, 1, 9}
previous := temperatureBandIndex(values[0])
for _, value := range values[1:] {
current := temperatureBandIndex(value)
if current < previous {
t.Fatalf("temperatureBandIndex(%d) = %d, want at least %d", value, current, previous)
}
previous = current
}
for _, tt := range []struct {
value int
want string
}{
{value: -11, want: "low 10s below zero"},
{value: -10, want: "low 10s below zero"},
{value: -9, want: "upper single digits below zero"},
{value: -5, want: "mid single digits below zero"},
{value: -1, want: "low single digits below zero"},
{value: 0, want: "low 0s"},
{value: 1, want: "low 0s"},
{value: 9, want: "upper 0s"},
} {
if got := temperatureBandPhrase(tt.value); got != tt.want {
t.Fatalf("temperatureBandPhrase(%d) = %q, want %q", tt.value, got, tt.want)
}
}
}
func TestOutdoorWindowsScoreSnowIceAndFog(t *testing.T) {
for _, tt := range []struct {
name string
indicators forecast.Indicators
reason string
}{
{name: "snow", indicators: forecast.Indicators{Snow: true}, reason: "snow risk"},
{name: "ice", indicators: forecast.Indicators{Ice: true}, reason: "ice risk"},
{name: "fog", indicators: forecast.Indicators{Fog: true}, reason: "fog risk"},
} {
t.Run(tt.name, func(t *testing.T) {
window := scoreOutdoorWindow(forecast.DaypartSummary{Name: tt.name, Indicators: tt.indicators})
if window.Score != outdoorIndicatorRiskScore || !containsString(window.Reasons, tt.reason) || containsString(window.Reasons, "quiet weather") {
t.Fatalf("outdoor window = %#v, want indicator risk without quiet weather", window)
}
})
}
dayparts := []forecast.DaypartSummary{
{Name: "snow", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
{Name: "ice and fog", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true, Fog: true}},
}
windows := buildOutdoorWindows(dayparts)
if windows.Best == nil || windows.Best.Daypart != "snow" || windows.Worst == nil || windows.Worst.Daypart != "ice and fog" {
t.Fatalf("outdoor windows = %#v, want mixed hazards ranked by accumulated risk", windows)
}
tied := buildOutdoorWindows([]forecast.DaypartSummary{
{Name: "first", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
{Name: "second", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true}},
})
if tied.Best == nil || tied.Best.Daypart != "first" || tied.Worst == nil || tied.Worst.Daypart != "first" {
t.Fatalf("tied outdoor windows = %#v, want input-order tie behavior", tied)
}
}
func TestPlanningUsesCanonicalDaypartIdentities(t *testing.T) {
timedValue := func(value float64) *forecast.TimedValue {
return &forecast.TimedValue{Value: value}
}
containsText := func(values []string, text string) bool {
return strings.Contains(strings.Join(values, "\n"), text)
}
summary := &forecast.DailySummary{Dayparts: []forecast.DaypartSummary{
{Name: "Overnight!", MaxPrecipitationProbability: timedValue(60)},
{Name: "MORNING", MaxPrecipitationProbability: timedValue(60)},
{Name: "Afternoon!!!", MaxPrecipitationProbability: timedValue(60)},
{Name: "EVENING!", MaxPrecipitationProbability: timedValue(60)},
}}
today := buildTodayPlanning(summary)
if !containsText(today.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("today morning readiness = %#v, want canonical morning window", today.MorningReadiness)
}
if !containsText(today.CommuteSchoolWorkdayConcerns, "Afternoon!!! precipitation chance reaches 60%.") ||
containsText(today.CommuteSchoolWorkdayConcerns, "Overnight!") ||
containsText(today.CommuteSchoolWorkdayConcerns, "EVENING!") {
t.Fatalf("today workday concerns = %#v, want only canonical workday windows", today.CommuteSchoolWorkdayConcerns)
}
if !containsText(today.LateDayChangeWatch, "Afternoon!!! precipitation timing may shift") ||
!containsText(today.LateDayChangeWatch, "EVENING! precipitation timing may shift") {
t.Fatalf("today late-day watch = %#v, want canonical afternoon and evening windows", today.LateDayChangeWatch)
}
base := buildMorningCommuteOvernightPlanning(summary)
if !containsText(base.MorningReadiness, "Morning precipitation chance peaks near 60%.") ||
!containsText(base.OvernightChangeWatch, "Overnight precipitation timing may shift") ||
containsText(base.CommuteSchoolWorkdayConcerns, "Overnight!") ||
containsText(base.CommuteSchoolWorkdayConcerns, "EVENING!") {
t.Fatalf("daily/tomorrow planning = %#v, want canonical daypart treatment", base)
}
}
func TestCapitalizeFirst(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "empty", input: "", want: ""},
{name: "ASCII", input: "morning", want: "Morning"},
{name: "multibyte", input: "mañana", want: "Mañana"},
{name: "already uppercase", input: "Morning", want: "Morning"},
{name: "leading space", input: " morning", want: " morning"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := capitalizeFirst(tt.input); got != tt.want {
t.Fatalf("capitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Tomorrow)

View File

@@ -0,0 +1,85 @@
package briefing
type factRequirementCategory string
const (
collectedFactRequirement factRequirementCategory = "collected"
derivedFactRequirement factRequirementCategory = "derived"
)
type factRequirement struct {
identity string
category factRequirementCategory
available func(ModuleContext) bool
}
func (r factRequirement) String() string {
return r.identity
}
var (
currentConditionsRequirement = &factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Current != nil
}}
narrativeForecastRequirement = &factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Narrative != nil
}}
hourlyForecastRequirement = &factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Hourly != nil
}}
alertsRequirement = &factRequirement{identity: "collected.alerts", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Alerts != nil
}}
discussionRequirement = &factRequirement{identity: "collected.discussion", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Discussion != nil
}}
weatherStoryRequirement = &factRequirement{identity: "collected.weather_story", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.WeatherStory != nil
}}
spcOutlooksRequirement = &factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.SPCConvectiveOutlooks != nil
}}
sourceMetadataRequirement = &factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
}}
hourlyPeriodsRequirement = &factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
}}
narrativePeriodsRequirement = &factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
}}
alertOverlapsRequirement = &factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement, available: func(ModuleContext) bool {
return true
}}
dailySummariesRequirement = &factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.DailySummaries) > 0
}}
daypartSummariesRequirement = &factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.DaypartSummaries) > 0
}}
precipTimingRequirement = &factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement, available: func(ModuleContext) bool {
return true
}}
spcDerivedOutlooksRequirement = &factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Derived.SPCConvectiveOutlooks != nil
}}
)
var factRequirementVocabulary = []*factRequirement{
currentConditionsRequirement,
narrativeForecastRequirement,
hourlyForecastRequirement,
alertsRequirement,
discussionRequirement,
weatherStoryRequirement,
spcOutlooksRequirement,
sourceMetadataRequirement,
hourlyPeriodsRequirement,
narrativePeriodsRequirement,
alertOverlapsRequirement,
dailySummariesRequirement,
daypartSummariesRequirement,
precipTimingRequirement,
spcDerivedOutlooksRequirement,
}

View File

@@ -9,7 +9,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
const hourlyForecastPrecipMentionProbabilityThreshold = 20
type HourlyForecastModule struct {
Product string `json:"product,omitempty"`
@@ -184,7 +184,7 @@ func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecas
}
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, hourlyForecastPrecipMentionProbabilityThreshold)
}
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {

View File

@@ -31,18 +31,17 @@ type SourceWarningSummary struct {
}
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
metadata := ctx.Resolved.Metadata()
value := MetadataModule{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Units: ctx.Units,
Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
RunID: ctx.Identity.RunID,
ReportID: ctx.Identity.ReportID,
Variant: ctx.Identity.Variant,
PromptID: ctx.Identity.PromptID,
GeneratedAt: ctx.Identity.GeneratedAt,
Units: ctx.Identity.Units,
Timezone: ctx.Identity.Timezone,
ValidPeriod: ctx.Identity.ValidPeriod,
Location: copyLocation(ctx.Identity.Location),
SourceWarnings: sourceWarningSummaries(ctx.Identity.SourceWarnings),
}
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
}

View File

@@ -11,6 +11,7 @@ import (
)
type ModuleContext struct {
Identity PreparedIdentity
Resolved report.Resolved
Collected facts.CollectedFacts
Derived facts.DerivedFacts
@@ -27,8 +28,8 @@ type ModuleDefinition struct {
ID module.ID
StanzaName string
DefaultOptions any
RequiredCollected []module.FactRequirement
RequiredDerived []module.FactRequirement
RequiredCollected []*factRequirement
RequiredDerived []*factRequirement
SupportedReports []report.ID
MissingData module.MissingDataBehavior
AllowDuplicate bool
@@ -75,6 +76,9 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
if definition.MissingData == module.MissingDataWarn {
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
}
if err := validateFactRequirements(definition); err != nil {
return ModuleRegistry{}, err
}
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
}
@@ -100,7 +104,8 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
}
if err := definition.ValidateOptions(item.Options); err != nil {
options, err := definition.CanonicalOptions(item.Options)
if err != nil {
return nil, err
}
if definition.Builder == nil {
@@ -120,7 +125,6 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
}
}
options := item.Options
if options == nil {
options = definition.DefaultOptions
}
@@ -152,60 +156,48 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
var missing []string
for _, requirement := range definition.RequiredCollected {
if !collectedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
if !requirement.available(ctx) {
missing = append(missing, requirement.String())
}
}
for _, requirement := range definition.RequiredDerived {
if !derivedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
if !requirement.available(ctx) {
missing = append(missing, requirement.String())
}
}
return missing
}
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
switch requirement {
case module.CollectedCurrentConditions:
return ctx.Collected.Current != nil
case module.CollectedNarrativeForecast:
return ctx.Collected.Narrative != nil
case module.CollectedHourlyForecast:
return ctx.Collected.Hourly != nil
case module.CollectedAlerts:
return ctx.Collected.Alerts != nil
case module.CollectedDiscussion:
return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
return false
func validateFactRequirements(definition ModuleDefinition) error {
if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil {
return err
}
return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement)
}
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
switch requirement {
case module.RequiresDerivedHourlyPeriods:
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
case module.RequiresDerivedNarrativePeriods:
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
case module.RequiresDerivedAlertOverlaps:
return true
case module.RequiresDerivedDailySummaries:
return len(ctx.Derived.DailySummaries) > 0
case module.RequiresDerivedDaypartSummaries:
return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming:
return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error {
for _, requirement := range requirements {
if requirement == nil {
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "")
}
descriptor, ok := lookupFactRequirement(requirement.identity)
if !ok || descriptor != requirement {
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, requirement.identity)
}
if descriptor.category != want {
return fmt.Errorf("module %q lists %s fact requirement %q as %s", moduleID, descriptor.category, descriptor.identity, want)
}
}
return nil
}
func lookupFactRequirement(identity string) (*factRequirement, bool) {
for _, requirement := range factRequirementVocabulary {
if requirement.identity == identity {
return requirement, true
}
}
return nil, false
}
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
@@ -264,6 +256,24 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
}
// CanonicalOptions validates options and converts an accepted typed pointer to its value form.
func (d ModuleDefinition) CanonicalOptions(options any) (any, error) {
if err := d.ValidateOptions(options); err != nil {
return nil, err
}
if options == nil {
return nil, nil
}
value := reflect.ValueOf(options)
if value.Kind() != reflect.Pointer {
return options, nil
}
if value.IsNil() {
return nil, fmt.Errorf("module %q options must not be a nil pointer", d.ID)
}
return value.Elem().Interface(), nil
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
@@ -272,7 +282,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
RequiredCollected: []*factRequirement{sourceMetadataRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildMetadataModule,
@@ -281,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.CurrentConditions,
StanzaName: "current_conditions",
DefaultOptions: module.CurrentConditionsOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
RequiredCollected: []*factRequirement{currentConditionsRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
@@ -291,8 +301,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.NarrativeForecast,
StanzaName: "narrative_forecast",
DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
RequiredCollected: []*factRequirement{narrativeForecastRequirement},
RequiredDerived: []*factRequirement{narrativePeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule,
@@ -301,8 +311,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.HourlyForecast,
StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
RequiredCollected: []*factRequirement{hourlyForecastRequirement},
RequiredDerived: []*factRequirement{hourlyPeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
@@ -312,7 +322,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
RequiredDerived: []*factRequirement{dailySummariesRequirement, precipTimingRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule,
@@ -321,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDaypartSummaries,
StanzaName: "derived_daypart_summaries",
DefaultOptions: module.DerivedDaypartSummariesOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports,
MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule,
@@ -331,7 +341,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
RequiredDerived: []*factRequirement{precipTimingRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,
@@ -340,8 +350,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AlertDigest,
StanzaName: "alert_digest",
DefaultOptions: module.AlertDigestOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
RequiredCollected: []*factRequirement{alertsRequirement},
RequiredDerived: []*factRequirement{alertOverlapsRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule,
@@ -350,8 +360,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
@@ -360,7 +370,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
RequiredCollected: []*factRequirement{discussionRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
@@ -369,8 +379,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
@@ -379,7 +389,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.WeatherStory,
StanzaName: "weather_story",
DefaultOptions: module.WeatherStoryOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
RequiredCollected: []*factRequirement{weatherStoryRequirement},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildWeatherStoryModule,
@@ -388,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.OutdoorWindows,
StanzaName: "outdoor_windows",
DefaultOptions: module.OutdoorWindowsOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports,
MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule,
@@ -397,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TodayPlanning,
StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule,
@@ -406,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule,
@@ -415,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,

View File

@@ -30,6 +30,55 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
}
}
func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
vocabulary := make(map[string]*factRequirement, len(factRequirementVocabulary))
for _, requirement := range factRequirementVocabulary {
if requirement.identity == "" {
t.Fatal("fact requirement identity is empty")
}
if requirement.category != collectedFactRequirement && requirement.category != derivedFactRequirement {
t.Fatalf("fact requirement %q category = %q, want collected or derived", requirement.identity, requirement.category)
}
if _, exists := vocabulary[requirement.identity]; exists {
t.Fatalf("duplicate fact requirement %q", requirement.identity)
}
if requirement.available == nil {
t.Fatalf("fact requirement %q has no availability predicate", requirement.identity)
}
vocabulary[requirement.identity] = requirement
}
used := map[*factRequirement]struct{}{}
for _, definition := range defaultModuleDefinitions() {
for _, requirement := range definition.RequiredCollected {
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement)
}
for _, requirement := range definition.RequiredDerived {
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, derivedFactRequirement)
}
}
for _, requirement := range factRequirementVocabulary {
if _, ok := used[requirement]; !ok {
t.Fatalf("fact requirement %q is not used by a module definition", requirement.identity)
}
}
}
func assertFactRequirementCategory(t *testing.T, vocabulary map[string]*factRequirement, used map[*factRequirement]struct{}, moduleID module.ID, requirement *factRequirement, want factRequirementCategory) {
t.Helper()
descriptor, ok := vocabulary[requirement.identity]
if !ok {
t.Fatalf("module %q uses unknown fact requirement %q", moduleID, requirement.identity)
}
if descriptor != requirement {
t.Fatalf("module %q requirement %q does not use the vocabulary descriptor", moduleID, requirement.identity)
}
if requirement.category != want {
t.Fatalf("module %q requirement %q category = %q, want %q", moduleID, requirement.identity, requirement.category, want)
}
used[requirement] = struct{}{}
}
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, definition := range report.DefaultRegistry().All() {
@@ -434,6 +483,52 @@ func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
}
}
func TestModuleRegistryRejectsInvalidFactRequirements(t *testing.T) {
tests := []struct {
name string
configure func(*ModuleDefinition)
wantErr string
}{
{
name: "Unknown",
configure: func(definition *ModuleDefinition) {
definition.RequiredCollected = []*factRequirement{{identity: "collected.unknown", category: collectedFactRequirement}}
},
wantErr: `module "metadata" uses unknown collected fact requirement "collected.unknown"`,
},
{
name: "DerivedListedAsCollected",
configure: func(definition *ModuleDefinition) {
definition.RequiredCollected = []*factRequirement{dailySummariesRequirement}
},
wantErr: `module "metadata" lists derived fact requirement "derived.daily_summaries" as collected`,
},
{
name: "CollectedListedAsDerived",
configure: func(definition *ModuleDefinition) {
definition.RequiredDerived = []*factRequirement{currentConditionsRequirement}
},
wantErr: `module "metadata" lists collected fact requirement "collected.current_conditions" as derived`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
definition := ModuleDefinition{
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
Builder: noopModuleBuilder,
}
tt.configure(&definition)
_, err := NewModuleRegistry([]ModuleDefinition{definition})
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
@@ -458,6 +553,7 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
{ID: module.AreaForecastDiscussion, Options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
})
if err != nil {
t.Fatalf("ValidateComposition() error = %v", err)
@@ -466,25 +562,25 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
if spcOutlooksRequirement.available(ctx) {
t.Fatal("SPC outlook requirement is available, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
if !spcOutlooksRequirement.available(ctx) {
t.Fatal("SPC outlook requirement is unavailable, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
if spcDerivedOutlooksRequirement.available(ctx) {
t.Fatal("derived SPC outlook requirement is available, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
if !spcDerivedOutlooksRequirement.available(ctx) {
t.Fatal("derived SPC outlook requirement is unavailable, want true for checked empty derived outlooks")
}
}

View File

@@ -9,7 +9,9 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type Metadata struct {
// PreparedIdentity is the single prepared authority for report identity,
// timing, configuration context, and source warnings.
type PreparedIdentity struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
@@ -21,9 +23,7 @@ type Metadata struct {
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
@@ -33,24 +33,6 @@ type LocationContext struct {
Timezone string `json:"timezone,omitempty"`
}
type SourceMetadata struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
type AlertStatus struct {
Checked bool `json:"checked"`
ActiveCount int `json:"activeCount"`
RelevantCount int `json:"relevantCount"`
Missing bool `json:"missing,omitempty"`
}
type BuildContext struct {
Resolved report.Resolved
Bundle *weatherdata.Bundle
@@ -59,10 +41,10 @@ type BuildContext struct {
Location *LocationContext
}
func BuildMetadata(ctx BuildContext) Metadata {
func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return Metadata{
return PreparedIdentity{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
@@ -74,9 +56,7 @@ func BuildMetadata(ctx BuildContext) Metadata {
Location: copyLocation(ctx.Location),
SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...),
}
}
@@ -135,26 +115,6 @@ func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
return "", ""
}
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
if bundle == nil {
return nil
}
out := make([]SourceMetadata, 0, len(bundle.Sources))
for _, source := range bundle.Sources {
out = append(out, SourceMetadata{
Name: source.Name,
Endpoint: source.Endpoint,
FetchedAt: source.FetchedAt,
IssuedAt: source.IssuedAt,
UpdatedAt: source.UpdatedAt,
DataSHA256: source.DataSHA256,
Missing: source.Missing,
Warnings: source.Warnings,
})
}
return out
}
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
if bundle == nil {
return nil
@@ -162,27 +122,6 @@ func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
return bundle.Warnings
}
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
if bundle == nil {
return nil
}
status := &AlertStatus{}
if bundle.Alerts != nil {
status.Checked = true
status.ActiveCount = len(bundle.Alerts.Alerts)
}
for _, source := range bundle.Sources {
if source.Name == "alerts" && source.Missing {
status.Missing = true
break
}
}
if !status.Checked && !status.Missing {
return nil
}
return status
}
func variantForReport(id report.ID) string {
switch id {
case report.Daily, report.Today:

View File

@@ -100,7 +100,7 @@ func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType s
case maxPopPercent >= precipTimingExpectLowerBound:
return fmt.Sprintf("Expect %s.", precipitationType)
case maxPopPercent >= precipTimingLikelyLowerBound:
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
return fmt.Sprintf("%s likely.", capitalizeFirst(strings.TrimSpace(precipitationType)))
case maxPopPercent >= precipTimingChanceLowerBound:
return fmt.Sprintf("Chance of %s.", precipitationType)
default:

View File

@@ -0,0 +1,26 @@
package briefing
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestPreparedIdentityCopiesMutableFields(t *testing.T) {
moduleContext := testModuleContext()
context := BuildContext{
Resolved: moduleContext.Resolved,
Bundle: moduleContext.Collected.Bundle(),
Units: moduleContext.Units,
Timezone: moduleContext.Timezone,
Location: moduleContext.Location,
}
identity := BuildPreparedIdentity(context)
originalWarnings := append([]weatherdata.SourceWarning(nil), moduleContext.Collected.SourceWarnings...)
identity.Location.Name = "consumer mutation"
identity.SourceWarnings = append(identity.SourceWarnings, moduleContext.Collected.SourceWarnings[0])
if moduleContext.Location.Name == "consumer mutation" || !reflect.DeepEqual(moduleContext.Collected.SourceWarnings, originalWarnings) {
t.Fatalf("prepared identity changed its source context: %#v", identity)
}
}

View File

@@ -17,11 +17,29 @@ func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDe
if err != nil {
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
}
var definitions map[string]SPCOutlookBackgroundDefinition
if err := json.Unmarshal(data, &definitions); err != nil {
var asset spcOutlookBackgroundDefinitionAsset
if err := json.Unmarshal(data, &asset); err != nil {
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
}
return definitions
return asset.Definitions
}
type spcOutlookBackgroundDefinitionAsset struct {
Provenance spcOutlookDefinitionProvenance `json:"provenance"`
Definitions map[string]SPCOutlookBackgroundDefinition `json:"definitions"`
}
type spcOutlookDefinitionProvenance struct {
Sources []spcOutlookDefinitionSource `json:"sources"`
ReviewedOn string `json:"reviewed_on"`
ReviewOwner string `json:"review_owner"`
ReviewSchedule string `json:"review_schedule"`
}
type spcOutlookDefinitionSource struct {
URL string `json:"url"`
UpdatedOn string `json:"updated_on"`
AppliesTo string `json:"applies_to"`
}
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {

View File

@@ -65,11 +65,9 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
}
if got.BackgroundDefinition == nil ||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
wantBackground := spcOutlookBackgroundDefinition("categorical", "SLGT")
if got.BackgroundDefinition == nil || wantBackground == nil || *got.BackgroundDefinition != *wantBackground {
t.Fatalf("background definition = %#v, want matching Slight Risk helper %#v", got.BackgroundDefinition, wantBackground)
}
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got)
@@ -193,12 +191,33 @@ func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
}
}
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
if defaultSPCRiskDigestOutlookType != "categorical" {
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
func TestSPCOutlookBackgroundDefinitionAssetRecordsProvenance(t *testing.T) {
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
var asset spcOutlookBackgroundDefinitionAsset
if err := json.Unmarshal(data, &asset); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if asset.Provenance.ReviewedOn == "" || asset.Provenance.ReviewOwner == "" || asset.Provenance.ReviewSchedule == "" {
t.Fatalf("asset provenance = %#v, want review metadata", asset.Provenance)
}
wantSources := map[string]string{
"https://www.spc.noaa.gov/about/outlooks/": "categorical outlook descriptions",
"https://www.spc.noaa.gov/exper/conditional-intensity-information": "conditional intensity group descriptions",
}
if len(asset.Provenance.Sources) != len(wantSources) {
t.Fatalf("asset provenance sources = %#v, want %d authoritative sources", asset.Provenance.Sources, len(wantSources))
}
for _, source := range asset.Provenance.Sources {
if source.UpdatedOn == "" || wantSources[source.URL] != source.AppliesTo {
t.Fatalf("asset provenance source = %#v, want authoritative source metadata", source)
}
delete(wantSources, source.URL)
}
if len(wantSources) != 0 {
t.Fatalf("asset provenance missing sources = %#v", wantSources)
}
}

View File

@@ -5,6 +5,7 @@ import (
"math"
"sort"
"strings"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -41,6 +42,15 @@ type TodayPlanning struct {
LateDayChangeWatch []string
}
const outdoorIndicatorRiskScore = 25
const (
morningDaypartIdentity = "morning"
afternoonDaypartIdentity = "afternoon"
eveningDaypartIdentity = "evening"
overnightDaypartIdentity = "overnight"
)
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow
var worst *OutdoorWindow
@@ -72,7 +82,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
}
for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" {
if isOutsideWorkday(daypart) {
continue
}
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
@@ -91,7 +101,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
}
for _, name := range []string{"afternoon", "evening"} {
for _, name := range []string{afternoonDaypartIdentity, eveningDaypartIdentity} {
daypart := daypartNamed(summary.Dayparts, name)
if daypart != nil {
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
@@ -124,7 +134,7 @@ func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morni
}
for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" {
if isOutsideWorkday(daypart) {
continue
}
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
@@ -153,10 +163,10 @@ func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
windows := buildOutdoorWindows(dayparts)
var notes []string
if windows.Best != nil {
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", capitalizeFirst(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
}
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", capitalizeFirst(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
}
return appendUnique(nil, notes...)
}
@@ -183,7 +193,7 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
prefix := titleWord(daypart.Name)
prefix := capitalizeFirst(daypart.Name)
if prefix == "" {
prefix = "Late-day"
}
@@ -204,7 +214,7 @@ func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
func concernNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
prefix := titleWord(daypart.Name)
prefix := capitalizeFirst(daypart.Name)
if prefix == "" {
prefix = "Daytime"
}
@@ -247,8 +257,9 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
}
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
identity := forecast.CanonicalDaypartKey(name)
for i := range dayparts {
if strings.EqualFold(dayparts[i].Name, name) {
if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity {
return &dayparts[i]
}
}
@@ -275,7 +286,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
reasons = append(reasons, "alert overlap")
}
if daypart.Indicators.Heat || daypart.Indicators.Cold {
score += 25
score += outdoorIndicatorRiskScore
if daypart.Indicators.Heat {
reasons = append(reasons, "heat risk")
}
@@ -283,6 +294,19 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
reasons = append(reasons, "cold risk")
}
}
for _, hazard := range []struct {
present bool
reason string
}{
{present: daypart.Indicators.Snow, reason: "snow risk"},
{present: daypart.Indicators.Ice, reason: "ice risk"},
{present: daypart.Indicators.Fog, reason: "fog risk"},
} {
if hazard.present {
score += outdoorIndicatorRiskScore
reasons = append(reasons, hazard.reason)
}
}
if len(reasons) == 0 {
reasons = append(reasons, "quiet weather")
}
@@ -382,9 +406,20 @@ func appendUnique(values []string, candidates ...string) []string {
return values
}
func titleWord(value string) string {
func isOutsideWorkday(daypart forecast.DaypartSummary) bool {
switch forecast.CanonicalDaypartKey(daypart.Name) {
case overnightDaypartIdentity, eveningDaypartIdentity:
return true
default:
return false
}
}
func capitalizeFirst(value string) string {
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
runes := []rune(value)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}

View File

@@ -23,7 +23,7 @@ type WeatherStoryModule struct {
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
story := ctx.Collected.WeatherStory
if story == nil {
if story == nil || !story.HasUsableContent() {
return nil, nil
}
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}

View File

@@ -69,36 +69,6 @@ func TestResolveComparisonActionBuildsExplicitRequest(t *testing.T) {
}
}
func TestResolveComparisonActionMatchesReportDatePolicies(t *testing.T) {
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n timezone: America/Chicago\n")
runner := comparisonRunner(t, t.TempDir())
for _, test := range []struct {
name string
args []string
wantDay string
wantErr bool
}{
{name: "daily requires date", args: []string{"daily", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
{name: "today uses current local date", args: []string{"today", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-29"},
{name: "today accepts date", args: []string{"today", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-30"},
{name: "tomorrow rejects date", args: []string{"tomorrow", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
{name: "hourly rejects date", args: []string{"hourly", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
req, _, err := runner.resolveComparisonAction(test.args)
if test.wantErr {
if err == nil {
t.Fatal("resolveComparisonAction() error = nil")
}
return
}
if err != nil || req.Date.Format("2006-01-02") != test.wantDay {
t.Fatalf("request/error = %#v/%v", req, err)
}
})
}
}
func TestResolveComparisonActionLeavesConfiguredOutputWithoutOverride(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured/../reports\n")
@@ -215,6 +185,40 @@ func TestCompareCommandWritesStructuredPartialFailure(t *testing.T) {
}
}
func TestCompareCommandPreservesMixedCancellationOutcomes(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
profileFailure := comparison.NewSafeError("validation_rejected", "validate prompt execution failed")
canceledProfile := comparison.NewSafeError("canceled", "profile execution canceled")
result := comparisonResult("/reports/comparison-daily", []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed, ValidationStatus: promptexec.ValidationFailed, Error: &profileFailure},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusFailed, ValidationStatus: promptexec.ValidationSkipped, Error: &canceledProfile},
})
runner := comparisonRunner(t, workingDir)
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
return result, context.Canceled
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath}, &stdout, &stderr)
if !errors.Is(err, context.Canceled) || stderr.Len() != 0 {
t.Fatalf("Run() error/stderr = %v/%q", err, stderr.String())
}
var summary comparisonSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
}
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "canceled" || len(summary.Results) != 2 {
t.Fatalf("summary = %#v", summary)
}
if first := summary.Results[0]; first.Error == nil || first.Error.Category != "validation_rejected" || first.ValidationStatus != string(promptexec.ValidationFailed) {
t.Fatalf("completed profile summary = %#v", first)
}
if second := summary.Results[1]; second.Error == nil || second.Error.Category != "canceled" || second.ValidationStatus != string(promptexec.ValidationSkipped) {
t.Fatalf("canceled profile summary = %#v", second)
}
}
func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
@@ -223,9 +227,9 @@ func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(outputDirectory, "01-weather-light.md")},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusSucceeded, ValidationStatus: promptexec.ValidationPassed, ReportPath: filepath.Join(outputDirectory, "02-weather-deep.md")},
})
backupPath := filepath.Join(workingDir, ".comparison-daily.backup-retained")
recoveryPath := filepath.Join(workingDir, ".comparison-daily.backup-retained")
cleanupCause := errors.New("filesystem cleanup detail")
cleanupErr := &comparison.PublicationCleanupError{RetainedBackupPath: backupPath, Err: cleanupCause}
cleanupErr := &comparison.PublicationCleanupError{RecoveryState: comparison.BackupRecoveryComplete, RecoveryPath: recoveryPath, Err: cleanupCause}
runner := comparisonRunner(t, workingDir)
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
return result, cleanupErr
@@ -240,10 +244,10 @@ func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
}
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "publication_cleanup" || summary.Error.Message != "comparison published but cleanup did not complete" || summary.ManifestPath != result.ManifestPath || summary.DataPackagePath != result.DataPackagePath || summary.Results[0].ReportPath == "" {
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "publication_cleanup" || summary.Error.Message != "comparison published but a complete prior bundle remains" || summary.ManifestPath != result.ManifestPath || summary.DataPackagePath != result.DataPackagePath || summary.Results[0].ReportPath == "" {
t.Fatalf("summary = %#v", summary)
}
for _, unsafe := range []string{cleanupCause.Error(), backupPath} {
for _, unsafe := range []string{cleanupCause.Error(), recoveryPath} {
if strings.Contains(stdout.String(), unsafe) {
t.Fatalf("summary contains unsafe recovery detail %q: %s", unsafe, stdout.String())
}
@@ -318,7 +322,7 @@ func TestCompareCommandLeavesPreExecutionFailuresUnstructured(t *testing.T) {
func TestCompareHelpIncludesCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") {
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") || !strings.Contains(stdout.String(), "--date YYYY-MM-DD") || !strings.Contains(stdout.String(), "Suppress action summaries and routine batch status output.") {
t.Fatalf("help/error = %q/%v", stdout.String(), err)
}
}

View File

@@ -0,0 +1,141 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestGenerateInputFailuresDoNotConstructOrExecute(t *testing.T) {
configPath := actionConfigPath(t)
for _, tt := range []struct {
name string
args []string
wantErr string
}{
{name: "MissingReport", args: nil, wantErr: "generate requires a report name"},
{name: "UnknownReport", args: []string{"unknown"}, wantErr: "unknown generate report"},
{name: "MissingDailyDate", args: []string{"daily", "--config", filepath.Join(t.TempDir(), "missing.yml")}, wantErr: "generate daily requires --date YYYY-MM-DD"},
{name: "MalformedDailyDate", args: []string{"daily", "--date", "not-a-date", "--config", configPath}},
{name: "MalformedTodayDate", args: []string{"today", "--date", "not-a-date", "--config", configPath}},
{name: "UnexpectedArgument", args: []string{"today", "extra"}},
{name: "UnsupportedFlag", args: []string{"today", "--out-dir", "reports"}},
} {
t.Run(tt.name, func(t *testing.T) {
factoryCalls, applicationCalls := 0, 0
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)},
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
factoryCalls++
return &factoryExecutor{}, nil
},
generateDetailed: func(context.Context, app.GenerateRequest) (*app.ReportResult, error) {
applicationCalls++
return nil, nil
},
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), append([]string{"generate"}, tt.args...), &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil")
}
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Run() error = %q, want %q", err, tt.wantErr)
}
if factoryCalls != 0 || applicationCalls != 0 || stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("factory/application calls/output = %d/%d/%q/%q", factoryCalls, applicationCalls, stdout.String(), stderr.String())
}
})
}
}
func TestGenerateCommandProjectsActionResults(t *testing.T) {
configPath := actionConfigPath(t)
failure := errors.New("prompt execution failed")
for _, tt := range []struct {
name string
quiet bool
actionErr error
}{
{name: "Success"},
{name: "Failure", actionErr: failure},
{name: "QuietFailure", quiet: true, actionErr: failure},
} {
t.Run(tt.name, func(t *testing.T) {
factoryCalls, applicationCalls := 0, 0
executor := &factoryExecutor{}
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)},
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
factoryCalls++
return executor, nil
},
generateDetailed: func(_ context.Context, req app.GenerateRequest) (*app.ReportResult, error) {
applicationCalls++
if req.Executor != executor {
t.Fatal("generate request did not receive the constructed executor")
}
return generatedReportResult(), tt.actionErr
},
}
args := []string{"generate", "daily", "--date", "2026-05-29", "--config", configPath}
if tt.quiet {
args = append(args, "--quiet")
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), args, &stdout, &stderr)
if !errors.Is(err, tt.actionErr) || factoryCalls != 1 || applicationCalls != 1 || stderr.Len() != 0 {
t.Fatalf("error/calls/stderr = %v/%d/%d/%q", err, factoryCalls, applicationCalls, stderr.String())
}
if tt.quiet {
if stdout.Len() != 0 {
t.Fatalf("quiet stdout = %q, want empty", stdout.String())
}
return
}
var summary generateSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v", err)
}
wantStatus, wantError := summaryStatusSucceeded, ""
if tt.actionErr != nil {
wantStatus, wantError = summaryStatusFailed, tt.actionErr.Error()
}
if summary.Command != commandGenerate || summary.Status != wantStatus || summary.Error != wantError || summary.OutputPath != "/reports/daily.md" {
t.Fatalf("summary = %#v", summary)
}
})
}
}
func actionConfigPath(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func generatedReportResult() *app.ReportResult {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
return &app.ReportResult{
ReportID: report.Daily, ReportName: "Daily Report", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0",
RunID: "daily-20260529", GeneratedAt: generatedAt, Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)},
ProfileID: "weather-light", BackendID: "local", ModelName: "weather-model", ValidationStatus: promptexec.ValidationPassed,
OutputPath: "/reports/daily.md",
}
}

View File

@@ -37,6 +37,10 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
continue
}
if item.Status == "canceled" {
_, _ = fmt.Fprintf(stderr, "report=%s status=canceled\n", item.ReportID)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
}
if result.Notification != nil {
@@ -58,5 +62,5 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
}
_, _ = fmt.Fprintln(stderr)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d canceled=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed, result.Canceled)
}

View File

@@ -0,0 +1,75 @@
package cli
import (
"errors"
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
var (
errReportDateRequired = errors.New("report date is required")
errReportDateNotAccepted = errors.New("report does not accept a date")
)
type reportDatePolicy struct {
acceptsDate bool
requiresDate bool
defaultsToLocalDate bool
}
func reportDatePolicyFor(report app.ReportKind) reportDatePolicy {
switch report {
case app.ReportDaily:
return reportDatePolicy{acceptsDate: true, requiresDate: true}
case app.ReportToday:
return reportDatePolicy{acceptsDate: true, defaultsToLocalDate: true}
default:
return reportDatePolicy{}
}
}
func addReportDateFlag(fs interface {
StringVar(*string, string, string, string)
}, report app.ReportKind, value *string) {
if reportDatePolicyFor(report).acceptsDate {
fs.StringVar(value, "date", "", "report date in YYYY-MM-DD")
}
}
func reportDateRequiredError(action string, report app.ReportKind, value string) error {
if reportDatePolicyFor(report).requiresDate && value == "" {
return fmt.Errorf("%s %s requires --date YYYY-MM-DD", action, report)
}
return nil
}
func resolveActionReportDate(action string, report app.ReportKind, value string, location *time.Location, now time.Time) (time.Time, error) {
date, err := resolveReportDate(report, value, location, now)
if errors.Is(err, errReportDateRequired) {
return time.Time{}, fmt.Errorf("%s %s requires --date YYYY-MM-DD", action, report)
}
return date, err
}
func resolveReportDate(report app.ReportKind, value string, location *time.Location, now time.Time) (time.Time, error) {
policy := reportDatePolicyFor(report)
if !policy.acceptsDate {
if value != "" {
return time.Time{}, errReportDateNotAccepted
}
return time.Time{}, nil
}
if value == "" {
if policy.requiresDate {
return time.Time{}, errReportDateRequired
}
if policy.defaultsToLocalDate {
return timeutil.LocalDate(now, location), nil
}
return time.Time{}, nil
}
return timeutil.ParseLocalDate(value, location)
}

View File

@@ -0,0 +1,101 @@
package cli
import (
"errors"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
)
func TestReportDatePolicy(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
for _, tt := range []struct {
name string
report app.ReportKind
value string
wantDay string
wantErr error
malformed bool
}{
{name: "DailyRequiresDate", report: app.ReportDaily, wantErr: errReportDateRequired},
{name: "DailyParsesDate", report: app.ReportDaily, value: "2026-05-30", wantDay: "2026-05-30"},
{name: "TodayDefaultsInConfiguredTimezone", report: app.ReportToday, wantDay: "2026-05-29"},
{name: "TodayParsesDate", report: app.ReportToday, value: "2026-05-30", wantDay: "2026-05-30"},
{name: "TodayRejectsMalformedDate", report: app.ReportToday, value: "not-a-date", malformed: true},
{name: "TomorrowHasNoDate", report: app.ReportTomorrow},
{name: "TomorrowRejectsDate", report: app.ReportTomorrow, value: "2026-05-30", wantErr: errReportDateNotAccepted},
{name: "HourlyHasNoDate", report: app.ReportHourly},
{name: "HourlyRejectsDate", report: app.ReportHourly, value: "2026-05-30", wantErr: errReportDateNotAccepted},
} {
t.Run(tt.name, func(t *testing.T) {
date, err := resolveReportDate(tt.report, tt.value, location, now)
if tt.malformed {
if err == nil {
t.Fatal("resolveReportDate() error = nil")
}
return
}
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("resolveReportDate() error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveReportDate() error = %v", err)
}
if tt.wantDay == "" {
if !date.IsZero() {
t.Fatalf("resolveReportDate() = %v, want zero date", date)
}
return
}
if date.Location() != location || date.Format("2006-01-02") != tt.wantDay {
t.Fatalf("resolveReportDate() = %v, want %s in %s", date, tt.wantDay, location)
}
})
}
}
func TestReportDateFlagsFollowPolicy(t *testing.T) {
for _, tt := range []struct {
report app.ReportKind
acceptsDate bool
}{
{report: app.ReportDaily, acceptsDate: true},
{report: app.ReportToday, acceptsDate: true},
{report: app.ReportTomorrow},
{report: app.ReportHourly},
} {
t.Run(string(tt.report), func(t *testing.T) {
_, generateErr := parseGenerateFlags(tt.report, []string{"--date", "2026-05-30"})
_, comparisonErr := parseComparisonFlags(tt.report, []string{"--date", "2026-05-30"})
if tt.acceptsDate && (generateErr != nil || comparisonErr != nil) {
t.Fatalf("date flag errors = %v/%v, want accepted", generateErr, comparisonErr)
}
if !tt.acceptsDate && (generateErr == nil || comparisonErr == nil) {
t.Fatalf("date flag errors = %v/%v, want rejected", generateErr, comparisonErr)
}
})
}
}
func TestResolveActionReportDateKeepsActionSpecificMissingDateErrors(t *testing.T) {
location := time.UTC
now := time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
for _, action := range []string{"generate", "compare"} {
t.Run(action, func(t *testing.T) {
_, err := resolveActionReportDate(action, app.ReportDaily, "", location, now)
want := action + " daily requires --date YYYY-MM-DD"
if err == nil || err.Error() != want {
t.Fatalf("error = %v, want %q", err, want)
}
})
}
}

View File

@@ -70,6 +70,7 @@ type batchSummary struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Canceled int `json:"canceled,omitempty"`
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
Reports []app.BatchReportResult `json:"reports"`
Error string `json:"error,omitempty"`
@@ -161,7 +162,7 @@ func newGenerateNotificationSummary(result *app.NotificationResult) *generateNot
return summary
}
func newBatchSummary(result *app.BatchResult) batchSummary {
func newBatchSummary(result *app.BatchResult, err error) batchSummary {
summary := batchSummary{Command: commandRun}
if result == nil {
return summary
@@ -174,10 +175,11 @@ func newBatchSummary(result *app.BatchResult) batchSummary {
summary.Total = result.Total
summary.Succeeded = result.Succeeded
summary.Failed = result.Failed
summary.Canceled = result.Canceled
summary.Notification = result.Notification
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
if summary.Status == summaryStatusFailed {
summary.Error = app.BatchError{Result: result}.Error()
summary.Error = app.BatchError{Result: result, Cause: err}.Error()
}
return summary
}
@@ -223,7 +225,7 @@ func safeComparisonSummaryError(err error) *comparison.SafeError {
}
var cleanupErr *comparison.PublicationCleanupError
if errors.As(err, &cleanupErr) {
safe := comparison.NewSafeError("publication_cleanup", "comparison published but cleanup did not complete")
safe := comparison.NewSafeError("publication_cleanup", publicationCleanupMessage(cleanupErr.RecoveryState))
return &safe
}
var destinationErr *comparison.DestinationError
@@ -247,6 +249,19 @@ func safeComparisonSummaryError(err error) *comparison.SafeError {
return &safe
}
func publicationCleanupMessage(state comparison.BackupRecoveryState) string {
switch state {
case comparison.BackupRecoveryComplete:
return "comparison published but a complete prior bundle remains"
case comparison.BackupRecoveryPartial:
return "comparison published but partial cleanup remnants remain"
case comparison.BackupRecoveryAbsent:
return "comparison published but no prior bundle remains"
default:
return "comparison published but cleanup recovery state is unknown"
}
}
func comparisonAggregateErrorMessage(err error) (string, bool) {
const prefix = "comparison completed with "
const suffix = " failed profiles"
@@ -267,7 +282,7 @@ func batchSummaryStatus(result *app.BatchResult) string {
if result == nil {
return ""
}
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
if result.Failed > 0 || result.Canceled > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
return summaryStatusFailed
}
return summaryStatusSucceeded

View File

@@ -179,12 +179,36 @@ func TestSafeComparisonSummaryErrorClassifiesWrappedFailures(t *testing.T) {
message: "comparison destination preflight failed",
},
{
name: "publication cleanup",
name: "complete cleanup recovery",
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
RetainedBackupPath: "/tmp/" + unsafeDetail, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
RecoveryState: comparison.BackupRecoveryComplete, RecoveryPath: "/tmp/" + unsafeDetail, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
}),
category: "publication_cleanup",
message: "comparison published but cleanup did not complete",
message: "comparison published but a complete prior bundle remains",
},
{
name: "partial cleanup remnants",
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
RecoveryState: comparison.BackupRecoveryPartial, RecoveryPath: "/tmp/" + unsafeDetail, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
}),
category: "publication_cleanup",
message: "comparison published but partial cleanup remnants remain",
},
{
name: "absent cleanup recovery",
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
RecoveryState: comparison.BackupRecoveryAbsent, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
}),
category: "publication_cleanup",
message: "comparison published but no prior bundle remains",
},
{
name: "unknown cleanup recovery",
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
RecoveryState: comparison.BackupRecoveryUnknown, Err: fmt.Errorf("%w: %s", context.Canceled, unsafeDetail),
}),
category: "publication_cleanup",
message: "comparison published but cleanup recovery state is unknown",
},
{
name: "unknown",

View File

@@ -36,11 +36,12 @@ Options:
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write the generated Markdown report to PATH.
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH.
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH (Unix only).
--profile PROFILE Select a prompt profile for compare; repeat for every profile.
--date YYYY-MM-DD Required for generate/compare daily; optional for generate/compare today.
--out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory.
--replace Authorize replacement of a recognized comparison bundle.
--quiet Suppress successful action output.
--quiet Suppress action summaries and routine batch status output.
`
type Runner struct {
@@ -48,6 +49,7 @@ type Runner struct {
ExecutorFactory ExecutorFactory
Version string
WorkingDir string
generateDetailed func(context.Context, app.GenerateRequest) (*app.ReportResult, error)
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error)
}
@@ -86,7 +88,11 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
if err != nil {
return err
}
result, err := app.GenerateDetailed(ctx, req)
generateDetailed := r.generateDetailed
if generateDetailed == nil {
generateDetailed = app.GenerateDetailed
}
result, err := generateDetailed(ctx, req)
if result != nil {
summary := newGenerateSummary(result, err)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
@@ -105,12 +111,15 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
}
result, err := runBatchDetailed(ctx, req)
if result != nil {
summary := newBatchSummary(result)
summary := newBatchSummary(result, err)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
writeBatchStatus(w, result)
}); encodeErr != nil {
return encodeErr
}
if err != nil {
return err
}
if summary.Status == summaryStatusFailed {
return app.BatchError{Result: result}
}
@@ -192,6 +201,9 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
if err := reportDateRequiredError("generate", reportKind, opts.Date); err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
@@ -200,11 +212,19 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
executor, err := r.promptExecutor(cfg.Promptkit)
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
now := r.Clock.Now()
req := app.GenerateRequest{
Config: cfg,
Report: reportKind,
LLMDebugDir: opts.LLMDebugDir,
Now: now,
}
req.Date, err = resolveActionReportDate("generate", reportKind, opts.Date, location, now)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
@@ -217,36 +237,12 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
req := app.GenerateRequest{
Config: cfg,
Report: reportKind,
WorkingDir: workingDir,
OutputPath: outputPath,
LLMDebugDir: opts.LLMDebugDir,
Now: r.Clock.Now(),
Executor: executor,
}
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
req.WorkingDir, req.OutputPath = workingDir, outputPath
executor, err := r.promptExecutor(cfg.Promptkit)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
case app.ReportToday:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
}
}
req.Executor = executor
return req, opts.commonOptions, nil
}
@@ -299,19 +295,7 @@ func (r Runner) resolveComparisonAction(args []string) (app.ComparisonRequest, c
Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...),
WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock,
}
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
case app.ReportToday:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
}
}
req.Date, err = resolveActionReportDate("compare", reportKind, opts.Date, location, r.Clock.Now())
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
@@ -373,10 +357,8 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
fs.SetOutput(io.Discard)
opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true)
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
addReportDateFlag(fs, report, &opts.Date)
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}
@@ -392,7 +374,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
opts := commonOptions{}
addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
if err := fs.Parse(args); err != nil {
return commonOptions{}, err
}
@@ -409,11 +391,9 @@ func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptio
addCommonFlags(fs, &opts.commonOptions, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory")
fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID")
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
addReportDateFlag(fs, report, &opts.Date)
if err := fs.Parse(args); err != nil {
return comparisonOptions{}, err
}
@@ -427,7 +407,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH (Unix only)")
if includeOutput {
fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
}

View File

@@ -193,6 +193,103 @@ func TestRunActionReturnsFailureForBatchNotificationFailure(t *testing.T) {
}
}
func TestRunActionPreservesBatchCancellation(t *testing.T) {
configPath := actionConfigPath(t)
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
t.Run(cause.Error(), func(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning, Total: 2, Succeeded: 1, Canceled: 1,
Reports: []app.BatchReportResult{
{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"},
{ReportID: "tomorrow", Status: "canceled"},
},
Notification: &app.BatchNotificationResult{Status: "skipped", Reason: "batch canceled"},
}
var stdout, stderr bytes.Buffer
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
return &factoryExecutor{}, nil
},
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
return result, cause
},
}
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
if !errors.Is(err, cause) {
t.Fatalf("Run() error = %v", err)
}
var summary batchSummary
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatal(decodeErr)
}
if summary.Status != summaryStatusFailed || summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 0 || summary.Canceled != 1 || summary.Error == "" || len(summary.Reports) != 2 || summary.Reports[1].Status != "canceled" || !strings.Contains(stderr.String(), "report=tomorrow status=canceled") || !strings.Contains(stderr.String(), "canceled=1") {
t.Fatalf("summary/stderr = %#v/%q", summary, stderr.String())
}
})
}
}
func TestRunCommandProjectsSuccessAndReportFailure(t *testing.T) {
configPath := actionConfigPath(t)
for _, tt := range []struct {
name string
result *app.BatchResult
wantStatus string
wantError bool
statusLine string
}{
{
name: "Success",
result: &app.BatchResult{Batch: app.BatchMorning, Total: 1, Succeeded: 1, Reports: []app.BatchReportResult{{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"}}},
wantStatus: summaryStatusSucceeded,
statusLine: `report=today status=succeeded output="/reports/today.md"`,
},
{
name: "ReportFailure",
result: &app.BatchResult{Batch: app.BatchMorning, Total: 1, Failed: 1, Reports: []app.BatchReportResult{{ReportID: "today", Status: "failed", Error: "prompt execution failed"}}},
wantStatus: summaryStatusFailed,
wantError: true,
statusLine: `report=today status=failed error="prompt execution failed"`,
},
} {
t.Run(tt.name, func(t *testing.T) {
factoryCalls, applicationCalls := 0, 0
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
factoryCalls++
return &factoryExecutor{}, nil
},
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
applicationCalls++
return tt.result, nil
},
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
if factoryCalls != 1 || applicationCalls != 1 || !strings.Contains(stderr.String(), tt.statusLine) {
t.Fatalf("calls/stderr = %d/%d/%q", factoryCalls, applicationCalls, stderr.String())
}
if tt.wantError {
var batchErr app.BatchError
if !errors.As(err, &batchErr) {
t.Fatalf("Run() error = %v, want BatchError", err)
}
} else if err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary batchSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v", err)
}
if summary.Command != commandRun || summary.Status != tt.wantStatus || (summary.Error != "") != tt.wantError {
t.Fatalf("summary = %#v", summary)
}
})
}
}
func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr)

View File

@@ -5,13 +5,19 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
func TestRunFetchesBundle(t *testing.T) {
server := collectionTestServer(t, nil)
var currentRequests atomic.Int32
server := collectionTestServer(t, nil, func(r *http.Request) {
if r.URL.Path == "/conditions/current" {
currentRequests.Add(1)
}
})
defer server.Close()
cfg := config.Defaults()
@@ -30,6 +36,9 @@ func TestRunFetchesBundle(t *testing.T) {
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
}
if got := currentRequests.Load(); got != 1 {
t.Fatalf("conditions/current requests = %d, want 1", got)
}
}
func TestRunWrapsAdapterConstructionError(t *testing.T) {
@@ -50,7 +59,7 @@ func TestRunWrapsAdapterConstructionError(t *testing.T) {
}
func TestRunWrapsFetchError(t *testing.T) {
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadRequest}, nil)
defer server.Close()
cfg := config.Defaults()
@@ -68,9 +77,12 @@ func TestRunWrapsFetchError(t *testing.T) {
}
}
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
func collectionTestServer(t *testing.T, statusByPath map[string]int, onRequest func(*http.Request)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if onRequest != nil {
onRequest(r)
}
if status := statusByPath[r.URL.Path]; status != 0 {
http.Error(w, "upstream failure", status)
return

View File

@@ -283,7 +283,11 @@ func (manifest Manifest) Validate() error {
if result.ValidationStatus != "passed" {
return fmt.Errorf("successful result %d did not pass validation", result.Position)
}
if result.Error != nil || !isReportPath(result.ReportPath) {
expectedPath, err := ReportFilename(result.Position, manifest.Total, result.ProfileID)
if err != nil {
return fmt.Errorf("successful result %d has invalid report identity: %w", result.Position, err)
}
if result.Error != nil || result.ReportPath != expectedPath {
return fmt.Errorf("successful result %d has invalid report details", result.Position)
}
if _, duplicate := reportPaths[result.ReportPath]; duplicate {

View File

@@ -1,7 +1,6 @@
package comparison
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -165,9 +164,9 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
t.Fatalf("EncodeManifest() =\n%s\nwant\n%s", encoded, want)
}
var decoded Manifest
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
decoded, err := decodeManifest(encoded)
if err != nil {
t.Fatalf("decodeManifest() error = %v", err)
}
if err := decoded.Validate(); err != nil {
t.Fatalf("decoded manifest validation error = %v", err)
@@ -192,6 +191,10 @@ func TestManifestValidateRejectsInvariants(t *testing.T) {
{name: "duplicate profile", mutate: func(manifest *Manifest) { manifest.Results[1].ProfileID = manifest.Results[0].ProfileID }},
{name: "unsupported status", mutate: func(manifest *Manifest) { manifest.Results[1].Status = "skipped" }},
{name: "successful result without report", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "" }},
{name: "arbitrary report name", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "arbitrary.md" }},
{name: "wrong report position", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "02-weather-light.md" }},
{name: "wrong report ordinal width", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "1-weather-light.md" }},
{name: "wrong report profile slug", mutate: func(manifest *Manifest) { manifest.Results[0].ReportPath = "01-weather.md" }},
{name: "successful result with error", mutate: func(manifest *Manifest) {
manifest.Results[0].Error = &SafeError{Category: "application", Message: "bad"}
}},
@@ -247,6 +250,12 @@ func TestLogicalBundleValidate(t *testing.T) {
if err := bundle.Validate(); err == nil {
t.Fatal("LogicalBundle.Validate() accepted unordered report position")
}
bundle.Reports[0].Position = 1
bundle.Reports[0].Path = "arbitrary.md"
bundle.Manifest.Results[0].ReportPath = "arbitrary.md"
if err := bundle.Validate(); err == nil {
t.Fatal("LogicalBundle.Validate() accepted a noncanonical report path")
}
}
func TestSHA256AndTruncateErrorMessage(t *testing.T) {

View File

@@ -24,6 +24,11 @@ const (
DestinationNotEmpty DestinationErrorKind = "not_empty"
DestinationUnrecognized DestinationErrorKind = "unrecognized"
DestinationInspection DestinationErrorKind = "inspection"
maxComparisonComponentBytes = 255
temporaryRandomBytes = 10
stagingDirectoryPattern = ".weatherreporter-staging-*"
backupDirectoryPattern = ".weatherreporter-backup-*"
)
// DestinationError provides inspectable context without making filesystem
@@ -68,6 +73,18 @@ var ErrUnrecognizedBundle = errors.New("unrecognized comparison bundle")
// PlanDestination performs the read-only comparison destination preflight.
func PlanDestination(workingDirectory, target string, replace bool) (DestinationPlan, error) {
return planDestination(workingDirectory, target, replace, RecognizeBundle)
}
func planDestination(workingDirectory, target string, replace bool, recognize func(string) (Manifest, error)) (DestinationPlan, error) {
plan, err := newDestinationPlan(workingDirectory, target, replace)
if err != nil {
return DestinationPlan{}, err
}
return inspectDestination(plan, recognize)
}
func newDestinationPlan(workingDirectory, target string, replace bool) (DestinationPlan, error) {
workingDirectory, err := absoluteCleanPath(workingDirectory)
if err != nil {
return DestinationPlan{}, newDestinationError(DestinationInvalidPath, workingDirectory, err)
@@ -82,8 +99,23 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
if target == workingDirectory {
return DestinationPlan{}, newDestinationError(DestinationWorkingDirectory, target, nil)
}
if err := validateTransactionSiblingNames(target); err != nil {
return DestinationPlan{}, newDestinationError(DestinationInvalidPath, target, err)
}
plan := DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace}
return DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace}, nil
}
func reauthorizeDestination(plan DestinationPlan) (DestinationPlan, error) {
plan, err := newDestinationPlan(plan.WorkingDirectory, plan.Target, plan.Replace)
if err != nil {
return DestinationPlan{}, err
}
return inspectDestination(plan, nil)
}
func inspectDestination(plan DestinationPlan, recognize func(string) (Manifest, error)) (DestinationPlan, error) {
target := plan.Target
info, err := os.Lstat(target)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
@@ -109,16 +141,34 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
plan.state = destinationEmpty
return plan, nil
}
if !replace {
if !plan.Replace {
return DestinationPlan{}, newDestinationError(DestinationNotEmpty, target, nil)
}
if _, err := RecognizeBundle(target); err != nil {
if recognize != nil {
if _, err := recognize(target); err != nil {
return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err)
}
}
plan.state = destinationBundle
return plan, nil
}
func validateTransactionSiblingNames(target string) error {
if len(filepath.Base(target)) > maxComparisonComponentBytes {
return fmt.Errorf("destination name exceeds the %d-byte limit", maxComparisonComponentBytes)
}
for _, pattern := range []string{stagingDirectoryPattern, backupDirectoryPattern} {
if !temporaryPatternFitsComponentLimit(pattern) {
return fmt.Errorf("comparison transaction sibling pattern exceeds the %d-byte limit", maxComparisonComponentBytes)
}
}
return nil
}
func temporaryPatternFitsComponentLimit(pattern string) bool {
return len(strings.Replace(pattern, "*", strings.Repeat("0", temporaryRandomBytes), 1)) <= maxComparisonComponentBytes
}
func absoluteCleanPath(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", fmt.Errorf("path is required")
@@ -164,7 +214,25 @@ func newDestinationError(kind DestinationErrorKind, target string, err error) er
// RecognizeBundle verifies that directory contains exactly one valid current
// comparison bundle. It never follows bundle entries through symlinks.
func RecognizeBundle(directory string) (Manifest, error) {
info, err := os.Lstat(directory)
return recognizeBundle(directory, defaultRecognitionOperations)
}
type recognitionOperations struct {
lstat func(string) (os.FileInfo, error)
readDir func(string) ([]os.DirEntry, error)
open func(string) (io.ReadCloser, error)
}
var defaultRecognitionOperations = recognitionOperations{
lstat: os.Lstat,
readDir: os.ReadDir,
open: func(path string) (io.ReadCloser, error) {
return os.Open(path)
},
}
func recognizeBundle(directory string, operations recognitionOperations) (Manifest, error) {
info, err := operations.lstat(directory)
if err != nil {
return Manifest{}, unrecognizedBundleError("inspect directory", err)
}
@@ -172,11 +240,11 @@ func RecognizeBundle(directory string) (Manifest, error) {
return Manifest{}, unrecognizedBundleError("directory is not a real directory", nil)
}
entries, err := os.ReadDir(directory)
entries, err := operations.readDir(directory)
if err != nil {
return Manifest{}, unrecognizedBundleError("read directory", err)
}
manifestData, err := readBundleFile(directory, ManifestFilename)
manifestData, err := readBundleFile(directory, ManifestFilename, operations)
if err != nil {
return Manifest{}, err
}
@@ -204,12 +272,15 @@ func RecognizeBundle(directory string) (Manifest, error) {
if _, ok := expected[entry.Name()]; !ok {
return Manifest{}, unrecognizedBundleError("directory has an undeclared entry", nil)
}
if _, err := readBundleFile(directory, entry.Name()); err != nil {
if entry.Name() == ManifestFilename || entry.Name() == DataPackageFilename {
continue
}
if err := inspectBundleFile(directory, entry.Name(), operations); err != nil {
return Manifest{}, err
}
}
dataPackage, err := readBundleFile(directory, DataPackageFilename)
dataPackage, err := readBundleFile(directory, DataPackageFilename, operations)
if err != nil {
return Manifest{}, err
}
@@ -219,26 +290,53 @@ func RecognizeBundle(directory string) (Manifest, error) {
return manifest, nil
}
func readBundleFile(directory, name string) ([]byte, error) {
if !isArtifactBasename(name) {
return nil, unrecognizedBundleError("bundle entry name is unsafe", nil)
}
filePath := filepath.Join(directory, name)
info, err := os.Lstat(filePath)
func readBundleFile(directory, name string, operations recognitionOperations) ([]byte, error) {
file, err := openBundleFile(directory, name, operations)
if err != nil {
return nil, unrecognizedBundleError("inspect bundle entry", err)
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, unrecognizedBundleError("bundle entry is not a regular file", nil)
}
data, err := os.ReadFile(filePath)
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return nil, unrecognizedBundleError("read bundle entry", err)
}
return data, nil
}
func inspectBundleFile(directory, name string, operations recognitionOperations) error {
file, err := openBundleFile(directory, name, operations)
if err != nil {
return err
}
if err := file.Close(); err != nil {
return unrecognizedBundleError("close bundle entry", err)
}
return nil
}
func openBundleFile(directory, name string, operations recognitionOperations) (io.ReadCloser, error) {
if !isArtifactBasename(name) {
return nil, unrecognizedBundleError("bundle entry name is unsafe", nil)
}
filePath := filepath.Join(directory, name)
info, err := operations.lstat(filePath)
if err != nil {
return nil, unrecognizedBundleError("inspect bundle entry", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, unrecognizedBundleError("bundle entry is not a regular file", nil)
}
file, err := operations.open(filePath)
if err != nil {
return nil, unrecognizedBundleError("read bundle entry", err)
}
return file, nil
}
func decodeManifest(data []byte) (Manifest, error) {
if err := validateManifestJSON(data); err != nil {
return Manifest{}, unrecognizedBundleError("decode manifest", err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var manifest Manifest
@@ -255,6 +353,162 @@ func decodeManifest(data []byte) (Manifest, error) {
return manifest, nil
}
var manifestFields = map[string]jsonValueValidator{
"schemaVersion": nil,
"comparisonId": nil,
"startedAt": nil,
"finishedAt": nil,
"reportId": nil,
"validPeriod": validateValidPeriodJSON,
"timezone": nil,
"promptId": nil,
"promptVersion": nil,
"promptHash": nil,
"dataPackage": validateDataPackageJSON,
"total": nil,
"succeeded": nil,
"failed": nil,
"results": validateResultsJSON,
}
var validPeriodFields = map[string]jsonValueValidator{
"start": nil,
"end": nil,
}
var dataPackageFields = map[string]jsonValueValidator{
"path": nil,
"sha256": nil,
}
var resultFields = map[string]jsonValueValidator{
"position": nil,
"profileId": nil,
"backendId": nil,
"modelName": nil,
"status": nil,
"validationStatus": nil,
"reportPath": nil,
"error": validateSafeErrorJSON,
}
var safeErrorFields = map[string]jsonValueValidator{
"category": nil,
"message": nil,
}
type jsonValueValidator func(*json.Decoder) error
func validateManifestJSON(data []byte) error {
decoder := json.NewDecoder(bytes.NewReader(data))
if err := validateJSONObject(decoder, manifestFields); err != nil {
return err
}
if _, err := decoder.Token(); err != io.EOF {
if err == nil {
return fmt.Errorf("manifest has multiple JSON values")
}
return err
}
return nil
}
func validateValidPeriodJSON(decoder *json.Decoder) error {
return validateJSONObject(decoder, validPeriodFields)
}
func validateDataPackageJSON(decoder *json.Decoder) error {
return validateJSONObject(decoder, dataPackageFields)
}
func validateResultsJSON(decoder *json.Decoder) error {
token, err := decoder.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return fmt.Errorf("results must be an array")
}
for decoder.More() {
if err := validateJSONObject(decoder, resultFields); err != nil {
return err
}
}
token, err = decoder.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != ']' {
return fmt.Errorf("results has an invalid array terminator")
}
return nil
}
func validateSafeErrorJSON(decoder *json.Decoder) error {
token, err := decoder.Token()
if err != nil {
return err
}
if token == nil {
return nil
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return fmt.Errorf("error must be an object")
}
return validateJSONObjectBody(decoder, safeErrorFields)
}
func validateJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
token, err := decoder.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return fmt.Errorf("manifest value must be an object")
}
return validateJSONObjectBody(decoder, fields)
}
func validateJSONObjectBody(decoder *json.Decoder, fields map[string]jsonValueValidator) error {
seen := make(map[string]struct{}, len(fields))
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return err
}
name, ok := token.(string)
if !ok {
return fmt.Errorf("manifest field name is invalid")
}
validator, known := fields[name]
if !known {
return fmt.Errorf("manifest field %q is not canonical", name)
}
if _, duplicate := seen[name]; duplicate {
return fmt.Errorf("manifest field %q is duplicated", name)
}
seen[name] = struct{}{}
if validator == nil {
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return err
}
continue
}
if err := validator(decoder); err != nil {
return err
}
}
token, err := decoder.Token()
if err != nil {
return err
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' {
return fmt.Errorf("manifest object has an invalid terminator")
}
return nil
}
func unrecognizedBundleError(action string, err error) error {
if err == nil {
return fmt.Errorf("%w: %s", ErrUnrecognizedBundle, action)
@@ -262,22 +516,44 @@ func unrecognizedBundleError(action string, err error) error {
return fmt.Errorf("%w: %s: %v", ErrUnrecognizedBundle, action, err)
}
// BackupRecoveryState describes what remains of a prior bundle after cleanup
// reports an error.
type BackupRecoveryState string
const (
BackupRecoveryComplete BackupRecoveryState = "complete"
BackupRecoveryPartial BackupRecoveryState = "partial"
BackupRecoveryAbsent BackupRecoveryState = "absent"
BackupRecoveryUnknown BackupRecoveryState = "unknown"
)
// PublicationResult describes the durable state of a publication attempt.
type PublicationResult struct {
Committed bool
RetainedBackupPath string
RecoveryState BackupRecoveryState
RecoveryPath string
}
// PublicationCleanupError reports that a committed bundle could not remove its
// prior sibling backup. The new bundle remains installed and the backup path
// is retained for operator recovery.
// PublicationCleanupError reports that a committed bundle could not completely
// remove its prior sibling backup. The new bundle remains installed; recovery
// fields describe the state observed after cleanup failed.
type PublicationCleanupError struct {
RetainedBackupPath string
RecoveryState BackupRecoveryState
RecoveryPath string
Err error
}
func (err *PublicationCleanupError) Error() string {
return fmt.Sprintf("remove comparison backup %q: %v", err.RetainedBackupPath, err.Err)
switch err.RecoveryState {
case BackupRecoveryComplete:
return fmt.Sprintf("remove comparison backup %q: %v; complete recovery bundle remains", err.RecoveryPath, err.Err)
case BackupRecoveryPartial:
return fmt.Sprintf("remove comparison backup %q: %v; partial remnants remain", err.RecoveryPath, err.Err)
case BackupRecoveryAbsent:
return fmt.Sprintf("remove comparison backup: %v; no recovery bundle remains", err.Err)
default:
return fmt.Sprintf("remove comparison backup: %v; recovery state is unknown", err.Err)
}
}
func (err *PublicationCleanupError) Unwrap() error {
@@ -294,6 +570,7 @@ type publishOperations struct {
rename func(string, string) error
removeAll func(string) error
beforeCommit func()
recognize func(string) (Manifest, error)
}
func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, operations publishOperations) (PublicationResult, error) {
@@ -303,6 +580,9 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
if operations.removeAll == nil {
operations.removeAll = os.RemoveAll
}
if operations.recognize == nil {
operations.recognize = RecognizeBundle
}
if err := bundle.Validate(); err != nil {
return PublicationResult{}, fmt.Errorf("validate comparison bundle: %w", err)
}
@@ -313,7 +593,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
if err := ctx.Err(); err != nil {
return PublicationResult{}, err
}
plan, err = PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace)
plan, err = reauthorizeDestination(plan)
if err != nil {
return PublicationResult{}, err
}
@@ -321,7 +601,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
if err := os.MkdirAll(filepath.Dir(plan.Target), 0o755); err != nil {
return PublicationResult{}, fmt.Errorf("create comparison destination parent %q: %w", filepath.Dir(plan.Target), err)
}
temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), "."+filepath.Base(plan.Target)+".staging-")
temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), stagingDirectoryPattern)
if err != nil {
return PublicationResult{}, fmt.Errorf("create comparison staging directory: %w", err)
}
@@ -339,7 +619,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
return PublicationResult{}, err
}
currentPlan, err := PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace)
currentPlan, err := reauthorizeDestination(plan)
if err != nil {
return PublicationResult{}, err
}
@@ -357,14 +637,17 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
return PublicationResult{Committed: true}, nil
}
backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), "."+filepath.Base(currentPlan.Target)+".backup-")
backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), backupDirectoryPattern)
if err != nil {
return PublicationResult{}, err
}
if err := operations.rename(currentPlan.Target, backupDirectory); err != nil {
return PublicationResult{}, fmt.Errorf("back up comparison destination %q: %w", currentPlan.Target, err)
}
if err := authorizeMovedDestination(currentPlan, backupDirectory); err != nil {
if err := authorizeMovedDestination(currentPlan, backupDirectory, operations.recognize); err != nil {
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
}
if err := ctx.Err(); err != nil {
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
}
if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil {
@@ -372,14 +655,28 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
}
temporaryDirectory = ""
if err := operations.removeAll(backupDirectory); err != nil {
cleanupErr := &PublicationCleanupError{RetainedBackupPath: backupDirectory, Err: err}
return PublicationResult{Committed: true, RetainedBackupPath: backupDirectory}, cleanupErr
recoveryState, recoveryPath := inspectBackupRecovery(backupDirectory)
cleanupErr := &PublicationCleanupError{RecoveryState: recoveryState, RecoveryPath: recoveryPath, Err: err}
return PublicationResult{Committed: true, RecoveryState: recoveryState, RecoveryPath: recoveryPath}, cleanupErr
}
return PublicationResult{Committed: true}, nil
}
func authorizeMovedDestination(plan DestinationPlan, backupDirectory string) error {
backupPlan, err := PlanDestination(plan.WorkingDirectory, backupDirectory, plan.Replace)
func inspectBackupRecovery(backupDirectory string) (BackupRecoveryState, string) {
if _, err := os.Lstat(backupDirectory); err != nil {
if errors.Is(err, os.ErrNotExist) {
return BackupRecoveryAbsent, ""
}
return BackupRecoveryUnknown, ""
}
if _, err := RecognizeBundle(backupDirectory); err == nil {
return BackupRecoveryComplete, backupDirectory
}
return BackupRecoveryPartial, backupDirectory
}
func authorizeMovedDestination(plan DestinationPlan, backupDirectory string, recognize func(string) (Manifest, error)) error {
backupPlan, err := planDestination(plan.WorkingDirectory, backupDirectory, plan.Replace, recognize)
if err != nil {
return fmt.Errorf("authorize moved comparison destination %q: %w", backupDirectory, err)
}

View File

@@ -4,11 +4,15 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestPlanDestination(t *testing.T) {
@@ -47,18 +51,16 @@ func TestPlanDestination(t *testing.T) {
assertDestinationErrorKind(t, workingDirectory, string(os.PathSeparator), false, DestinationFilesystemRoot)
assertDestinationErrorKind(t, workingDirectory, "relative", false, DestinationInvalidPath)
t.Run("symbolic links", func(t *testing.T) {
link := filepath.Join(workingDirectory, "link")
if err := os.Symlink(empty, link); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, empty, link)
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
dangling := filepath.Join(workingDirectory, "dangling")
if err := os.Symlink(filepath.Join(workingDirectory, "missing"), dangling); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, filepath.Join(workingDirectory, "missing"), dangling)
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
})
nonempty := filepath.Join(workingDirectory, "nonempty")
if err := os.Mkdir(nonempty, 0o755); err != nil {
@@ -116,9 +118,7 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(directory, DataPackageFilename), path); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, filepath.Join(directory, DataPackageFilename), path)
}},
{name: "digest mismatch", mutate: func(t *testing.T, directory string) {
t.Helper()
@@ -138,6 +138,14 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
t.Fatal(err)
}
}},
{name: "duplicate manifest field", mutate: func(t *testing.T, directory string) {
t.Helper()
appendManifestField(t, directory, `"schemaVersion": "weatherreporter.comparison.v1"`)
}},
{name: "case variant manifest field", mutate: func(t *testing.T, directory string) {
t.Helper()
appendManifestField(t, directory, `"SchemaVersion": "weatherreporter.comparison.v1"`)
}},
{name: "traversal report path", mutate: func(t *testing.T, directory string) {
t.Helper()
path := filepath.Join(directory, ManifestFilename)
@@ -158,6 +166,29 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
t.Fatal(err)
}
}},
{name: "noncanonical report path", mutate: func(t *testing.T, directory string) {
t.Helper()
path := filepath.Join(directory, ManifestFilename)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var manifest Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(directory, manifest.Results[0].ReportPath), filepath.Join(directory, "arbitrary.md")); err != nil {
t.Fatal(err)
}
manifest.Results[0].ReportPath = "arbitrary.md"
data, err = json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}},
}
for _, test := range tests {
@@ -171,6 +202,19 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
}
}
func appendManifestField(t *testing.T, directory, field string) {
t.Helper()
path := filepath.Join(directory, ManifestFilename)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
data = append(data[:len(data)-2], []byte(",\n "+field+"\n}\n")...)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
directory := publishTestBundle(t, testBundle())
plan, err := PlanDestination(filepath.Dir(directory), directory, true)
@@ -182,6 +226,75 @@ func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
}
}
func TestRecognizeBundleReadsOnlyRequiredArtifactContents(t *testing.T) {
for _, reportCount := range []int{2, 12} {
t.Run(fmt.Sprintf("%d reports", reportCount), func(t *testing.T) {
bundle := testBundleWithReports(t, reportCount)
directory := publishTestBundle(t, bundle)
bytesRead := make(map[string]int)
opens := make(map[string]int)
operations := defaultRecognitionOperations
operations.open = func(path string) (io.ReadCloser, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
name := filepath.Base(path)
opens[name]++
return &countingReadCloser{ReadCloser: file, count: func(n int) {
bytesRead[name] += n
}}, nil
}
if _, err := recognizeBundle(directory, operations); err != nil {
t.Fatalf("recognizeBundle() error = %v", err)
}
for _, name := range []string{ManifestFilename, DataPackageFilename} {
data := readFile(t, filepath.Join(directory, name))
if opens[name] != 1 || bytesRead[name] != len(data) {
t.Fatalf("%s opens/bytes = %d/%d, want 1/%d", name, opens[name], bytesRead[name], len(data))
}
}
for _, report := range bundle.Reports {
if opens[report.Path] != 1 || bytesRead[report.Path] != 0 {
t.Fatalf("report %s opens/bytes = %d/%d, want 1/0", report.Path, opens[report.Path], bytesRead[report.Path])
}
}
})
}
}
func TestReplacementUsesEarlyAndFinalRecognition(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
bundle := testBundleWithReports(t, 12)
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, bundle); err != nil {
t.Fatal(err)
}
recognitions := 0
recognize := func(directory string) (Manifest, error) {
recognitions++
return RecognizeBundle(directory)
}
plan, err := planDestination(workingDirectory, target, true, recognize)
if err != nil {
t.Fatal(err)
}
if _, err := publish(context.Background(), plan, bundle, publishOperations{
rename: os.Rename, removeAll: os.RemoveAll, recognize: recognize,
}); err != nil {
t.Fatal(err)
}
if recognitions != 2 {
t.Fatalf("full bundle recognitions = %d, want early and final authorization", recognitions)
}
}
func TestPublishReauthorizesMovedDestination(t *testing.T) {
tests := []struct {
name string
@@ -254,9 +367,7 @@ func TestPublishReauthorizesMovedDestination(t *testing.T) {
},
mutate: func(t *testing.T, target string) {
t.Helper()
if err := os.Symlink("unrelated-target", target); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, "unrelated-target", target)
},
verify: func(t *testing.T, target string) {
t.Helper()
@@ -462,6 +573,49 @@ func TestPublishReplacesEmptyDirectory(t *testing.T) {
}
}
func TestComparisonDestinationPreflightsTransactionSiblingNames(t *testing.T) {
workingDirectory := t.TempDir()
missingParent := filepath.Join(workingDirectory, "missing")
target := filepath.Join(missingParent, strings.Repeat("a", maxComparisonComponentBytes+1))
if _, err := PlanDestination(workingDirectory, target, false); err == nil {
t.Fatal("PlanDestination() accepted an overlong destination name")
}
if _, err := os.Lstat(missingParent); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing parent stat error = %v, want not exist", err)
}
}
func TestPublishSupportsLongestDestinationComponent(t *testing.T) {
workingDirectory := t.TempDir()
parent := filepath.Join(workingDirectory, "nested")
target := filepath.Join(parent, strings.Repeat("a", maxComparisonComponentBytes))
plan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatalf("PlanDestination() error = %v", err)
}
if _, err := Publish(context.Background(), plan, testBundle()); err != nil {
t.Fatalf("Publish() error = %v", err)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("RecognizeBundle() error = %v", err)
}
next := testBundle()
next.Reports[0].Markdown = []byte("# Replacement\n")
plan, err = PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatalf("PlanDestination(replace) error = %v", err)
}
if _, err := Publish(context.Background(), plan, next); err != nil {
t.Fatalf("Publish(replace) error = %v", err)
}
if data := readFile(t, filepath.Join(target, "01-weather-light.md")); string(data) != "# Replacement\n" {
t.Fatalf("replacement report = %q", data)
}
assertOnlyDestinationEntry(t, parent, filepath.Base(target))
}
func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
@@ -490,7 +644,7 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
},
})
var cleanupErr *PublicationCleanupError
if !result.Committed || result.RetainedBackupPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RetainedBackupPath != backupPath || !errors.Is(err, cleanupCause) {
if !result.Committed || result.RecoveryState != BackupRecoveryComplete || result.RecoveryPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryComplete || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) {
t.Fatalf("publish() result/error = %#v/%v", result, err)
}
if _, err := RecognizeBundle(target); err != nil {
@@ -505,6 +659,82 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
}
}
func TestPublishReportsPartialRecoveryAfterBackupCleanupFailure(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
cleanupCause := errors.New("backup removal failed")
var backupPath string
result, err := publish(context.Background(), plan, testBundle(), publishOperations{
rename: os.Rename,
removeAll: func(path string) error {
backupPath = path
if err := os.Remove(filepath.Join(path, ManifestFilename)); err != nil {
t.Fatal(err)
}
return cleanupCause
},
})
var cleanupErr *PublicationCleanupError
if !result.Committed || result.RecoveryState != BackupRecoveryPartial || result.RecoveryPath != backupPath || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryPartial || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) {
t.Fatalf("publish() result/error = %#v/%v", result, err)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("new bundle recognition error = %v", err)
}
if _, err := RecognizeBundle(backupPath); err == nil {
t.Fatal("partially removed backup was recognized")
}
}
func TestPublishReportsAbsentRecoveryAfterBackupCleanupFailure(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
cleanupCause := errors.New("backup removal failed")
var backupPath string
result, err := publish(context.Background(), plan, testBundle(), publishOperations{
rename: os.Rename,
removeAll: func(path string) error {
backupPath = path
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
return cleanupCause
},
})
var cleanupErr *PublicationCleanupError
if !result.Committed || result.RecoveryState != BackupRecoveryAbsent || result.RecoveryPath != "" || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryAbsent || cleanupErr.RecoveryPath != "" || !errors.Is(err, cleanupCause) {
t.Fatalf("publish() result/error = %#v/%v", result, err)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("new bundle recognition error = %v", err)
}
if _, err := os.Lstat(backupPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("backup stat error = %v, want not exist", err)
}
}
func TestPublishRestoresExistingBundleAfterReplacementFailure(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
@@ -598,6 +828,154 @@ func TestPublishCancellationBeforeCommitLeavesNoDestination(t *testing.T) {
}
}
func TestPublishRestoresPriorDestinationWhenCanceledAfterBackup(t *testing.T) {
for _, test := range []struct {
name string
prepare func(t *testing.T, workingDirectory, target string)
}{
{
name: "empty directory",
prepare: func(t *testing.T, _, target string) {
t.Helper()
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
},
},
{
name: "recognized bundle",
prepare: func(t *testing.T, workingDirectory, target string) {
t.Helper()
plan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), plan, testBundle()); err != nil {
t.Fatal(err)
}
},
},
} {
t.Run(test.name, func(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
test.prepare(t, workingDirectory, target)
before := directorySnapshot(t, target)
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
next := testBundle()
next.Reports[0].Markdown = []byte("# Replacement\n")
publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error {
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
if oldPath == target {
cancel()
}
return nil
}})
if publication.Committed || !errors.Is(err, context.Canceled) {
t.Fatalf("publish() result/error = %#v/%v, want canceled uncommitted publication", publication, err)
}
if after := directorySnapshot(t, target); !equalSnapshots(before, after) {
t.Fatalf("restored destination = %#v, want %#v", after, before)
}
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target))
})
}
}
func TestPublishRetainsBackupWhenCancellationRestorationFails(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
restoreCause := errors.New("restore failed")
var backupPath string
calls := 0
publication, err := publish(ctx, plan, testBundle(), publishOperations{rename: func(oldPath, newPath string) error {
calls++
if calls == 2 {
return restoreCause
}
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
if calls == 1 {
backupPath = newPath
cancel()
}
return nil
}})
if publication.Committed || !errors.Is(err, context.Canceled) || !errors.Is(err, restoreCause) {
t.Fatalf("publish() result/error = %#v/%v, want cancellation and restoration failure", publication, err)
}
if info, statErr := os.Stat(backupPath); statErr != nil || !info.IsDir() {
t.Fatalf("backup stat = %v, %v, want retained directory", info, statErr)
}
if _, statErr := os.Lstat(target); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("destination stat error = %v, want not exist", statErr)
}
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(backupPath))
}
func TestPublishReturnsCommittedBundleWhenCanceledAfterInstall(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
next := testBundle()
next.Reports[0].Markdown = []byte("# Next\n")
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error {
if err := os.Rename(oldPath, newPath); err != nil {
return err
}
if newPath == target && oldPath != target {
cancel()
}
return nil
}})
if !publication.Committed || err != nil {
t.Fatalf("publish() result/error = %#v/%v, want committed bundle", publication, err)
}
if err := ctx.Err(); !errors.Is(err, context.Canceled) {
t.Fatalf("context error = %v, want cancellation", err)
}
if got := string(readFile(t, filepath.Join(target, next.Reports[0].Path))); got != string(next.Reports[0].Markdown) {
t.Fatalf("published report = %q, want %q", got, next.Reports[0].Markdown)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("committed bundle recognition error = %v", err)
}
assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target))
}
func assertDestinationErrorKind(t *testing.T, workingDirectory, target string, replace bool, want DestinationErrorKind) {
t.Helper()
_, err := PlanDestination(workingDirectory, target, replace)
@@ -683,6 +1061,41 @@ func testBundle() LogicalBundle {
}
}
func testBundleWithReports(t *testing.T, count int) LogicalBundle {
t.Helper()
dataPackage := []byte("report: daily\n")
manifest := validManifest()
manifest.Total, manifest.Succeeded, manifest.Failed = count, count, 0
manifest.Results = make([]Result, count)
reports := make([]BundleReport, count)
for i := range manifest.Results {
position := i + 1
profileID := fmt.Sprintf("weather-%d", position)
path, err := ReportFilename(position, count, profileID)
if err != nil {
t.Fatal(err)
}
manifest.Results[i] = Result{
Position: position, ProfileID: profileID, ModelName: "gpt-5-mini",
Status: StatusSucceeded, ValidationStatus: "passed", ReportPath: path,
}
reports[i] = BundleReport{Position: position, Path: path, Markdown: []byte("# Daily\n")}
}
manifest.DataPackage.SHA256 = SHA256(dataPackage)
return LogicalBundle{Manifest: manifest, DataPackage: dataPackage, Reports: reports}
}
type countingReadCloser struct {
io.ReadCloser
count func(int)
}
func (reader *countingReadCloser) Read(buffer []byte) (int, error) {
n, err := reader.ReadCloser.Read(buffer)
reader.count(n)
return n, err
}
func assertMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)

View File

@@ -21,6 +21,33 @@ const (
NotifyFailureError NotifyFailurePolicy = "error"
)
const (
MissingSourceObservations = "observations"
MissingSourceCurrent = "current"
MissingSourceNarrative = "narrative"
MissingSourceAlerts = "alerts"
MissingSourceDiscussion = "discussion"
MissingSourceWeatherStory = "weather_story"
MissingSourceSPCConvectiveOutlooks = "spc_convective_outlooks"
)
var supportedMissingSources = map[string]struct{}{
MissingSourceObservations: {},
MissingSourceCurrent: {},
MissingSourceNarrative: {},
MissingSourceAlerts: {},
MissingSourceDiscussion: {},
MissingSourceWeatherStory: {},
MissingSourceSPCConvectiveOutlooks: {},
}
// IsSupportedMissingSource reports whether source accepts a missing-source
// policy override. Required sources, including hourly, are not configurable.
func IsSupportedMissingSource(source string) bool {
_, ok := supportedMissingSources[source]
return ok
}
type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
@@ -246,7 +273,11 @@ func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
}
func (c ReportDistributorConfig) PathTemplatesSet() bool {
return c.pathTemplatesSet
return c.pathTemplatesSet || c.PathTemplates != nil
}
func (c ReportConfig) deterministicModulesConfigured() bool {
return c.deterministicModulesSet || c.DeterministicModules != nil
}
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
"gopkg.in/yaml.v3"
)
@@ -82,6 +83,70 @@ func TestDefaults(t *testing.T) {
}
}
func TestDaypartNamesMustHaveDistinctCanonicalIdentities(t *testing.T) {
tests := []struct {
name string
names []string
wantErr string
}{
{name: "exact duplicate", names: []string{"morning", "morning"}, wantErr: "conflicts with"},
{name: "case-only duplicate", names: []string{"morning", "MORNING"}, wantErr: "conflicts with"},
{name: "punctuation-normalized duplicate", names: []string{"morning", "morning!"}, wantErr: "conflicts with"},
{name: "distinct Unicode names", names: []string{"mañana", "manana"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Dayparts = make([]DaypartConfig, 0, len(tt.names))
for _, name := range tt.names {
cfg.Dayparts = append(cfg.Dayparts, DaypartConfig{Name: name, Start: "06:00", End: "12:00"})
}
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestWeatherAPIBaseURLValidation(t *testing.T) {
tests := []struct {
name string
baseURL string
wantErr string
}{
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.WeatherAPI.BaseURL = tt.baseURL
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestOutputDirectoryLoading(t *testing.T) {
tests := []struct {
name string
@@ -533,6 +598,35 @@ func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(
}
}
func TestReportModuleOverridesCanonicalizesTypedPointerOptions(t *testing.T) {
cfg := Defaults()
options := &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: options},
},
deterministicModulesSet: true,
},
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
got, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
}
if !reflect.DeepEqual(got, *options) {
t.Fatalf("override options = %#v, want %#v", got, *options)
}
if cfg.Reports["daily"].DeterministicModules[1].Options != options {
t.Fatalf("config options = %#v, want original pointer %#v", cfg.Reports["daily"].DeterministicModules[1].Options, options)
}
}
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct {
@@ -1066,6 +1160,44 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
}
}
func TestMissingSourcePoliciesOnlyAllowSupportedOptionalSources(t *testing.T) {
for _, source := range []string{
MissingSourceObservations,
MissingSourceCurrent,
MissingSourceNarrative,
MissingSourceAlerts,
MissingSourceDiscussion,
MissingSourceWeatherStory,
MissingSourceSPCConvectiveOutlooks,
} {
t.Run("supported_"+source, func(t *testing.T) {
cfg := Defaults()
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceNone}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
loaded, err := LoadFile(writeConfig(t, "missing_source:\n sources:\n "+source+": none\n"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if loaded.MissingSource.Sources[source] != MissingSourceNone {
t.Fatalf("loaded source policy = %q, want none", loaded.MissingSource.Sources[source])
}
})
}
for _, source := range []string{"alert", "unknown", "hourly", ""} {
t.Run("unsupported_"+source, func(t *testing.T) {
cfg := Defaults()
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{source: MissingSourceWarn}
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "missing_source.sources") {
t.Fatalf("Validate() error = %v, want unsupported source error", err)
}
})
}
}
func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil {
@@ -1183,6 +1315,7 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
name string
mutate func(*Config)
wantErr string
wantAbsent string
}{
{
name: "Endpoint",
@@ -1191,6 +1324,35 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
},
wantErr: "notify.distributor.endpoint",
},
{
name: "UnsupportedEndpointScheme",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "EndpointUserinfo",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "https://userinfo-secret@distributor.example.test"
},
wantErr: "notify.distributor.endpoint",
wantAbsent: "userinfo-secret",
},
{
name: "EndpointQuery",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "https://distributor.example.test?preview=1"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "EndpointFragment",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Endpoint = "https://distributor.example.test#status"
},
wantErr: "notify.distributor.endpoint",
},
{
name: "TokenEnvEmpty",
mutate: func(cfg *Config) {
@@ -1247,6 +1409,13 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
},
wantErr: "notify.distributor.bundle_id_template",
},
{
name: "BundleTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.BundleIDTemplate = " "
},
wantErr: "notify.distributor.bundle_id_template",
},
{
name: "IdempotencyTemplate",
mutate: func(cfg *Config) {
@@ -1254,6 +1423,13 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
},
wantErr: "notify.distributor.idempotency_key_template",
},
{
name: "IdempotencyTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.IdempotencyKeyTemplate = " "
},
wantErr: "notify.distributor.idempotency_key_template",
},
{
name: "BatchTemplate",
mutate: func(cfg *Config) {
@@ -1277,6 +1453,27 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
if tt.wantAbsent != "" && strings.Contains(err.Error(), tt.wantAbsent) {
t.Fatalf("error = %q, must not contain endpoint userinfo", err.Error())
}
})
}
}
func TestEnabledDistributorNotifyAcceptsHTTPBasePaths(t *testing.T) {
for _, endpoint := range []string{
"http://distributor.example.test/archive",
"https://distributor.example.test/archive/",
} {
t.Run(endpoint, func(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.Endpoint = endpoint
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}
@@ -1529,43 +1726,59 @@ func TestDistributorBatchTemplateRendering(t *testing.T) {
}
}
func TestDistributorBatchTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
func TestDistributorTemplateRejectsUnknownVariables(t *testing.T) {
tests := []struct {
name string
template string
render func(string) error
}{
{name: "Unknown", template: "{report_id}"},
{name: "Unclosed", template: "{batch"},
{name: "Unopened", template: "batch}"},
{name: "Empty", template: "{}"},
{
name: "SingleReport",
template: "{unknown}",
render: func(template string) error {
_, err := RenderDistributorBundleID(template, DistributorTemplateValues{})
return err
},
},
{
name: "Batch",
template: "{report_id}",
render: func(template string) error {
_, err := RenderDistributorBatchBundleID(template, DistributorBatchTemplateValues{})
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorBatchBundleID(tt.template, DistributorBatchTemplateValues{})
err := tt.render(tt.template)
if err == nil {
t.Fatal("RenderDistributorBatchBundleID() error = nil, want error")
t.Fatal("rendering error = nil, want error")
}
})
}
}
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
func TestDistributorTemplateParserRejectsMalformedVariables(t *testing.T) {
const name = "notify.distributor.bundle_id_template"
tests := []struct {
name string
template string
wantErr string
}{
{name: "Unknown", template: "{unknown}"},
{name: "Unclosed", template: "{location_id"},
{name: "Unopened", template: "location_id}"},
{name: "Empty", template: "{}"},
{name: "Unclosed", template: "{location_id", wantErr: name + " contains an unclosed template variable"},
{name: "Unopened", template: "location_id}", wantErr: name + " contains an unopened template variable"},
{name: "Empty", template: "{}", wantErr: name + " contains an empty template variable"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{})
if err == nil {
t.Fatal("RenderDistributorBundleID() error = nil, want error")
_, err := renderDistributorTemplate(name, tt.template, func(variable string) (string, bool) {
return "value", variable == "location_id"
})
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
})
}
@@ -1668,9 +1881,13 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
if err := loadSecrets(SecretsConfig{}); err != nil {
secrets, err := loadSecrets(SecretsConfig{})
if err != nil {
t.Fatalf("loadSecrets() error = %v", err)
}
if len(secrets) != 0 {
t.Fatalf("staged secrets = %#v, want none", secrets)
}
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
t.Fatalf("environment value = %q, want original", got)
}
@@ -1706,9 +1923,13 @@ func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
}
t.Setenv("WEATHERREPORTER_SECRET", "existing")
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
secrets, err := loadSecrets(SecretsConfig{Directory: dir})
if err != nil {
t.Fatalf("loadSecrets() error = %v", err)
}
if err := applySecrets(secrets); err != nil {
t.Fatalf("applySecrets() error = %v", err)
}
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
t.Fatalf("environment value = %q, want from-file", got)
}
@@ -1735,9 +1956,13 @@ func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
}
t.Setenv("WEATHERREPORTER_SECRET", "")
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
secrets, err := loadSecrets(SecretsConfig{Directory: dir})
if err != nil {
t.Fatalf("loadSecrets() error = %v", err)
}
if err := applySecrets(secrets); err != nil {
t.Fatalf("applySecrets() error = %v", err)
}
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
t.Fatalf("environment value = %q, want %q", got, tt.want)
}
@@ -1776,9 +2001,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
t.Fatalf("write target: %v", err)
}
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
t.Fatalf("create symlink: %v", err)
}
testutil.RequireSymlink(t, target, filepath.Join(dir, "SYMLINK"))
},
wantErr: "not a symlink",
},
@@ -1808,7 +2031,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
dir := t.TempDir()
tt.setup(t, dir)
err := loadSecrets(SecretsConfig{Directory: dir})
_, err := loadSecrets(SecretsConfig{Directory: dir})
if err == nil {
t.Fatal("loadSecrets() error = nil, want error")
}
@@ -1823,7 +2046,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
}
func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
_, err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
if err == nil {
t.Fatal("loadSecrets() error = nil, want missing directory error")
}
@@ -1831,3 +2054,82 @@ func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
t.Fatalf("error = %q, want read secrets directory context", err.Error())
}
}
func TestLoadFileSecretDirectoryFailureLeavesEnvironmentUnchanged(t *testing.T) {
dir := t.TempDir()
secretsDir := filepath.Join(dir, "secrets")
if err := os.Mkdir(secretsDir, 0o700); err != nil {
t.Fatalf("create secrets directory: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "A_SECRET"), []byte("new-value"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "Z-INVALID"), []byte("unused"), 0o600); err != nil {
t.Fatalf("write invalid secret: %v", err)
}
path := writeConfig(t, "secrets:\n directory: "+secretsDir+"\n")
t.Setenv("A_SECRET", "original-value")
_, err := LoadFile(path)
if err == nil || !strings.Contains(err.Error(), "invalid environment variable name") {
t.Fatalf("LoadFile() error = %v, want invalid secret filename", err)
}
if got := os.Getenv("A_SECRET"); got != "original-value" {
t.Fatalf("environment value = %q, want original-value after rejected load", got)
}
}
func TestLoadFileValidationFailureLeavesSecretEnvironmentUnset(t *testing.T) {
dir := t.TempDir()
secretsDir := filepath.Join(dir, "secrets")
if err := os.Mkdir(secretsDir, 0o700); err != nil {
t.Fatalf("create secrets directory: %v", err)
}
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("new-value"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
path := writeConfig(t, "secrets:\n directory: "+secretsDir+"\nmissing_source:\n default: invalid\n")
unsetEnvironment(t, "WEATHERREPORTER_SECRET")
_, err := LoadFile(path)
if err == nil || !strings.Contains(err.Error(), "missing_source.default") {
t.Fatalf("LoadFile() error = %v, want configuration validation error", err)
}
if _, set := os.LookupEnv("WEATHERREPORTER_SECRET"); set {
t.Fatal("WEATHERREPORTER_SECRET was set by a rejected configuration")
}
}
func TestApplySecretsRollsBackOnEnvironmentFailure(t *testing.T) {
t.Setenv("A_SECRET", "original-value")
unsetEnvironment(t, "Z_SECRET")
err := applySecrets([]secretValue{
{name: "A_SECRET", value: "new-value"},
{name: "Z_SECRET", value: "invalid\x00value"},
})
if err == nil || !strings.Contains(err.Error(), `secret file "Z_SECRET"`) {
t.Fatalf("applySecrets() error = %v, want Z_SECRET context", err)
}
if got := os.Getenv("A_SECRET"); got != "original-value" {
t.Fatalf("A_SECRET = %q, want original-value after rollback", got)
}
if _, set := os.LookupEnv("Z_SECRET"); set {
t.Fatal("Z_SECRET was set after failed environment application")
}
}
func unsetEnvironment(t *testing.T, name string) {
t.Helper()
value, set := os.LookupEnv(name)
if err := os.Unsetenv(name); err != nil {
t.Fatalf("unset environment variable %q: %v", name, err)
}
t.Cleanup(func() {
if set {
_ = os.Setenv(name, value)
return
}
_ = os.Unsetenv(name)
})
}

View File

@@ -40,13 +40,17 @@ func Load(opts LoadOptions) (Config, error) {
return Config{}, err
}
if err := loadSecrets(cfg.Secrets); err != nil {
secrets, err := loadSecrets(cfg.Secrets)
if err != nil {
return Config{}, err
}
if err := Validate(cfg); err != nil {
return Config{}, err
}
if err := applySecrets(secrets); err != nil {
return Config{}, err
}
return cfg, nil
}

View File

@@ -78,11 +78,18 @@ var distributorBatchIdempotencyTemplateVariables = map[string]struct{}{
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
rendered, err := renderDistributorTemplate("notify.distributor.bundle_id_template", template, newDistributorTemplateResolver(values, distributorTemplateVariables))
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.bundle_id_template renders an empty bundle id")
}
return rendered, nil
}
func RenderDistributorPipelineID(template string, values DistributorTemplateValues) (string, error) {
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, values, distributorPipelineTemplateVariables)
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, newDistributorTemplateResolver(values, distributorPipelineTemplateVariables))
if err != nil {
return "", err
}
@@ -93,11 +100,18 @@ func RenderDistributorPipelineID(template string, values DistributorTemplateValu
}
func RenderDistributorIdempotencyKey(template string, values DistributorTemplateValues) (string, error) {
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
rendered, err := renderDistributorTemplate("notify.distributor.idempotency_key_template", template, newDistributorTemplateResolver(values, distributorIdempotencyTemplateVariables))
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.idempotency_key_template renders an empty idempotency key")
}
return rendered, nil
}
func RenderDistributorBatchBundleID(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", template, values, distributorBatchTemplateVariables)
rendered, err := renderDistributorTemplate("notify.distributor.batch.bundle_id_template", template, newDistributorBatchTemplateResolver(values, distributorBatchTemplateVariables))
if err != nil {
return "", err
}
@@ -108,7 +122,7 @@ func RenderDistributorBatchBundleID(template string, values DistributorBatchTemp
}
func RenderDistributorBatchPipelineID(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", template, values, distributorBatchPipelineTemplateVariables)
rendered, err := renderDistributorTemplate("notify.distributor.batch.pipeline_id_template", template, newDistributorBatchTemplateResolver(values, distributorBatchPipelineTemplateVariables))
if err != nil {
return "", err
}
@@ -119,7 +133,7 @@ func RenderDistributorBatchPipelineID(template string, values DistributorBatchTe
}
func RenderDistributorBatchIdempotencyKey(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", template, values, distributorBatchIdempotencyTemplateVariables)
rendered, err := renderDistributorTemplate("notify.distributor.batch.idempotency_key_template", template, newDistributorBatchTemplateResolver(values, distributorBatchIdempotencyTemplateVariables))
if err != nil {
return "", err
}
@@ -137,7 +151,7 @@ func RenderDistributorReportPaths(name string, templates []string, values Distri
seen := make(map[string]struct{}, len(templates))
for i, template := range templates {
itemName := fmt.Sprintf("%s[%d]", name, i)
rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
rendered, err := renderDistributorTemplate(itemName, template, newDistributorTemplateResolver(values, distributorTemplateVariables))
if err != nil {
return nil, err
}
@@ -154,16 +168,18 @@ func RenderDistributorReportPaths(name string, templates []string, values Distri
}
func validateDistributorTemplate(name, template string, allowed map[string]struct{}) error {
_, err := renderDistributorTemplate(name, template, DistributorTemplateValues{}, allowed)
_, err := renderDistributorTemplate(name, template, newDistributorTemplateResolver(DistributorTemplateValues{}, allowed))
return err
}
func validateDistributorBatchTemplate(name, template string, allowed map[string]struct{}) error {
_, err := renderDistributorBatchTemplate(name, template, DistributorBatchTemplateValues{}, allowed)
_, err := renderDistributorTemplate(name, template, newDistributorBatchTemplateResolver(DistributorBatchTemplateValues{}, allowed))
return err
}
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
type distributorTemplateResolver func(string) (string, bool)
func renderDistributorTemplate(name, template string, resolve distributorTemplateResolver) (string, error) {
var rendered strings.Builder
for i := 0; i < len(template); {
switch template[i] {
@@ -176,10 +192,11 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
if variable == "" {
return "", fmt.Errorf("%s contains an empty template variable", name)
}
if _, ok := allowed[variable]; !ok {
value, ok := resolve(variable)
if !ok {
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
}
rendered.WriteString(distributorTemplateValue(variable, values))
rendered.WriteString(value)
i += end + 2
case '}':
return "", fmt.Errorf("%s contains an unopened template variable", name)
@@ -191,32 +208,22 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
return rendered.String(), nil
}
func renderDistributorBatchTemplate(name, template string, values DistributorBatchTemplateValues, allowed map[string]struct{}) (string, error) {
var rendered strings.Builder
for i := 0; i < len(template); {
switch template[i] {
case '{':
end := strings.IndexByte(template[i+1:], '}')
if end < 0 {
return "", fmt.Errorf("%s contains an unclosed template variable", name)
}
variable := template[i+1 : i+1+end]
if variable == "" {
return "", fmt.Errorf("%s contains an empty template variable", name)
}
func newDistributorTemplateResolver(values DistributorTemplateValues, allowed map[string]struct{}) distributorTemplateResolver {
return func(variable string) (string, bool) {
if _, ok := allowed[variable]; !ok {
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
return "", false
}
rendered.WriteString(distributorBatchTemplateValue(variable, values))
i += end + 2
case '}':
return "", fmt.Errorf("%s contains an unopened template variable", name)
default:
rendered.WriteByte(template[i])
i++
return distributorTemplateValue(variable, values), true
}
}
func newDistributorBatchTemplateResolver(values DistributorBatchTemplateValues, allowed map[string]struct{}) distributorTemplateResolver {
return func(variable string) (string, bool) {
if _, ok := allowed[variable]; !ok {
return "", false
}
return distributorBatchTemplateValue(variable, values), true
}
return rendered.String(), nil
}
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {

View File

@@ -0,0 +1,75 @@
package config_test
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestConstructedReportOverridesUseExportedFields(t *testing.T) {
cfg := config.Defaults()
cfg.Reports = map[string]config.ReportConfig{
"daily": {
DeterministicModules: []config.ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion},
},
Distributor: config.ReportDistributorConfig{
PathTemplates: []string{"daily/{valid_start_date}/index.md"},
},
},
}
if err := config.Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
modules, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
if got := modules[report.Daily]; len(got) != 2 || got[0].ID != module.Metadata || got[1].ID != module.AreaForecastDiscussion {
t.Fatalf("module override = %#v, want exported daily modules", got)
}
paths, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if got := paths[report.Daily]; len(got) != 1 || got[0] != "daily/{valid_start_date}/index.md" {
t.Fatalf("path override = %#v, want exported daily path", got)
}
}
func TestConstructedExplicitEmptyReportOverridesAreRejected(t *testing.T) {
tests := []struct {
name string
report config.ReportConfig
wantErr string
}{
{
name: "modules",
report: config.ReportConfig{DeterministicModules: []config.ModuleConfigItem{}},
wantErr: "reports.daily.deterministic_modules must contain at least one entry",
},
{
name: "paths",
report: config.ReportConfig{Distributor: config.ReportDistributorConfig{
PathTemplates: []string{},
}},
wantErr: "reports.daily.distributor.path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.Defaults()
cfg.Reports = map[string]config.ReportConfig{"daily": tt.report}
err := config.Validate(cfg)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
})
}
}

View File

@@ -72,9 +72,12 @@ func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[
if _, err := reportRegistry.Lookup(reportID); err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
if !reportCfg.deterministicModulesConfigured() {
continue
}
if len(reportCfg.DeterministicModules) == 0 {
return nil, fmt.Errorf("reports.%s.deterministic_modules must contain at least one entry", key)
}
items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
if err != nil {
return nil, err
@@ -110,7 +113,7 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string,
if _, err := reportRegistry.Lookup(reportID); err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.Distributor.pathTemplatesSet {
if !reportCfg.Distributor.PathTemplatesSet() {
continue
}
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
@@ -177,7 +180,7 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
return nil, fmt.Errorf("module %q does not accept options", id)
}
if err := definition.ValidateOptions(raw); err == nil {
return raw, nil
return definition.CanonicalOptions(raw)
}
optionType := reflect.TypeOf(definition.DefaultOptions)
normalized, err := decodeKnownOptions(raw, optionType)

View File

@@ -10,43 +10,54 @@ import (
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func loadSecrets(cfg SecretsConfig) error {
type secretValue struct {
name string
value string
}
type environmentValue struct {
value string
set bool
}
func loadSecrets(cfg SecretsConfig) ([]secretValue, error) {
if cfg.Directory == "" {
return nil
return nil, nil
}
entries, err := os.ReadDir(cfg.Directory)
if err != nil {
return fmt.Errorf("read secrets directory %q: %w", cfg.Directory, err)
return nil, fmt.Errorf("read secrets directory %q: %w", cfg.Directory, err)
}
secrets := make([]secretValue, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if name == "" {
return fmt.Errorf("secrets directory %q contains an empty filename", cfg.Directory)
return nil, fmt.Errorf("secrets directory %q contains an empty filename", cfg.Directory)
}
if !secretNamePattern.MatchString(name) {
return fmt.Errorf("secret file %q has invalid environment variable name", name)
return nil, fmt.Errorf("secret file %q has invalid environment variable name", name)
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("secret file %q must be a regular file, not a symlink", name)
return nil, fmt.Errorf("secret file %q must be a regular file, not a symlink", name)
}
if entry.IsDir() {
return fmt.Errorf("secret file %q must be a regular file, not a directory", name)
return nil, fmt.Errorf("secret file %q must be a regular file, not a directory", name)
}
info, err := entry.Info()
if err != nil {
return fmt.Errorf("inspect secret file %q: %w", name, err)
return nil, fmt.Errorf("inspect secret file %q: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("secret file %q must be a regular file", name)
return nil, fmt.Errorf("secret file %q must be a regular file", name)
}
path := filepath.Join(cfg.Directory, name)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read secret file %q: %w", name, err)
return nil, fmt.Errorf("read secret file %q: %w", name, err)
}
value := string(data)
if strings.HasSuffix(value, "\r\n") {
@@ -54,10 +65,44 @@ func loadSecrets(cfg SecretsConfig) error {
} else {
value = strings.TrimSuffix(value, "\n")
}
if err := os.Setenv(name, value); err != nil {
return fmt.Errorf("set environment variable from secret file %q: %w", name, err)
}
secrets = append(secrets, secretValue{name: name, value: value})
}
return secrets, nil
}
func applySecrets(secrets []secretValue) error {
previous := make(map[string]environmentValue, len(secrets))
applied := make([]string, 0, len(secrets))
for _, secret := range secrets {
if _, ok := previous[secret.name]; !ok {
value, set := os.LookupEnv(secret.name)
previous[secret.name] = environmentValue{value: value, set: set}
}
if err := os.Setenv(secret.name, secret.value); err != nil {
if rollbackErr := restoreEnvironment(previous, applied); rollbackErr != nil {
return fmt.Errorf("set environment variable from secret file %q: %w; restore environment: %v", secret.name, err, rollbackErr)
}
return fmt.Errorf("set environment variable from secret file %q: %w", secret.name, err)
}
applied = append(applied, secret.name)
}
return nil
}
func restoreEnvironment(previous map[string]environmentValue, names []string) error {
for i := len(names) - 1; i >= 0; i-- {
name := names[i]
value := previous[name]
var err error
if value.set {
err = os.Setenv(name, value.value)
} else {
err = os.Unsetenv(name)
}
if err != nil {
return fmt.Errorf("restore environment variable %q: %w", name, err)
}
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"net/url"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
@@ -23,6 +24,9 @@ func Validate(cfg Config) error {
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("weather_api.base_url must be an absolute URL")
}
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
return fmt.Errorf("weather_api.base_url must use http or https")
}
}
if cfg.WeatherAPI.Timeout <= 0 {
return fmt.Errorf("weather_api.timeout must be greater than zero")
@@ -53,6 +57,9 @@ func Validate(cfg Config) error {
if strings.TrimSpace(source) == "" {
return fmt.Errorf("missing_source.sources contains an empty source name")
}
if !IsSupportedMissingSource(source) {
return fmt.Errorf("missing_source.sources.%s is not a supported optional source", source)
}
if err := validatePolicy("missing_source.sources."+source, policy); err != nil {
return err
}
@@ -68,10 +75,16 @@ func Validate(cfg Config) error {
if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry")
}
daypartNames := make(map[string]int, len(cfg.Dayparts))
for i, daypart := range cfg.Dayparts {
if strings.TrimSpace(daypart.Name) == "" {
return fmt.Errorf("dayparts[%d].name is required", i)
}
key := forecast.CanonicalDaypartKey(daypart.Name)
if previous, exists := daypartNames[key]; exists {
return fmt.Errorf("dayparts[%d].name %q conflicts with dayparts[%d].name after canonicalization", i, daypart.Name, previous)
}
daypartNames[key] = i
if _, err := timeutil.ParseClock(daypart.Start); err != nil {
return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err)
}
@@ -106,9 +119,8 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
return nil
}
parsed, err := url.Parse(cfg.Endpoint)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("notify.distributor.endpoint must be an absolute URL when enabled")
if err := ValidateDistributorEndpoint(cfg.Endpoint); err != nil {
return fmt.Errorf("notify.distributor.endpoint %w", err)
}
if cfg.TokenEnv == "" {
return fmt.Errorf("notify.distributor.token_env is required when enabled")
@@ -149,6 +161,9 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
return err
}
if _, err := RenderDistributorIdempotencyKey(cfg.IdempotencyKeyTemplate, values); err != nil {
return err
}
if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
return err
}
@@ -156,6 +171,25 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
return nil
}
// ValidateDistributorEndpoint verifies the endpoint grammar accepted by the
// pinned Distributor upload client.
func ValidateDistributorEndpoint(endpoint string) error {
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("must be an absolute URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("must use http or https")
}
if parsed.User != nil {
return fmt.Errorf("must not include userinfo")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("must not include query or fragment")
}
return nil
}
func validateDistributorBatchNotify(cfg DistributorBatchNotifyConfig) error {
if !cfg.Enabled {
return nil

Some files were not shown because too many files have changed in this diff Show More