Compare commits
87 Commits
2b1fb26e7d
...
53aa0b0a55
| Author | SHA1 | Date | |
|---|---|---|---|
| 53aa0b0a55 | |||
| fc8ddada9a | |||
| 13b06039b1 | |||
| b9080466a2 | |||
| 142f2f92e7 | |||
| 88fde0df7f | |||
| b985c5faac | |||
| d6829af32b | |||
| cd7b9aef2b | |||
| c3ebf06bd5 | |||
| 7884b9a6c3 | |||
| 17468cb8dd | |||
| 71b7a74d3d | |||
| 2c4c0bbd90 | |||
| 965f16d7a4 | |||
| fb891fad07 | |||
| 0516ee148d | |||
| e6450138c2 | |||
| 57aa27c9de | |||
| 166c4ce53b | |||
| 78fc461a75 | |||
| 5e492cf1fb | |||
| 79cba800ee | |||
| 302f5aba2d | |||
| 0314a302f1 | |||
| 707db5394c | |||
| 70cad789ea | |||
| 4b748c2e53 | |||
| 0b57d99a97 | |||
| 04b8358965 | |||
| f4e3a6f26c | |||
| 44ee389334 | |||
| ef2634c2cb | |||
| a18d5134c7 | |||
| 2bd921f247 | |||
| 360c665a3e | |||
| e2dd8d0e29 | |||
| e520ffb13b | |||
| f8beed04cf | |||
| 44af91cadf | |||
| a38d291f63 | |||
| 27849813db | |||
| 41b86109e3 | |||
| 13829cc65c | |||
| daf0c7efd7 | |||
| 9b4e53702b | |||
| 730929e2ed | |||
| 8e49ba88c7 | |||
| 8fafacf921 | |||
| 8bb7307f22 | |||
| c515529b3a | |||
| 3c1ebab289 | |||
| 2d956f7315 | |||
| 4d5a1d9709 | |||
| 1d3ea64541 | |||
| 706086e3de | |||
| 26a681e0b1 | |||
| 5139c1a586 | |||
| 0b869af75e | |||
| c3eeb298f0 | |||
| 6945306a2f | |||
| 4f52555389 | |||
| fa19452dec | |||
| 91e7e5f321 | |||
| 4bb3913276 | |||
| ab571cd8ab | |||
| 798e6f11c5 | |||
| c49c50bc8d | |||
| d328a1daa6 | |||
| 7ae3820e12 | |||
| a4ef76f17a | |||
| 5ed1e264fc | |||
| c025afcd1a | |||
| 2b06541ef8 | |||
| d92ff0ef48 | |||
| 880ad710ae | |||
| edde330390 | |||
| ae52606772 | |||
| 8a323d5574 | |||
| cfb64ded34 | |||
| 5ed448df11 | |||
| 725c1420dd | |||
| e5250bd6cb | |||
| 00fe0c3e96 | |||
| 6d2c097657 | |||
| e7c7262404 | |||
| 151c536cb9 |
@@ -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)
|
||||
}
|
||||
|
||||
36
cmd/weatherreporter/main_test.go
Normal file
36
cmd/weatherreporter/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
58
cmd/weatherreporter/main_unix_test.go
Normal file
58
cmd/weatherreporter/main_unix_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
27
docs/cli.md
27
docs/cli.md
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
66
docs/releases/v0.12.0.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
7
go.mod
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
310
internal/adapters/distributor/client_http_test.go
Normal file
310
internal/adapters/distributor/client_http_test.go
Normal 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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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...),
|
||||
}
|
||||
}
|
||||
@@ -247,13 +260,12 @@ func copyInputHashes(values map[string]string) map[string]string {
|
||||
|
||||
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||
parameters := struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ExtraParams map[string]any `json:"extra_params"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
}{
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,49 +114,32 @@ 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
|
||||
}
|
||||
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
client: c,
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
}
|
||||
|
||||
if err := builder.fetchObservation(ctx); 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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return builder.bundle, nil
|
||||
}
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *weatherdata.Bundle
|
||||
fetchedAt time.Time
|
||||
client *Client
|
||||
bundle *weatherdata.Bundle
|
||||
}
|
||||
|
||||
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",
|
||||
}, ¤t)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(acquired sourceAcquisition) error {
|
||||
var current weatherdata.Current
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, ¤t)
|
||||
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)
|
||||
if err != nil {
|
||||
return fetchedSource{}, false, err
|
||||
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
|
||||
}
|
||||
acquired.fetched = fetchedSource{raw: raw, source: source}
|
||||
} else if acquired.err != nil {
|
||||
return fetchedSource{}, false, acquired.err
|
||||
}
|
||||
if raw == nil {
|
||||
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||
if acquired.fetched.raw == nil {
|
||||
return fetchedSource{}, false, b.handleMissing(&acquired.fetched.source, acquired.request.missingMessage, acquired.request.required)
|
||||
}
|
||||
return fetchedSource{raw: raw, source: source}, true, nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,48 +1055,40 @@ 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
|
||||
}
|
||||
|
||||
var weatherFixtureFiles = map[string]string{
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/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()
|
||||
fixtures := map[string]string{
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
item.Status = "failed"
|
||||
item.Error = err.Error()
|
||||
result.Failed++
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
})
|
||||
}()
|
||||
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{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
})
|
||||
}()
|
||||
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{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
})
|
||||
}()
|
||||
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{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
|
||||
})
|
||||
}()
|
||||
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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -87,27 +88,42 @@ func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testin
|
||||
}
|
||||
|
||||
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
backupPath := filepath.Join(t.TempDir(), ".comparison-daily.backup-retained")
|
||||
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}
|
||||
}
|
||||
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)
|
||||
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, RecoveryState: test.state, RecoveryPath: recoveryPath}, &comparison.PublicationCleanupError{RecoveryState: test.state, RecoveryPath: recoveryPath, Err: cleanupCause}
|
||||
}
|
||||
|
||||
result, err := compareDetailed(context.Background(), 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: &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) {
|
||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, profile := range result.Results {
|
||||
if profile.Status == comparison.StatusSucceeded && !filepath.IsAbs(profile.ReportPath) {
|
||||
t.Fatalf("published profile result = %#v", profile)
|
||||
}
|
||||
result, err := compareDetailed(context.Background(), 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: &generationExecutor{},
|
||||
}, publish)
|
||||
var cleanupErr *comparison.PublicationCleanupError
|
||||
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 {
|
||||
if profile.Status == comparison.StatusSucceeded && !filepath.IsAbs(profile.ReportPath) {
|
||||
t.Fatalf("published profile result = %#v", profile)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
45
internal/app/distributor_notification_test.go
Normal file
45
internal/app/distributor_notification_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,10 +99,27 @@ 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 {
|
||||
return nil, err
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
59
internal/app/output_linux_test.go
Normal file
59
internal/app/output_linux_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -17,19 +18,20 @@ import (
|
||||
// preparedReport contains the immutable deterministic inputs shared by prompt
|
||||
// executions for one resolved report.
|
||||
type preparedReport struct {
|
||||
resolved report.Resolved
|
||||
reportFacts ReportFacts
|
||||
moduleSnapshot module.Snapshot
|
||||
briefingMetadata briefing.Metadata
|
||||
sourceWarnings []weatherdata.SourceWarning
|
||||
dataPackage []byte
|
||||
handler generatedtext.Handler
|
||||
resolved report.Resolved
|
||||
derived facts.DerivedFacts
|
||||
moduleSnapshot module.Snapshot
|
||||
identity briefing.PreparedIdentity
|
||||
sourceWarnings []weatherdata.SourceWarning
|
||||
dataPackage []byte
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
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,
|
||||
moduleSnapshot: clonedSnapshot,
|
||||
briefingMetadata: clonedMetadata,
|
||||
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...),
|
||||
dataPackage: append([]byte(nil), serializedDataPackage...),
|
||||
handler: handler,
|
||||
resolved: cloneResolved(req.Resolved),
|
||||
derived: clonedDerived,
|
||||
moduleSnapshot: clonedSnapshot,
|
||||
identity: clonedIdentity,
|
||||
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
|
||||
dataPackage: append([]byte(nil), serializedDataPackage...),
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 +462,58 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
||||
})
|
||||
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: tt.options,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||
}
|
||||
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)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted weather story", output)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
qualifier := "mid"
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
qualifier = "low"
|
||||
case remainder >= 7:
|
||||
qualifier = "upper"
|
||||
if value < 0 {
|
||||
decade, remainder := temperatureBandParts(-value)
|
||||
qualifier := temperatureBandQualifier(remainder)
|
||||
if decade == 0 {
|
||||
return fmt.Sprintf("%s single digits below zero", qualifier)
|
||||
}
|
||||
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(), "_")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
85
internal/briefing/fact_requirements.go
Normal file
85
internal/briefing/fact_requirements.go
Normal 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,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
26
internal/briefing/prepared_identity_test.go
Normal file
26
internal/briefing/prepared_identity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
141
internal/cli/generate_test.go
Normal file
141
internal/cli/generate_test.go
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
75
internal/cli/report_date.go
Normal file
75
internal/cli/report_date.go
Normal 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)
|
||||
}
|
||||
101
internal/cli/report_date_test.go
Normal file
101
internal/cli/report_date_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
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.WorkingDir, req.OutputPath = workingDir, outputPath
|
||||
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err)
|
||||
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
|
||||
Committed bool
|
||||
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
|
||||
Err error
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
link := filepath.Join(workingDirectory, "link")
|
||||
if err := os.Symlink(empty, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
|
||||
t.Run("symbolic links", func(t *testing.T) {
|
||||
link := filepath.Join(workingDirectory, "link")
|
||||
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)
|
||||
}
|
||||
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
|
||||
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
|
||||
dangling := filepath.Join(workingDirectory, "dangling")
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -1180,9 +1312,10 @@ func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
|
||||
|
||||
func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
||||
}
|
||||
rendered.WriteString(distributorBatchTemplateValue(variable, values))
|
||||
i += end + 2
|
||||
case '}':
|
||||
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
||||
default:
|
||||
rendered.WriteByte(template[i])
|
||||
i++
|
||||
func newDistributorTemplateResolver(values DistributorTemplateValues, allowed map[string]struct{}) distributorTemplateResolver {
|
||||
return func(variable string) (string, bool) {
|
||||
if _, ok := allowed[variable]; !ok {
|
||||
return "", false
|
||||
}
|
||||
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 {
|
||||
|
||||
75
internal/config/overrides_external_test.go
Normal file
75
internal/config/overrides_external_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user