Compare commits
89 Commits
eed47b4f68
...
v0.12.0
| 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 | |||
| 2b1fb26e7d | |||
| 3c7383e2ce |
@@ -3,14 +3,29 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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)
|
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
|
||||||
os.Exit(1)
|
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
|
notification failure occurs after publication, so the newly written output
|
||||||
remains available.
|
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
|
Action commands (`generate`, `run`, and `compare`) write a JSON summary to
|
||||||
stdout unless `--quiet` is set. `run` also writes compact per-report and batch
|
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
|
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
|
a failed batch notification can leave `failed` at `0` while the top-level
|
||||||
notification and action status are `failed`.
|
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:
|
Without `--quiet`, batch status lines use this form:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
report=today status=succeeded output="/srv/weather/reports/today.md"
|
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
|
### 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
|
and unclassified application failures use `application`; cancellation uses
|
||||||
`canceled`; deadlines use `deadline_exceeded`; prompt execution uses its
|
`canceled`; deadlines use `deadline_exceeded`; prompt execution uses its
|
||||||
published Promptkit category; destination failures use `destination_<kind>`;
|
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
|
provider diagnostics, filesystem causes, or recovery paths. See the
|
||||||
[comparison bundle contract](integrations/comparison-bundle.md) for durable
|
[comparison bundle contract](integrations/comparison-bundle.md) for durable
|
||||||
artifact fields and failure invariants.
|
artifact fields and failure invariants.
|
||||||
|
|
||||||
If the bundle is published but cleanup of its replaced prior bundle fails, the
|
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`.
|
summary still includes the published artifact paths and has status `failed`.
|
||||||
Its JSON error is `publication_cleanup` with the message `comparison published
|
Its JSON error is `publication_cleanup`; the returned command error identifies
|
||||||
but cleanup did not complete`; the returned command error identifies the
|
a recovery path only when cleanup left a sibling behind. Only a reported
|
||||||
retained backup path for operator recovery.
|
complete prior bundle is a rollback artifact.
|
||||||
|
|
||||||
## Flag Reference
|
## Flag Reference
|
||||||
|
|
||||||
@@ -163,11 +176,11 @@ retained backup path for operator recovery.
|
|||||||
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
|
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
|
||||||
| `--tz NAME` | `generate`, `run`, `compare` | Override `weather_api.timezone` 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. |
|
| `--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. |
|
| `--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. |
|
| `--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. |
|
| `--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. |
|
| `--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
|
Distributor notification is configured through `notify.distributor`; there are
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ All omitted fields use their built-in defaults.
|
|||||||
|
|
||||||
| Field | Default | Rules |
|
| 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. |
|
| `timeout` | `10s` | Must be greater than zero. |
|
||||||
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
|
| `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. |
|
| `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`. |
|
| `format` | `json` | Required and must be `json`. |
|
||||||
|
|
||||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
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`
|
### `location`
|
||||||
|
|
||||||
@@ -72,8 +76,10 @@ The prompt-facing location timezone is derived from the effective
|
|||||||
### `secrets`
|
### `secrets`
|
||||||
|
|
||||||
`secrets.directory` defaults to empty, which disables secret loading. When it
|
`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
|
is set, every regular file directly in that directory is staged after the file
|
||||||
and command-line overrides. A file basename must match
|
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
|
`[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.
|
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 |
|
| Field | Default | Rules when notification is enabled |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `enabled` | `false` | Activates Distributor notification validation. |
|
| `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. |
|
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
|
||||||
| `timeout` | `30s` | Must be greater than zero. |
|
| `timeout` | `30s` | Must be greater than zero. |
|
||||||
| `failure_policy` | `error` | Must be `error`. |
|
| `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`.
|
The upload token is read from the environment variable named by `token_env`.
|
||||||
Use `secrets.directory` when a file-backed secret is appropriate.
|
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`,
|
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
|
||||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
`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
|
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||||
`none`. `missing_source.sources` optionally overrides that policy by source.
|
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||||
Hourly forecast data is required for generated reports. Supported optional
|
Hourly forecast data is required for generated reports and cannot have a
|
||||||
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
source-specific policy. Supported optional source keys are `observations`,
|
||||||
`weather_story`, and `spc_convective_outlooks`.
|
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||||
|
`spc_convective_outlooks`; any other key is rejected.
|
||||||
|
|
||||||
### `promptkit`
|
### `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
|
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
|
||||||
individual `generate`, `run`, or `compare` command when explicitly needed.
|
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 |
|
| 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`
|
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
||||||
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
(`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`
|
||||||
|
|
||||||
`reports` optionally overrides a report's ordered deterministic modules and
|
`reports` optionally overrides a report's ordered deterministic modules and
|
||||||
Distributor path templates. Omit a report entry to retain its defaults.
|
Distributor path templates. Omit a report entry to retain its defaults.
|
||||||
|
|
||||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
|
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
|
||||||
and underscores are equivalent.
|
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
|
||||||
|
lookup.
|
||||||
|
|
||||||
Each report entry can contain:
|
Each report entry can contain:
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ contiguous from one, profile IDs are distinct and nonblank, and
|
|||||||
`succeeded + failed == total`.
|
`succeeded + failed == total`.
|
||||||
|
|
||||||
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
|
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
|
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
|
||||||
`category` and `message`. Its validation status is absent, `failed`, or
|
`category` and `message`. Its validation status is absent, `failed`, or
|
||||||
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
|
`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
|
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
|
||||||
the current version, schema, file set, file types, relative-path rules, and
|
the current version, schema, file set, file types, relative-path rules, and
|
||||||
data-package digest. It rejects unknown manifest fields, multiple JSON values,
|
data-package digest. JSON field names are case-sensitive canonical names and a
|
||||||
extra entries, symlinks, and future or otherwise unsupported versions. Treat a
|
field may appear only once in each manifest object. It rejects unknown,
|
||||||
bundle that fails recognition as an ordinary directory, not as a compatible
|
case-variant, or duplicate fields; multiple JSON values; extra entries;
|
||||||
bundle.
|
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 manifest contains safe operational provenance, but `data-package.yml` and
|
||||||
the generated Markdown can contain sensitive weather or location context. Do
|
the generated Markdown can contain sensitive weather or location context. Do
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ and [operations guide](../../operations.md).
|
|||||||
|
|
||||||
## Upload Admission
|
## Upload Admission
|
||||||
|
|
||||||
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
|
Weatherreporter uses an absolute HTTP(S) endpoint with a host as a base URL.
|
||||||
posts a gzip-compressed source bundle to:
|
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
|
```text
|
||||||
POST /v1/pipelines/<pipeline_id>/upload
|
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;
|
`status`. Acceptance means Distributor staged and validated the source bundle;
|
||||||
it does not mean downstream destinations have published it.
|
it does not mean downstream destinations have published it.
|
||||||
|
|
||||||
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
|
The adapter requires nonblank pipeline ID, bundle ID, and idempotency key, plus
|
||||||
source-file mapping before calling Distributor. It reads the bearer token from
|
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
|
the configured environment variable and redacts that value from errors. Request
|
||||||
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
|
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
|
## Idempotency
|
||||||
|
|
||||||
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
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
|
## Client And Upload
|
||||||
|
|
||||||
The adapter constructs the client with the configured endpoint, bearer token,
|
The adapter constructs the client with the prevalidated HTTP(S) endpoint,
|
||||||
and an HTTP client whose timeout is the configured Distributor timeout. It
|
bearer token, and an HTTP client whose timeout is the configured Distributor
|
||||||
passes no custom retry options, so the pinned client's defaults apply: three
|
timeout. The endpoint may include a path prefix but never userinfo, a query, or
|
||||||
attempts, 100 ms base delay, and one-second maximum delay.
|
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:
|
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`
|
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
|
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
|
terminal status remains attached to the otherwise accepted upload as diagnostic
|
||||||
status information. Polling cadence, final failure handling, and redaction are
|
status information. Normal diagnostics use local status classifications; they
|
||||||
internal behavior documented in the
|
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
|
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||||
[application orchestration](../../internal/app-orchestration.md).
|
[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
|
## 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;
|
1. explicit in-memory profiles used by an embedding consumer or test;
|
||||||
2. the configured `profile_file` or `profile_dir`;
|
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.
|
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
|
## Comparison Execution
|
||||||
|
|
||||||
For `compare`, Weatherreporter validates one exact prompt and every explicitly
|
For `compare`, Weatherreporter validates the report's generated-text catalog
|
||||||
selected profile before weather collection. It prepares one deterministic YAML
|
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
|
data package, retains immutable copies of the report inputs, and executes every
|
||||||
profile against the same exact data-package bytes. Each profile remains an
|
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.
|
stop its peers, while caller cancellation applies to every in-flight execution.
|
||||||
|
|
||||||
Weatherreporter starts selected profile executions concurrently and does not
|
Weatherreporter starts selected profile executions concurrently and does not
|
||||||
add an application-level concurrency limit. Promptkit owns backend capacity and
|
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
|
its compatibility rules are defined by the
|
||||||
[comparison bundle contract](comparison-bundle.md); the user-facing command
|
[comparison bundle contract](comparison-bundle.md); the user-facing command
|
||||||
contract is in the [CLI reference](../cli.md).
|
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).
|
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
|
## Base URL And Requests
|
||||||
|
|
||||||
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
|
`weather_api.base_url` must be an absolute HTTP(S) URL. Weatherreporter joins
|
||||||
endpoint path to the configured base URL path, so a service hosted under a path
|
each endpoint path to the configured base URL path, so a service hosted under a
|
||||||
prefix must keep that prefix available. Requests use `GET` and carry the
|
path prefix must keep that prefix available. Requests use `GET` and carry the
|
||||||
configured timeout on every HTTP attempt.
|
configured timeout on every HTTP attempt.
|
||||||
|
|
||||||
Every request sends `format` and, except where noted below, `units`. The
|
Every request sends `format` and, except where noted below, `units`. The
|
||||||
configured format must be `json`.
|
configured format must be `json`.
|
||||||
|
|
||||||
Before retrieving sources, Weatherreporter warms up
|
Before retrieving sources, Weatherreporter requests `/conditions/current` with
|
||||||
`/conditions/current` with the same `format`, `units`, and `precision` query
|
the same `format`, `units`, and `precision` query parameters used for current
|
||||||
parameters used for current conditions. The warmup only requires a readable
|
conditions. After a readable 2xx response, it retains that response for the
|
||||||
2xx response; its body is not decoded. Failure after its internal retry budget
|
normal current-conditions source step rather than making a second identical
|
||||||
stops the fetch before source requests begin.
|
request. Failure after the readiness request's internal retry budget stops the
|
||||||
|
fetch before source requests begin.
|
||||||
|
|
||||||
## Endpoints And Query Parameters
|
## Endpoints And Query Parameters
|
||||||
|
|
||||||
The adapter makes one source request for each endpoint after a successful
|
The adapter makes one source request for each endpoint, subject to retry on
|
||||||
warmup, subject to retry on transient failures.
|
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 |
|
| 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
|
`data: null` is also missing. The active-alert exception is listed above: its
|
||||||
explicit `null` payload represents an empty alert result.
|
explicit `null` payload represents an empty alert result.
|
||||||
|
|
||||||
Hourly forecast data must be present and contain at least one `period`; a
|
Hourly forecast data must be present and contain at least one `period`. Every
|
||||||
missing, malformed, or empty hourly product fails collection. The remaining
|
hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
|
||||||
sources follow the configured missing-source policy. Under `error`, collection
|
after `startTime`; a missing, malformed, empty, or invalidly bounded hourly
|
||||||
fails; under `warn`, the source is omitted and an inspectable warning is
|
product fails collection. The remaining sources follow the configured
|
||||||
recorded; under `none`, the source is omitted without a warning. A per-source
|
missing-source policy. Under `error`, collection fails; under `warn`, the
|
||||||
policy overrides the default. See [Configuration](../config.md) for policy
|
source is omitted and an inspectable warning is recorded; under `none`, the
|
||||||
settings and [Weather data internals](../internal/weather-data.md) for recorded
|
source is omitted without a warning. A per-source policy overrides the default.
|
||||||
source metadata.
|
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 top-level JSON envelopes and HTTP failures are direct request errors.
|
||||||
Malformed `data` for an optional source follows its missing-source policy.
|
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
|
payload decoding failures. A canceled context also stops an in-progress retry
|
||||||
delay.
|
delay.
|
||||||
|
|
||||||
The adapter reads at most 10 MiB from one response body. A non-2xx response,
|
The adapter accepts response bodies up to 10 MiB and rejects larger bodies
|
||||||
request construction failure, read failure, or decode failure includes endpoint
|
before decoding. A non-2xx response reports its relative endpoint and status,
|
||||||
context in its error.
|
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
|
Retry counts and delays are adapter behavior rather than Weather API request
|
||||||
parameters. Do not depend on a particular attempt count when implementing the
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## Comparisons
|
||||||
|
|
||||||
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
||||||
and preflights the exact bundle destination before initializing optional prompt
|
and preflights the exact bundle destination before initializing optional prompt
|
||||||
debugging, prompt inspection, or collection. It then inspects the one prompt
|
debugging, prompt inspection, or collection. It then validates the report's
|
||||||
and every selected profile, collects once, and delegates shared report
|
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.
|
construction to the prepared-report flow. It does not accept a notifier.
|
||||||
|
|
||||||
Once the destination is resolved, the partial result retains its absolute
|
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.
|
and hash. Artifact paths are added only after publication commits.
|
||||||
|
|
||||||
The comparison execution core starts each inspected profile independently,
|
The comparison execution core starts each inspected profile independently,
|
||||||
keeps results in selection order, and waits for all started work. Independent
|
keeps results in selection order, and waits for all started work. Every profile
|
||||||
profile failures are recorded and do not stop peers. Context cancellation marks
|
reconciles its callback and completion provenance before its JSON can be
|
||||||
unfinished work and prevents publication. Details of prepared values, execution
|
rendered. Independent profile failures are recorded and do not stop peers; a
|
||||||
and debugging, and publication are documented in [prepared report
|
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](prepared-report.md), [comparison execution
|
||||||
internals](comparison-execution.md), and [comparison publication
|
internals](comparison-execution.md), and [comparison publication
|
||||||
internals](comparison-publication.md).
|
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
|
absolute manifest, data-package, and successful report paths even if removal of
|
||||||
the previous sibling backup then fails. That cleanup failure is still returned
|
the previous sibling backup then fails. That cleanup failure is still returned
|
||||||
as an operational error rather than treating the new bundle as unpublished;
|
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
|
## 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,
|
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
||||||
required collected and derived facts, supported report IDs, missing-data
|
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
|
`BuildModule` first verifies the requested module, report compatibility, and
|
||||||
option shape. It then applies the declared missing-data behavior:
|
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
|
summaries, precipitation timing, outdoor windows, and the report-specific
|
||||||
Daily, Today, and Tomorrow planning values.
|
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
|
The module registry preserves rich values for templates and snapshots while
|
||||||
curating prompt exports where needed. In particular, source warnings are a
|
curating prompt exports where needed. In particular, source warnings are a
|
||||||
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
||||||
missing sources, and prompt-safe SPC values omit geometry and other
|
missing sources, and prompt-safe SPC values omit geometry and other
|
||||||
template-only or source details. The complete module composition is in
|
template-only or source details. The complete module composition is in
|
||||||
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
[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
|
Derived daypart-summary maps use the forecast package's canonical daypart
|
||||||
modules are report-specific: `daily_planning` supports Daily,
|
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.
|
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||||
|
|
||||||
## Missing data and boundaries
|
## Missing data and boundaries
|
||||||
|
|
||||||
Optional current conditions, narrative products, discussions, and weather
|
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
|
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
||||||
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||||
the package's severity criterion and matching discussion text exists.
|
the package's severity criterion and matching discussion text exists.
|
||||||
|
|
||||||
Effective units, timezone, and location context arrive in `ModuleContext` from
|
`ModuleContext` carries the effective units, timezone, location context, and
|
||||||
configuration and resolved report metadata. Field defaults are owned by
|
prepared identity. Report preparation creates that one `PreparedIdentity` for
|
||||||
[configuration](../config.md), and prompt-package layout is owned by
|
the shared report identity, timing, configuration context, and source warnings
|
||||||
[prompt input](prompt-input.md).
|
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
|
## 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.
|
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
|
```sh
|
||||||
go test ./internal/cli
|
go test ./internal/cli
|
||||||
|
|||||||
@@ -1,43 +1,36 @@
|
|||||||
# Collection Internals
|
# 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
|
normalized Weather API bundle. The external HTTP contract belongs in the
|
||||||
[Weather API integration guide](../integrations/weatherapi.md); normalized data
|
[Weather API integration guide](../integrations/weatherapi.md); normalized
|
||||||
semantics belong in [weather-data internals](weather-data.md).
|
source values belong in [weather-data internals](weather-data.md).
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
`Run` accepts a `context.Context` and a `Request` containing effective
|
`Run` receives a context and effective configuration in `Request`. It creates
|
||||||
`config.Config`. It constructs the Weather API adapter from that configuration,
|
the Weather API adapter, calls `FetchBundle`, and returns the adapter's
|
||||||
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
|
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
|
The package neither chooses reports nor derives facts, builds modules, invokes
|
||||||
errors and fetch failures as bundle-collection errors. It does not retry,
|
Promptkit, writes files, or sends notifications. Request scheduling, endpoint
|
||||||
persist, select reports, derive facts, build modules, invoke Promptkit, or
|
retrieval, response limits, and source-level warnings belong to the Weather
|
||||||
notify Distributor.
|
API adapter and its integration contract.
|
||||||
|
|
||||||
## Application Composition
|
## Application Use
|
||||||
|
|
||||||
`internal/app` owns the narrow `Collector` interface used by workflow tests;
|
`internal/app` owns the `Collector` interface used by report workflows and
|
||||||
the production implementation delegates to `collect.Run`. Generation, batch
|
tests. Its default implementation delegates to `collect.Run`; callers may
|
||||||
execution, and explicit bundle fetching all use this boundary. Application
|
substitute a collector at that boundary. Application orchestration owns
|
||||||
orchestration rejects a nil collector result or a nil bundle before report work
|
collection timing, reuse across a workflow, and the handling of nil collection
|
||||||
can continue.
|
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
|
## Verification
|
||||||
normalized collection to planning and to every report it generates. Collection
|
|
||||||
failure prevents later workflow work for that request.
|
|
||||||
|
|
||||||
## 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
|
```sh
|
||||||
must not make report, period, batch, prompt, module, filesystem, or notification
|
go test ./internal/collect
|
||||||
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`.
|
|
||||||
|
|||||||
@@ -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
|
Every profile uses the exact inspected prompt identity and a private copy of
|
||||||
the same prepared data package. Provider, generated-text validation, rendering,
|
the same prepared data package. Provider, generated-text validation, rendering,
|
||||||
or debug-write failure becomes that profile's safe failed outcome and does not
|
or debug-write failure becomes that profile's safe failed outcome and does not
|
||||||
cancel its peers. The application deliberately imposes no additional semaphore:
|
cancel its peers. The shared executor must support those concurrent `Execute`
|
||||||
Promptkit owns backend capacity. Cancellation or a deadline marks unfinished
|
calls. The application deliberately imposes no additional semaphore: Promptkit
|
||||||
outcomes as skipped or failed, joins work, and prevents bundle publication.
|
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
|
When debugging is enabled, each execution receives a deterministic reference
|
||||||
derived from the comparison identity, ordered profile position, and safe
|
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
|
owns secure-root validation and file permissions. It safely creates shared
|
||||||
missing ancestors during concurrent writes, then rejects symlink and non-
|
missing ancestors during concurrent writes, then rejects symlink and non-
|
||||||
directory components. Operational retention and sensitivity are documented in
|
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
|
The output result and its safe errors are converted into the durable contract
|
||||||
only by comparison publication. See [comparison publication
|
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
|
schema, and compatibility rules are owned by the [comparison bundle
|
||||||
contract](../integrations/comparison-bundle.md).
|
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
|
Destination planning is read-only. It requires an exact absolute target that
|
||||||
is neither the filesystem root nor the working directory, rejects unsafe
|
is neither the filesystem root nor the working directory, rejects unsafe
|
||||||
symlinks and non-directories, accepts a missing or empty directory, and permits
|
symlinks and non-directories, accepts a missing or empty directory, and permits
|
||||||
replacement only for a recognized current bundle. Publication rechecks that
|
replacement only for a recognized current bundle. Publication rechecks the
|
||||||
authorization immediately before it writes a private sibling staging directory.
|
destination namespace and type immediately before it writes a private sibling
|
||||||
For replacement, it moves the prior bundle to a private sibling backup,
|
staging directory. For replacement, it moves the prior bundle to a private
|
||||||
reauthorizes that moved entry, and restores it if installing the new bundle
|
sibling backup, fully reauthorizes that moved entry, checks for cancellation,
|
||||||
fails.
|
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
|
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
|
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.
|
to remove the retained sibling backup does not roll back the new bundle.
|
||||||
Publication returns an inspectable cleanup error with the absolute backup path
|
After a cleanup failure, publication inspects the sibling without masking the
|
||||||
and underlying filesystem cause so an operator can recover or remove that
|
original filesystem cause. Its inspectable cleanup result distinguishes a
|
||||||
backup manually.
|
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
|
The application preflights before prompt inspection and collection. Publication
|
||||||
preflights again before publication. A cancellation or any failure before the
|
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
|
commit leaves the prior destination untouched. Completed bundles include
|
||||||
partial profile results; comparison publication never coordinates Distributor
|
partial profile results; comparison publication never coordinates Distributor
|
||||||
notification. Operator-facing lifecycle and cleanup are in the
|
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,
|
`New` validates its configuration before creating the adapter. For each upload,
|
||||||
the adapter reads the token from the configured environment variable and builds
|
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
|
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
|
The upstream client is an implementation dependency, not a source of
|
||||||
application configuration: retry ownership, pipeline selection, path
|
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
|
An accepted upload is followed by one status request. When a timeout is
|
||||||
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
||||||
until the context ends. The translated `UploadResult` contains the run ID,
|
until the context ends. The translated `UploadResult` contains the run ID,
|
||||||
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
|
status, and `RunStatus`, including pipeline ID and lifecycle timestamps.
|
||||||
and remote error details.
|
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
|
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
||||||
the caller can report an accepted-but-unconfirmed delivery. A terminal failed
|
the caller can report an accepted-but-unconfirmed delivery, using a bounded
|
||||||
run returns that result and an error. Upload failures return no result. Upstream
|
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
|
idempotency conflicts become the local `IdempotencyConflictError`, which adds
|
||||||
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
||||||
the token.
|
the token.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Focused tests cover configuration validation, request mapping, timeouts and
|
Focused tests cover configuration validation, request mapping, response size
|
||||||
polling, status translation, conflict handling, and token redaction:
|
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
|
```sh
|
||||||
go test ./internal/adapters/distributor
|
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
|
into their own slices so downstream consumers can inspect data completeness
|
||||||
without treating it as an ordinary weather fact.
|
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
|
A nil bundle produces an empty collected value. Collection itself, missing
|
||||||
source policy, and source hashes are outside this package.
|
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
|
data; it also derives precipitation timing. Convective outlooks are retained
|
||||||
only when their valid interval overlaps the report period, with discussions
|
only when their valid interval overlaps the report period, with discussions
|
||||||
kept for represented outlook days. Both collections are sorted deterministically.
|
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:
|
Report identity controls the summary shape:
|
||||||
|
|
||||||
@@ -37,6 +42,8 @@ Report identity controls the summary shape:
|
|||||||
`DaypartSummaries` is collected from the resulting daily summaries.
|
`DaypartSummaries` is collected from the resulting daily summaries.
|
||||||
The detailed grouping, daypart-window, and alert rules are owned by
|
The detailed grouping, daypart-window, and alert rules are owned by
|
||||||
[forecast derivation](forecast-derivation.md).
|
[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
|
## Missing data and failures
|
||||||
|
|
||||||
|
|||||||
@@ -2,60 +2,46 @@
|
|||||||
|
|
||||||
`internal/forecast` deterministically selects and summarizes normalized
|
`internal/forecast` deterministically selects and summarizes normalized
|
||||||
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||||
registry dependency. Its summaries are consumed by
|
registry dependency. The report-scoped caller is [fact
|
||||||
[fact contracts](facts.md) and later module builders.
|
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
|
`BuildDailySummary` builds one summary for one local civil day. The facts
|
||||||
when both intervals share time. `BuildDailySummary` creates one local civil
|
layer calls it for Daily, Today, and Tomorrow reports; it does not provide a
|
||||||
day; `BuildPeriodDailySummaries` intersects every local civil day with the
|
multi-day or arbitrary-period summary constructor. `timeutil.Period` supplies
|
||||||
requested period, preserving partial first and last days.
|
the shared half-open overlap rule used while selecting source values.
|
||||||
|
|
||||||
`ResolveDayparts` converts each configured name, start clock, and end clock
|
`ResolveDayparts` turns configured local clock ranges into windows. A range
|
||||||
into a local window. An end clock at or before its start clock wraps into the
|
whose end is not after its start continues into the next civil day. The
|
||||||
next civil day. The daypart and timezone defaults are defined in the
|
available daypart and timezone settings are defined in the
|
||||||
[configuration reference](../config.md), not here.
|
[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
|
## Boundaries And Failures
|
||||||
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.
|
|
||||||
|
|
||||||
Indicators are deterministic checks over normalized values and condition text:
|
Daily-summary construction requires a bundle with hourly forecast data,
|
||||||
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
valid precipitation probabilities, and valid daypart definitions. Optional
|
||||||
wind text are detected from the forecast description. `BuildPrecipTiming`
|
normalized products remain absent when unavailable. Invalid alerts are ignored
|
||||||
sorts periods, records the maximum and first precipitation, groups contiguous
|
while valid overlaps are selected for the relevant day or daypart window.
|
||||||
periods at or above its package-owned probability threshold, and records
|
|
||||||
thunder mentions.
|
|
||||||
|
|
||||||
Alert overlap parsing supports the normalized alert payload's available timing
|
Thresholds, text classification, unit normalization, and alert selection are
|
||||||
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
package implementation rules. Report identity, period selection, and the
|
||||||
are clipped to the requested period and ordered by alert start time.
|
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.
|
Focused `internal/forecast` tests exercise daily and overnight dayparts,
|
||||||
Direct daily or period-summary calls fail when their required bundle, valid
|
summary derivation, invalid precipitation data, precipitation timing, and
|
||||||
period, hourly data, or daypart definitions are invalid. A nil location uses
|
alert overlap handling. `internal/facts` tests cover the report-scoped caller:
|
||||||
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:
|
|
||||||
|
|
||||||
```sh
|
```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
|
## Catalog and validation
|
||||||
|
|
||||||
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
||||||
generated text. `LookupDefinition` rejects unknown schema or template IDs and
|
generated text. `LookupDefinition` requires the exact report, schema, and
|
||||||
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
|
template triple and rejects unknown IDs, unsupported pairs, and a pair that
|
||||||
value and canonical normalized JSON, loads its canonical schema through
|
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/promptassets`, builds a render context, and renders through
|
||||||
`internal/reporttemplate`.
|
`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 one or more nonblank discussion paragraphs. Hourly requires trimmed summary
|
||||||
and a single trimmed discussion string. Every form also requires the
|
and a single trimmed discussion string. Every form also requires the
|
||||||
`precipitation_timing` field; an empty string means there is no supported timing
|
`precipitation_timing` field; an empty string means there is no supported timing
|
||||||
prose to render. Typed decoding rejects missing required fields and unknown JSON
|
prose to render. Typed decoding requires the exact lowercase JSON field names,
|
||||||
fields; no general-purpose JSON Schema engine is used at runtime.
|
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
|
## Render contexts
|
||||||
|
|
||||||
The catalog's report-specific builders receive briefing metadata, a rich module
|
The catalog's report-specific builders receive the prepared report identity, a
|
||||||
snapshot, collected facts, derived facts, and the matching validated generated
|
rich module snapshot, derived facts needed to order dayparts, and the matching
|
||||||
text. They decode the module stanzas needed by the template and build typed
|
validated generated text. They require the identity's report ID to match the
|
||||||
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
|
selected builder. When the optional metadata stanza is present, every shared
|
||||||
metadata and periods, preserves rich module values, and uses ordered slices for
|
identity field must agree with that prepared authority before context
|
||||||
template iteration rather than maps.
|
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
|
Optional source stanzas become nil or fallback context fields. Today also
|
||||||
stanzas, type-decoding failures, invalid metadata, or a generated-text type
|
computes whether its ordered dayparts contain a displayable condition so the
|
||||||
that does not match the chosen handler fail before template execution. Prompt
|
template can render either rows or its explicit no-details fallback. Missing
|
||||||
packages, raw Promptkit output handling, and template asset lookup remain
|
required stanzas, type-decoding failures, conflicting identity values, invalid
|
||||||
outside this package.
|
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
|
## Verification and invariants
|
||||||
|
|
||||||
@@ -48,5 +70,7 @@ go test ./internal/generatedtext
|
|||||||
```
|
```
|
||||||
|
|
||||||
Generated text supplies prose slots only; deterministic weather facts remain in
|
Generated text supplies prose slots only; deterministic weather facts remain in
|
||||||
module and fact values. Every report definition must resolve to exactly one
|
module and fact values. The renderer applies its plain-text policy to every
|
||||||
supported catalog pair.
|
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
|
# 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
|
module builders, in-memory snapshots, templates, and prompt packages. It
|
||||||
does not define a report, execute a builder, or choose prompt-export policy;
|
does not define a report, execute a builder, or choose prompt-export policy;
|
||||||
those responsibilities belong to [report registry](report-registry.md) and
|
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
|
otherwise the rich value. This permits custom prompt exports without shrinking
|
||||||
the template value.
|
the template value.
|
||||||
|
|
||||||
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
|
`NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
|
||||||
validates it. Its JSON representation contains IDs, stanza names, and rich values only;
|
representation carries a package-owned schema marker, IDs, stanza names, and rich values only;
|
||||||
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
|
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
|
||||||
stanza into a caller-supplied type, reporting a missing stanza separately from
|
stanza into a caller-supplied type, reporting a missing stanza separately from
|
||||||
a decoding error.
|
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
|
The only non-empty default option is the AFD section selection. It accepts a
|
||||||
`sections` list; omitted or empty selects all available sections. Report
|
`sections` list; omitted or empty selects all available sections. Report
|
||||||
definitions may narrow it as shown above. Option shape and report compatibility
|
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
|
## Rich and prompt-facing values
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
# Prepared Report Internals
|
# Prepared Report Internals
|
||||||
|
|
||||||
`internal/app` builds a `preparedReport` after collection and before profile
|
`internal/app` validates the report's generated-text catalog binding during
|
||||||
execution. This is the immutable boundary shared by ordinary report generation
|
prompt inspection, before collection, and carries the resulting handler into
|
||||||
and profile comparison; it is not a durable artifact.
|
`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
|
Preparation first establishes one `PreparedIdentity` for the report run, report
|
||||||
metadata, the curated prompt-input package, serialized YAML, and the
|
and prompt IDs, variant, generation time, units, timezone, valid period,
|
||||||
generated-text definition. It deep-copies mutable facts, snapshots, metadata,
|
location, and source warnings. It passes that identity to the configured module
|
||||||
and data-package bytes before returning them. Consumers receive independent
|
snapshot, curated prompt-input package, serialized YAML, generated-text render
|
||||||
copies so one execution cannot change another's input or rendering context.
|
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
|
Single-report generation executes one prepared profile and publishes its
|
||||||
Markdown. Comparison prepares once, gives every selected profile the same YAML
|
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
|
shape is owned by [prompt-input internals](prompt-input.md); profile execution
|
||||||
semantics are owned by [Promptkit integration](../integrations/promptkit.md).
|
semantics are owned by [Promptkit integration](../integrations/promptkit.md).
|
||||||
|
|
||||||
Preparation failure has no publication side effects. Tests for this boundary
|
Catalog incompatibility stops prompt inspection before weather collection or
|
||||||
cover mutation isolation, byte equality, and reuse by both execution paths.
|
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
|
# 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
|
## 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 |
|
Focused tests cover package construction, report-local dates, validation,
|
||||||
| --- | --- |
|
curated snapshot exports, deterministic YAML grouping, and safe source-warning
|
||||||
| `applicable_risk_products` | alert digest, SPC convective outlooks |
|
projection:
|
||||||
| `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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/promptinput
|
go test ./internal/promptinput
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
# Report Registry Internals
|
# 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 |
|
Definitions carry the internal collaborators needed downstream: prompt and
|
||||||
| --- | --- | --- | --- | --- | --- |
|
template identity, module configuration, output naming, Distributor path
|
||||||
| `daily` | `2.0.0` | `weather-balanced` | Explicit local civil day | Dynamic Daily inclusion is app-owned | `daily-YYYY-MM-DD.md` |
|
templates, and fixed batch eligibility. The external prompt contract is owned
|
||||||
| `today` | `2.0.0` | `weather-balanced` | Selected or current local civil day | Morning | `today.md` |
|
by the [Promptkit integration guide](../integrations/promptkit.md), template
|
||||||
| `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
|
surface by the [report template guide](../templates.md), and published
|
||||||
| `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
|
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.
|
Focused tests protect retained report definitions, period resolution, Daily
|
||||||
|
run-ID disambiguation, and rejection of retired command or configuration names:
|
||||||
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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/report
|
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
|
Top-level templates decide which shared partials they invoke. The current
|
||||||
partials cover daypart forecast variants, alert digest, and precipitation
|
partials cover daypart forecast variants, alert digest, and precipitation
|
||||||
timing. Template code receives curated typed contexts rather than raw data
|
timing. Template code receives curated typed contexts rather than raw data
|
||||||
packages, and it must not reimplement weather selection or generated-text
|
packages or complete fact bundles, and it must not reimplement weather
|
||||||
validation.
|
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
|
## 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
|
Promptkit, or upload reports. It produces Markdown bytes for application
|
||||||
orchestration to persist.
|
orchestration to persist.
|
||||||
|
|
||||||
Focused tests cover template lookup, rendering, partial
|
Focused tests cover template lookup, rendering, partial behavior, daypart
|
||||||
behavior, missing keys, and malformed context:
|
fallbacks, missing keys, and malformed context:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./internal/reporttemplate
|
go test ./internal/reporttemplate
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ provider endpoint or retry policy from the normalized types. See
|
|||||||
[collection](collect.md) for assembly and
|
[collection](collect.md) for assembly and
|
||||||
[report templates](../templates.md) for the values exposed to authors.
|
[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
|
## Source provenance
|
||||||
|
|
||||||
Every checked source is represented by a `Source` entry. The record identifies
|
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
|
response or parsing failure as unavailable. The policy itself belongs to the
|
||||||
[configuration reference](../config.md).
|
[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
|
## Warning semantics
|
||||||
|
|
||||||
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
`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
|
debug, and notification information; see the [CLI reference](cli.md) for its
|
||||||
exact fields.
|
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
|
## Batch Outputs And Distributor Notification
|
||||||
|
|
||||||
Run a scheduled batch with an explicit output directory when appropriate:
|
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
|
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 output is created or replaced. After successful validation, each selected
|
||||||
report processes independently and successful outputs remain available if
|
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,
|
When `notify.distributor.enabled` and batch notification are enabled,
|
||||||
Weatherreporter sends one Distributor upload only after every selected output
|
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
|
leaves all successfully published report files in place. Distributor source
|
||||||
files are those operator-owned Markdown outputs; rendered bundle paths and
|
files are those operator-owned Markdown outputs; rendered bundle paths and
|
||||||
delivery status appear in the result, not in a local notification receipt.
|
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
|
Report counters count report items only. A batch notification failure therefore
|
||||||
returns a failed batch status even when all report counters show success; the
|
returns a failed batch status even when all report counters show success; the
|
||||||
top-level notification result contains the delivery diagnostic.
|
top-level notification result contains the delivery diagnostic.
|
||||||
|
|
||||||
For a single report, Distributor notification follows the atomic output write.
|
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.
|
idempotency-key, and per-report path templates.
|
||||||
|
|
||||||
## Comparison Bundles
|
## 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
|
is usable. A nonempty directory can be replaced only when `--replace` is given
|
||||||
and it is recognized as a current Weatherreporter comparison bundle; ordinary
|
and it is recognized as a current Weatherreporter comparison bundle; ordinary
|
||||||
directories, symlinks, and unsafe destinations are rejected. Cancellation and
|
directories, symlinks, and unsafe destinations are rejected. Cancellation and
|
||||||
all failures before publication preserve an existing bundle. Profile failures
|
all failures before publication preserve an existing bundle, including a
|
||||||
are different: the command publishes a complete partial bundle, with failed
|
cancellation observed while a replacement is being prepared. If guarded
|
||||||
profiles represented in the manifest and no Markdown file for those profiles.
|
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
|
## 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
|
content. Debug capture is never created for an ordinary command without
|
||||||
`--llm-debug-dir`.
|
`--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
|
If capture creation or writing fails, the affected run fails rather than
|
||||||
silently continuing without the requested diagnostics.
|
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
|
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
|
bundle remains valid and its artifact paths appear in the failed command
|
||||||
summary. The summary records a safe `publication_cleanup` error, while the
|
summary. The summary records a safe `publication_cleanup` error that indicates
|
||||||
returned command error reports the retained backup path. Preserve that backup
|
whether a complete prior bundle remains, only partial remnants remain, or no
|
||||||
until it has been inspected and cleaned up manually; do not remove the new
|
prior bundle remains; it also identifies when the sibling cannot be inspected.
|
||||||
bundle to retry that cleanup.
|
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
|
Enable explicit debug capture only when content-rich Promptkit diagnostics are
|
||||||
necessary.
|
necessary.
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ directly.
|
|||||||
pre-publication failure, including cancellation observed immediately before
|
pre-publication failure, including cancellation observed immediately before
|
||||||
publication, does not replace an existing destination; a notification failure
|
publication, does not replace an existing destination; a notification failure
|
||||||
does not remove a newly published output.
|
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;
|
- Configuration or explicit CLI input selects that operator-owned destination;
|
||||||
it does not create an application-owned state boundary.
|
it does not create an application-owned state boundary.
|
||||||
- Comparison bundles are flat, versioned operator outputs. Their guarded
|
- 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
|
outcomes only; a failed batch notification is represented separately at the
|
||||||
batch level.
|
batch level.
|
||||||
- Comparison never invokes Distributor notification.
|
- 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
|
- Default tests are deterministic, offline, and use Promptkit/provider fakes
|
||||||
rather than live provider calls. See the [testing policy](testing.md).
|
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,
|
weatherreporter selects explicit generated files and submits source bundles,
|
||||||
while distributor owns destination routing and publication behavior.
|
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
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
Status: Proposed and unimplemented.
|
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/` |
|
| 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
|
The matching schemas and Promptkit definitions are embedded by
|
||||||
`internal/promptassets`. The generated-text catalog pairs each schema ID with
|
`internal/promptassets`. The generated-text catalog requires each report's
|
||||||
its template ID; keep the matching prompt definition aligned with that pair.
|
exact schema/template pair; keep the matching prompt definition aligned with
|
||||||
|
that report-specific triple.
|
||||||
|
|
||||||
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
||||||
|
|
||||||
@@ -46,11 +47,18 @@ from rendering.
|
|||||||
calculations, source selection, or prompt-input shaping to a template.
|
calculations, source selection, or prompt-input shaping to a template.
|
||||||
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||||
in generated prose merely to compensate for a template change.
|
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,
|
- When changing the generated-prose contract, update the matching prompt,
|
||||||
schema, validator, render context, and template together. The validation and
|
schema, validator, render context, and template together. The validation and
|
||||||
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
||||||
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
|
- 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:
|
Minimal optional-value pattern:
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ Minimal list pattern:
|
|||||||
|
|
||||||
```gotemplate
|
```gotemplate
|
||||||
{{ range .GeneratedText.ForecastDiscussion }}
|
{{ range .GeneratedText.ForecastDiscussion }}
|
||||||
{{ . }}
|
{{ plainText . }}
|
||||||
{{ end }}
|
{{ 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 |
|
| `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 |
|
| `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 |
|
| `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
|
For example, the alert partial uses the first two functions to decide whether
|
||||||
to render the section:
|
to render the section:
|
||||||
@@ -91,7 +100,7 @@ to render the section:
|
|||||||
|
|
||||||
## Render Context
|
## 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:
|
fields:
|
||||||
|
|
||||||
| Field | Purpose |
|
| Field | Purpose |
|
||||||
@@ -99,12 +108,6 @@ fields:
|
|||||||
| `.Report` | Display labels and canonical report timing metadata. |
|
| `.Report` | Display labels and canonical report timing metadata. |
|
||||||
| `.GeneratedText` | Validated prose supplied by Promptkit. |
|
| `.GeneratedText` | Validated prose supplied by Promptkit. |
|
||||||
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
|
| `.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
|
### Report Metadata
|
||||||
|
|
||||||
@@ -126,13 +129,15 @@ It is not a source for deterministic weather facts.
|
|||||||
|
|
||||||
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `.GeneratedText.Summary` | `string` | `string` | Required. |
|
| `.GeneratedText.Summary` | `string` | `string` | Required; at most 4,000 characters. |
|
||||||
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
|
| `.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; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
|
| `.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 JSON schema rejects unknown properties and defines the required fields, but
|
||||||
the schema body and validation behavior are documented in [Generated Text
|
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
|
### Deterministic Module Values
|
||||||
|
|
||||||
@@ -141,8 +146,9 @@ Module pointers can be nil when their source or policy permits omission.
|
|||||||
|
|
||||||
| Module field | Available in |
|
| 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.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
|
||||||
|
| `.Modules.HasDaypartDetails` | Today |
|
||||||
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
||||||
| `.Modules.TodayPlanning` | Today |
|
| `.Modules.TodayPlanning` | Today |
|
||||||
| `.Modules.TomorrowPlanning` | Tomorrow |
|
| `.Modules.TomorrowPlanning` | Tomorrow |
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -7,9 +7,8 @@ require gopkg.in/yaml.v3 v3.0.1
|
|||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/distributor v0.5.0
|
gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||||
gitea.maximumdirect.net/eric/promptkit 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 (
|
require golang.org/x/text v0.14.0 // indirect
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
|
||||||
golang.org/x/text v0.14.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
package distributor
|
package distributor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +24,7 @@ type Client struct {
|
|||||||
TokenEnv string
|
TokenEnv string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
newUploadClient uploadClientFactory
|
newUploadClient uploadClientFactory
|
||||||
|
pollWait func(context.Context, time.Duration) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadRequest struct {
|
type UploadRequest struct {
|
||||||
@@ -107,6 +110,10 @@ type runStatus struct {
|
|||||||
|
|
||||||
const statusPollInterval = 250 * time.Millisecond
|
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 {
|
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||||
return newClient(cfg, newDistributorUploadClient)
|
return newClient(cfg, newDistributorUploadClient)
|
||||||
}
|
}
|
||||||
@@ -120,6 +127,7 @@ func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory)
|
|||||||
TokenEnv: cfg.TokenEnv,
|
TokenEnv: cfg.TokenEnv,
|
||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
newUploadClient: factory,
|
newUploadClient: factory,
|
||||||
|
pollWait: waitForPoll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +209,12 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
|||||||
Status: result.Status,
|
Status: result.Status,
|
||||||
UploadStatus: 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 != "" {
|
if status.RunID != "" || status.Status != "" {
|
||||||
uploadResult.RunStatus = &RunStatus{
|
uploadResult.RunStatus = &RunStatus{
|
||||||
RunID: status.RunID,
|
RunID: status.RunID,
|
||||||
@@ -218,7 +231,7 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if statusErr != nil {
|
if statusErr != nil {
|
||||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
uploadResult.StatusError = safeDistributorDiagnostic(statusErr, token).Error()
|
||||||
return uploadResult, nil
|
return uploadResult, nil
|
||||||
}
|
}
|
||||||
if status.Status == "failed" {
|
if status.Status == "failed" {
|
||||||
@@ -227,19 +240,15 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
|||||||
return uploadResult, nil
|
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)
|
status, err := client.Status(ctx, runID)
|
||||||
if err != nil || terminalRunStatus(status.Status) || !poll {
|
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
timer := time.NewTimer(statusPollInterval)
|
if err := wait(ctx, statusPollInterval); err != nil {
|
||||||
select {
|
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, err)
|
||||||
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:
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next, err := client.Status(ctx, runID)
|
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 {
|
func terminalRunStatus(status string) bool {
|
||||||
return status == "succeeded" || status == "failed"
|
return status == "succeeded" || status == "failed"
|
||||||
}
|
}
|
||||||
@@ -261,10 +281,52 @@ type distributorUploadClient struct {
|
|||||||
client *distributorupload.Client
|
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) {
|
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 {
|
if timeout > 0 {
|
||||||
httpClient = &http.Client{Timeout: timeout}
|
httpClient.Timeout = timeout
|
||||||
}
|
}
|
||||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
@@ -306,7 +368,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return runStatus{}, err
|
return runStatus{}, err
|
||||||
}
|
}
|
||||||
return runStatus{
|
return sanitizeRunStatus(runStatus{
|
||||||
RunID: status.RunID,
|
RunID: status.RunID,
|
||||||
PipelineID: status.PipelineID,
|
PipelineID: status.PipelineID,
|
||||||
Status: status.Status,
|
Status: status.Status,
|
||||||
@@ -315,7 +377,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
|||||||
FinishedAt: status.FinishedAt,
|
FinishedAt: status.FinishedAt,
|
||||||
Report: append(json.RawMessage(nil), status.Report...),
|
Report: append(json.RawMessage(nil), status.Report...),
|
||||||
Error: status.Error,
|
Error: status.Error,
|
||||||
}, nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type uploadErrorContext struct {
|
type uploadErrorContext struct {
|
||||||
@@ -331,7 +393,7 @@ type uploadErrorContext struct {
|
|||||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||||
var conflict *distributorupload.IdempotencyConflictError
|
var conflict *distributorupload.IdempotencyConflictError
|
||||||
isConflict := errors.As(err, &conflict)
|
isConflict := errors.As(err, &conflict)
|
||||||
err = redactToken(err, ctx.Token)
|
err = safeDistributorDiagnostic(err, ctx.Token)
|
||||||
if isConflict {
|
if isConflict {
|
||||||
return &IdempotencyConflictError{
|
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),
|
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)
|
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 {
|
func uploadSourcePaths(files []UploadFile) []string {
|
||||||
paths := make([]string, 0, len(files))
|
paths := make([]string, 0, len(files))
|
||||||
for _, file := range 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" {
|
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
||||||
t.Fatalf("result = %#v, want accepted run", result)
|
t.Fatalf("result = %#v, want accepted run", result)
|
||||||
}
|
}
|
||||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || len(result.RunStatus.Report) != 0 {
|
||||||
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
t.Fatalf("RunStatus = %#v, want safe status details", result.RunStatus)
|
||||||
}
|
}
|
||||||
if factory.endpoint != cfg.Endpoint {
|
if factory.endpoint != cfg.Endpoint {
|
||||||
t.Fatalf("factory endpoint = %q, want %q", 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 := newClient(cfg, factory.newClient)
|
||||||
|
client.pollWait = func(context.Context, time.Duration) error { return nil }
|
||||||
|
|
||||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upload() error = %v", err)
|
t.Fatalf("Upload() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
if result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
|
||||||
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
t.Fatalf("result = %#v, want terminal succeeded status without remote report", result)
|
||||||
}
|
}
|
||||||
if factory.client.statusCalls != 2 {
|
if factory.client.statusCalls != 2 {
|
||||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||||
@@ -298,8 +299,8 @@ func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Upload() error = nil, want failed distributor run error")
|
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") {
|
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 failed run status report", result)
|
t.Fatalf("result = %#v, want safe failed run status", result)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
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)
|
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
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/promptassets"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"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.
|
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||||
|
// It supports concurrent Execute calls on the shared executor.
|
||||||
type Adapter struct {
|
type Adapter struct {
|
||||||
engine *promptkit.Engine
|
engine *promptkit.Engine
|
||||||
}
|
}
|
||||||
@@ -193,6 +195,17 @@ func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.E
|
|||||||
value.Validation.SchemaPath,
|
value.Validation.SchemaPath,
|
||||||
value.Validation.Errors,
|
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{
|
execution := &promptexec.Execution{
|
||||||
RunID: value.RunID,
|
RunID: value.RunID,
|
||||||
PromptID: value.PromptID,
|
PromptID: value.PromptID,
|
||||||
@@ -215,11 +228,11 @@ func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.E
|
|||||||
EndedAt: value.EndTime,
|
EndedAt: value.EndTime,
|
||||||
Duration: value.Duration,
|
Duration: value.Duration,
|
||||||
Validation: validation,
|
Validation: validation,
|
||||||
RawOutput: []byte(value.RawOutput),
|
RawOutput: rawOutput,
|
||||||
}
|
}
|
||||||
if captureDebug {
|
if captureDebug {
|
||||||
execution.Debug = &promptexec.ExecutionDebug{
|
execution.Debug = &promptexec.ExecutionDebug{
|
||||||
RawOutput: append([]byte(nil), value.RawOutput...),
|
RawOutput: append([]byte(nil), rawOutput...),
|
||||||
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +266,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
|||||||
TimeoutSeconds int `json:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
ServiceTier string `json:"service_tier"`
|
ServiceTier string `json:"service_tier"`
|
||||||
ReasoningEffort string `json:"reasoning_effort"`
|
ReasoningEffort string `json:"reasoning_effort"`
|
||||||
ExtraParams map[string]any `json:"extra_params"`
|
|
||||||
}{
|
}{
|
||||||
Temperature: value.Temperature,
|
Temperature: value.Temperature,
|
||||||
MaxTokens: value.MaxTokens,
|
MaxTokens: value.MaxTokens,
|
||||||
@@ -261,7 +273,6 @@ func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
|||||||
TimeoutSeconds: value.TimeoutSeconds,
|
TimeoutSeconds: value.TimeoutSeconds,
|
||||||
ServiceTier: value.ServiceTier,
|
ServiceTier: value.ServiceTier,
|
||||||
ReasoningEffort: value.ReasoningEffort,
|
ReasoningEffort: value.ReasoningEffort,
|
||||||
ExtraParams: value.ExtraParams,
|
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(parameters)
|
data, _ := json.Marshal(parameters)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ type fakeClient struct {
|
|||||||
calls int
|
calls int
|
||||||
requests []promptkit.GenerateRequest
|
requests []promptkit.GenerateRequest
|
||||||
block bool
|
block bool
|
||||||
|
started chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordingReader struct {
|
type recordingReader struct {
|
||||||
@@ -44,9 +46,13 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
|
|||||||
client.calls++
|
client.calls++
|
||||||
client.requests = append(client.requests, request)
|
client.requests = append(client.requests, request)
|
||||||
block := client.block
|
block := client.block
|
||||||
|
started := client.started
|
||||||
response := client.response
|
response := client.response
|
||||||
err := client.err
|
err := client.err
|
||||||
client.mu.Unlock()
|
client.mu.Unlock()
|
||||||
|
if started != nil {
|
||||||
|
started <- struct{}{}
|
||||||
|
}
|
||||||
if block {
|
if block {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
@@ -54,6 +60,36 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
|
|||||||
return response, err
|
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 {
|
func (client *fakeClient) callCount() int {
|
||||||
client.mu.Lock()
|
client.mu.Lock()
|
||||||
defer client.mu.Unlock()
|
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) {
|
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||||
client := &fakeClient{response: validResponse()}
|
client := &fakeClient{response: validResponse()}
|
||||||
adapter := newTestAdapter(t, client)
|
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) {
|
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -431,6 +508,7 @@ func TestNewValidatesConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||||
|
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
|
||||||
profiles := testProfileDirectory(t, `id: local-profile
|
profiles := testProfileDirectory(t, `id: local-profile
|
||||||
backend: local
|
backend: local
|
||||||
model: local-model
|
model: local-model
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,24 +15,28 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
currentConditionsEndpoint = "/conditions/current"
|
||||||
|
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
|
||||||
|
|
||||||
defaultWarmupEndpoint = "/conditions/current"
|
defaultWarmupEndpoint = currentConditionsEndpoint
|
||||||
defaultWarmupAttempts = 3
|
defaultWarmupAttempts = 3
|
||||||
defaultWarmupDelay = time.Second
|
defaultWarmupDelay = time.Second
|
||||||
defaultFetchAttempts = 2
|
defaultFetchAttempts = 2
|
||||||
defaultFetchRetryDelay = time.Second
|
defaultFetchRetryDelay = time.Second
|
||||||
|
maxResponseBodyBytes = 10 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -75,6 +80,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||||
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
|
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
|
timeout := cfg.WeatherAPI.Timeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
@@ -106,7 +114,8 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,32 +123,15 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
|||||||
builder := bundleBuilder{
|
builder := bundleBuilder{
|
||||||
client: c,
|
client: c,
|
||||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||||
fetchedAt: fetchedAt,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := builder.fetchObservation(ctx); err != nil {
|
for _, acquired := range builder.acquireSources(ctx, warmup) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch weather API sources: %w", err)
|
||||||
|
}
|
||||||
|
if err := builder.mergeSource(acquired); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := builder.fetchCurrent(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchHourly(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchNarrative(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchAlerts(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.bundle, nil
|
return builder.bundle, nil
|
||||||
@@ -148,7 +140,6 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
|||||||
type bundleBuilder struct {
|
type bundleBuilder struct {
|
||||||
client *Client
|
client *Client
|
||||||
bundle *weatherdata.Bundle
|
bundle *weatherdata.Bundle
|
||||||
fetchedAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceRequest struct {
|
type sourceRequest struct {
|
||||||
@@ -165,14 +156,81 @@ type fetchedSource struct {
|
|||||||
source weatherdata.Source
|
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
|
var observation weatherdata.Observation
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &observation)
|
||||||
name: "observations",
|
|
||||||
endpoint: "/observations",
|
|
||||||
query: queryOptions{precision: true},
|
|
||||||
missingMessage: "observation data is missing",
|
|
||||||
}, &observation)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -183,14 +241,18 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
func currentConditionsRequest() sourceRequest {
|
||||||
var current weatherdata.Current
|
return sourceRequest{
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
name: config.MissingSourceCurrent,
|
||||||
name: "current",
|
endpoint: currentConditionsEndpoint,
|
||||||
endpoint: "/conditions/current",
|
|
||||||
query: queryOptions{precision: true},
|
query: queryOptions{precision: true},
|
||||||
missingMessage: "current conditions data is missing",
|
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 {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,16 +262,9 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
func (b *bundleBuilder) fetchHourly(acquired sourceAcquisition) error {
|
||||||
var hourly weatherdata.ForecastRun
|
var hourly weatherdata.ForecastRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &hourly)
|
||||||
name: "hourly",
|
|
||||||
endpoint: "/forecast/hourly",
|
|
||||||
query: queryOptions{precision: true, timezone: true},
|
|
||||||
missingMessage: "hourly forecast data is missing",
|
|
||||||
required: true,
|
|
||||||
decodeLabel: "hourly forecast",
|
|
||||||
}, &hourly)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -217,6 +272,14 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
if len(hourly.Periods) == 0 {
|
if len(hourly.Periods) == 0 {
|
||||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
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.IssuedAt = &hourly.IssuedAt
|
||||||
source.UpdatedAt = hourly.UpdatedAt
|
source.UpdatedAt = hourly.UpdatedAt
|
||||||
b.bundle.Hourly = &hourly
|
b.bundle.Hourly = &hourly
|
||||||
@@ -224,14 +287,9 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
func (b *bundleBuilder) fetchNarrative(acquired sourceAcquisition) error {
|
||||||
var narrative weatherdata.ForecastRun
|
var narrative weatherdata.ForecastRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &narrative)
|
||||||
name: "narrative",
|
|
||||||
endpoint: "/forecast/narrative",
|
|
||||||
query: queryOptions{precision: true, timezone: true},
|
|
||||||
missingMessage: "narrative forecast data is missing",
|
|
||||||
}, &narrative)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -243,24 +301,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
func (b *bundleBuilder) fetchAlerts(acquired sourceAcquisition) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
fetched, ok, err := b.fetchSource(acquired)
|
||||||
if err != nil {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
raw, source := fetched.raw, fetched.source
|
||||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
|
||||||
}
|
|
||||||
if isJSONNull(raw) {
|
if isJSONNull(raw) {
|
||||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
b.bundle.Alerts = &weatherdata.AlertRun{}
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var alerts weatherdata.AlertRun
|
var alerts weatherdata.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
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 {
|
if alerts.AsOf != nil {
|
||||||
source.IssuedAt = alerts.AsOf
|
source.IssuedAt = alerts.AsOf
|
||||||
}
|
}
|
||||||
@@ -269,14 +324,9 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
func (b *bundleBuilder) fetchDiscussion(acquired sourceAcquisition) error {
|
||||||
var discussion weatherdata.Discussion
|
var discussion weatherdata.Discussion
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &discussion)
|
||||||
name: "discussion",
|
|
||||||
endpoint: "/discussion",
|
|
||||||
query: queryOptions{timezone: true},
|
|
||||||
missingMessage: "forecast discussion data is missing",
|
|
||||||
}, &discussion)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -288,18 +338,16 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(acquired sourceAcquisition) error {
|
||||||
var story weatherdata.WeatherStory
|
var story weatherdata.WeatherStory
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &story)
|
||||||
name: "weather_story",
|
|
||||||
endpoint: "/weatherstories/latest",
|
|
||||||
query: queryOptions{omitUnits: true},
|
|
||||||
missingMessage: "NWS weather story data is missing",
|
|
||||||
}, &story)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
source := fetched.source
|
source := fetched.source
|
||||||
|
if !story.HasUsableContent() {
|
||||||
|
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), acquired.request)
|
||||||
|
}
|
||||||
if !story.StartTime.IsZero() {
|
if !story.StartTime.IsZero() {
|
||||||
source.IssuedAt = &story.StartTime
|
source.IssuedAt = &story.StartTime
|
||||||
}
|
}
|
||||||
@@ -309,14 +357,9 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(acquired sourceAcquisition) error {
|
||||||
var run weatherdata.ConvectiveOutlookRun
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
fetched, ok, err := b.fetchDecodedSource(acquired, &run)
|
||||||
name: sourceSPCConvectiveOutlooks,
|
|
||||||
endpoint: convectiveOutlooksEndpoint,
|
|
||||||
query: queryOptions{timezone: true, omitUnits: true},
|
|
||||||
missingMessage: "SPC convective outlook data is missing",
|
|
||||||
}, &run)
|
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -332,26 +375,35 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
func (b *bundleBuilder) fetchDecodedSource(acquired sourceAcquisition, target any) (fetchedSource, bool, error) {
|
||||||
fetched, ok, err := b.fetchSource(ctx, request)
|
fetched, ok, err := b.fetchSource(acquired)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return fetchedSource{}, false, err
|
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 {
|
if err := decodeSource(fetched.raw, target); err != nil {
|
||||||
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||||
}
|
}
|
||||||
return fetched, true, nil
|
return fetched, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
func (b *bundleBuilder) fetchSource(acquired sourceAcquisition) (fetchedSource, bool, error) {
|
||||||
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
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 {
|
if err != nil {
|
||||||
return fetchedSource{}, false, err
|
return fetchedSource{}, false, err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
acquired.fetched = fetchedSource{raw: raw, source: source}
|
||||||
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
} else if acquired.err != nil {
|
||||||
|
return fetchedSource{}, false, acquired.err
|
||||||
}
|
}
|
||||||
return fetchedSource{raw: raw, source: source}, true, nil
|
if acquired.fetched.raw == nil {
|
||||||
|
return fetchedSource{}, false, b.handleMissing(&acquired.fetched.source, acquired.request.missingMessage, acquired.request.required)
|
||||||
|
}
|
||||||
|
return acquired.fetched, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
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 {
|
if err != nil {
|
||||||
return nil, weatherdata.Source{}, err
|
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
|
var env envelope
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
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,
|
Name: sourceName,
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
Query: queryMap(reqURL.Query()),
|
Query: queryMap(reqURL.Query()),
|
||||||
FetchedAt: c.now(),
|
FetchedAt: fetchedAt,
|
||||||
}
|
}
|
||||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
@@ -446,53 +501,40 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
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
|
endpoint := c.warmupEndpoint
|
||||||
if strings.TrimSpace(endpoint) == "" {
|
if strings.TrimSpace(endpoint) == "" {
|
||||||
endpoint = defaultWarmupEndpoint
|
endpoint = defaultWarmupEndpoint
|
||||||
}
|
}
|
||||||
attempts := positiveAttemptCount(c.warmupAttempts)
|
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var lastRetryable bool
|
||||||
for attempt := 1; attempt <= attempts; attempt++ {
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
if err := ctx.Err(); err != nil {
|
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
|
lastErr = err
|
||||||
|
lastRetryable = isRetryableRequestError(err)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
|
||||||
}
|
}
|
||||||
if attempt == attempts {
|
if !lastRetryable || attempt == attempts {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
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 {
|
func (c *Client) warmupOnce(ctx context.Context, endpoint string) (*url.URL, []byte, error) {
|
||||||
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
|
return c.fetchHTTPOnce(ctx, 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) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
body, err := readResponseBody(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
return reqURL, nil, responseReadError(ctx, endpoint, err)
|
||||||
if ctx.Err() != nil {
|
|
||||||
return reqURL, nil, err
|
|
||||||
}
|
|
||||||
return reqURL, nil, retryableRequestError{err: err}
|
|
||||||
}
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
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) {
|
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||||
return reqURL, nil, retryableRequestError{err: err}
|
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
|
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 {
|
type retryableRequestError struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
@@ -659,10 +716,3 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
|||||||
sum := sha256.Sum256(compact.Bytes())
|
sum := sha256.Sum256(compact.Bytes())
|
||||||
return hex.EncodeToString(sum[:]), nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +17,12 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"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) {
|
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||||
var requested []string
|
var requested []string
|
||||||
server := fixtureServer(t, nil, &requested)
|
server := fixtureServer(t, nil, &requested)
|
||||||
@@ -80,8 +88,8 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
"/weatherstories/latest",
|
"/weatherstories/latest",
|
||||||
convectiveOutlooksEndpoint,
|
convectiveOutlooksEndpoint,
|
||||||
}
|
}
|
||||||
if len(requested) != len(wantPaths)+1 {
|
if len(requested) != len(wantPaths) {
|
||||||
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
|
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
||||||
t.Fatalf("first requested path = %q, want warmup endpoint %s", 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)
|
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") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
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) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
var requested []string
|
var requested []string
|
||||||
server := fixtureServer(t, nil, &requested)
|
server := fixtureServer(t, nil, &requested)
|
||||||
@@ -194,8 +390,9 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
|
const marker = "upstream-secret-marker"
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
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)
|
}, nil)
|
||||||
client := newTestClient(t, server.URL+"/", 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") {
|
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
|
||||||
t.Fatalf("error = %q, want endpoint and status", err.Error())
|
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) {
|
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
||||||
@@ -231,8 +431,8 @@ func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
|||||||
if bundle.Current == nil {
|
if bundle.Current == nil {
|
||||||
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
||||||
}
|
}
|
||||||
if warmupCalls != 3 {
|
if warmupCalls != 2 {
|
||||||
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
|
t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", warmupCalls)
|
||||||
}
|
}
|
||||||
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
||||||
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
|
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) {
|
func TestFetchRetriesRetryableStatus(t *testing.T) {
|
||||||
var hourlyCalls int
|
var hourlyCalls int
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
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) {
|
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
|
||||||
var hourlyCalls int
|
var hourlyCalls int
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
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) {
|
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
"/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) {
|
func TestContextCancellation(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -618,36 +1055,13 @@ func TestHTTPTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSaveBundle(t *testing.T) {
|
|
||||||
server := fixtureServer(t, nil, nil)
|
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
|
||||||
bundle, err := client.FetchBundle(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FetchBundle() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
|
|
||||||
if err := SaveBundle(path, bundle); err != nil {
|
|
||||||
t.Fatalf("SaveBundle() error = %v", err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read saved bundle: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), `"hourly"`) {
|
|
||||||
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type handlerOverride struct {
|
type handlerOverride struct {
|
||||||
status int
|
status int
|
||||||
body string
|
body string
|
||||||
handler http.HandlerFunc
|
handler http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
var weatherFixtureFiles = map[string]string{
|
||||||
t.Helper()
|
|
||||||
fixtures := map[string]string{
|
|
||||||
"/observations": "observations.json",
|
"/observations": "observations.json",
|
||||||
"/conditions/current": "current.json",
|
"/conditions/current": "current.json",
|
||||||
"/forecast/hourly": "hourly.json",
|
"/forecast/hourly": "hourly.json",
|
||||||
@@ -657,9 +1071,24 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
"/weatherstories/latest": "weather_story.json",
|
"/weatherstories/latest": "weather_story.json",
|
||||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveWeatherFixture(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
name, ok := weatherFixtureFiles[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join("testdata", name))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
var requestedMu sync.Mutex
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
requestedMu.Lock()
|
||||||
*requested = append(*requested, r.URL.String())
|
*requested = append(*requested, r.URL.String())
|
||||||
|
requestedMu.Unlock()
|
||||||
}
|
}
|
||||||
if override, ok := overrides[r.URL.Path]; ok {
|
if override, ok := overrides[r.URL.Path]; ok {
|
||||||
if override.handler != nil {
|
if override.handler != nil {
|
||||||
@@ -670,12 +1099,9 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
_, _ = w.Write([]byte(override.body))
|
_, _ = w.Write([]byte(override.body))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name, ok := fixtures[r.URL.Path]
|
if !serveWeatherFixture(w, r) {
|
||||||
if !ok {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, filepath.Join("testdata", name))
|
|
||||||
}))
|
}))
|
||||||
t.Cleanup(server.Close)
|
t.Cleanup(server.Close)
|
||||||
return server
|
return server
|
||||||
@@ -706,6 +1132,14 @@ func fixedNow() time.Time {
|
|||||||
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
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 {
|
func containsPath(requested []string, path string) bool {
|
||||||
for _, rawURL := range requested {
|
for _, rawURL := range requested {
|
||||||
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -66,6 +67,7 @@ type BatchRequest struct {
|
|||||||
type ModuleSnapshotRequest struct {
|
type ModuleSnapshotRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
|
Identity briefing.PreparedIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportFacts struct {
|
type ReportFacts struct {
|
||||||
@@ -99,6 +101,7 @@ type BatchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
|
Canceled int `json:"canceled,omitempty"`
|
||||||
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
||||||
Reports []BatchReportResult `json:"reports"`
|
Reports []BatchReportResult `json:"reports"`
|
||||||
}
|
}
|
||||||
@@ -142,12 +145,19 @@ type BatchReportResult struct {
|
|||||||
|
|
||||||
type BatchError struct {
|
type BatchError struct {
|
||||||
Result *BatchResult
|
Result *BatchResult
|
||||||
|
Cause error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e BatchError) Error() string {
|
func (e BatchError) Error() string {
|
||||||
if e.Result == nil {
|
if e.Result == nil {
|
||||||
return "batch failed"
|
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)
|
failedReports := batchReportFailures(e.Result)
|
||||||
if batchNotificationFailed(e.Result) && failedReports == 0 {
|
if batchNotificationFailed(e.Result) && failedReports == 0 {
|
||||||
if e.Result.Notification.Error != "" {
|
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))
|
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 {
|
func batchNotificationFailed(result *BatchResult) bool {
|
||||||
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
result := initialReportResult(req, resolved, PromptInspectionResult{})
|
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)
|
outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, req.Config.Output.Directory, resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
@@ -258,6 +275,7 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
inspection, err := InspectPromptExecution(ctx, PromptInspectionRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Executor: req.Executor,
|
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 {
|
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := preflightDistributorNotification(req.Config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
outputDir, err := resolveOutputDirWithConfigured(req.WorkingDir, req.OutputDir, req.Config.Output.Directory)
|
outputDir, err := resolveOutputDirWithConfigured(req.WorkingDir, req.OutputDir, req.Config.Output.Directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -309,6 +330,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
candidates, err := batchInspectionCandidates(req, now)
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -335,7 +357,12 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
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
|
resolved := planned.Resolved
|
||||||
item := batchReportResult(planned)
|
item := batchReportResult(planned)
|
||||||
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||||
@@ -355,26 +382,72 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
copyBatchReportDetails(&item, reportResult)
|
copyBatchReportDetails(&item, reportResult)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if reportCancellation := batchReportCancellationCause(err); reportCancellation != nil {
|
||||||
|
cancellation = reportCancellation
|
||||||
|
item.Status = "canceled"
|
||||||
|
result.Canceled++
|
||||||
|
} else {
|
||||||
item.Status = "failed"
|
item.Status = "failed"
|
||||||
item.Error = err.Error()
|
item.Error = err.Error()
|
||||||
result.Failed++
|
result.Failed++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
item.Status = "succeeded"
|
item.Status = "succeeded"
|
||||||
result.Succeeded++
|
result.Succeeded++
|
||||||
}
|
}
|
||||||
result.Reports = append(result.Reports, item)
|
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)
|
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 {
|
if batchNotification != nil {
|
||||||
result.Notification = batchNotification
|
result.Notification = batchNotification
|
||||||
}
|
}
|
||||||
result.FinishedAt = time.Now()
|
result.FinishedAt = time.Now()
|
||||||
return result, nil
|
return result, cancellation
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
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) {
|
func copyBatchReportDetails(item *BatchReportResult, result *ReportResult) {
|
||||||
item.LLMDebugPath = result.LLMDebugPath
|
item.LLMDebugPath = result.LLMDebugPath
|
||||||
item.OutputPath = result.OutputPath
|
item.OutputPath = result.OutputPath
|
||||||
@@ -509,6 +582,16 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
|||||||
}, true
|
}, 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) {
|
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))
|
values, err := distributorTemplateValuesForReport(cfg, resolved, runID, filepath.Base(outputPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -631,25 +714,7 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
|
|||||||
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
|
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
|
||||||
CreatedAt: req.CreatedAt,
|
CreatedAt: req.CreatedAt,
|
||||||
})
|
})
|
||||||
notification := &NotificationResult{
|
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
|
||||||
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
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notification, err
|
return notification, err
|
||||||
}
|
}
|
||||||
@@ -673,7 +738,7 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
|
|||||||
RunID: result.RunID,
|
RunID: result.RunID,
|
||||||
Status: result.Status,
|
Status: result.Status,
|
||||||
UploadStatus: result.UploadStatus,
|
UploadStatus: result.UploadStatus,
|
||||||
StatusError: result.StatusError,
|
StatusError: safeDistributorStatusError(result.StatusError),
|
||||||
}
|
}
|
||||||
if result.RunStatus != nil {
|
if result.RunStatus != nil {
|
||||||
if result.RunStatus.PipelineID != "" {
|
if result.RunStatus.PipelineID != "" {
|
||||||
@@ -682,12 +747,25 @@ func notificationResultFromUpload(pipelineID string, bundleID string, idempotenc
|
|||||||
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
||||||
notification.StartedAt = result.RunStatus.StartedAt
|
notification.StartedAt = result.RunStatus.StartedAt
|
||||||
notification.FinishedAt = result.RunStatus.FinishedAt
|
notification.FinishedAt = result.RunStatus.FinishedAt
|
||||||
notification.Report = append([]byte(nil), result.RunStatus.Report...)
|
notification.Error = safeDistributorRunError(result.RunStatus.Error)
|
||||||
notification.Error = result.RunStatus.Error
|
|
||||||
}
|
}
|
||||||
return notification
|
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 {
|
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
|
||||||
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
||||||
for _, bundlePath := range bundlePaths {
|
for _, bundlePath := range bundlePaths {
|
||||||
@@ -727,7 +805,12 @@ func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportF
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return module.Snapshot{}, err
|
return module.Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
identity := req.Identity
|
||||||
|
if identity.ReportID == "" {
|
||||||
|
identity = briefing.BuildPreparedIdentity(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||||
|
}
|
||||||
moduleContext := briefing.ModuleContext{
|
moduleContext := briefing.ModuleContext{
|
||||||
|
Identity: identity,
|
||||||
Resolved: req.Resolved,
|
Resolved: req.Resolved,
|
||||||
Collected: reportFacts.Collected,
|
Collected: reportFacts.Collected,
|
||||||
Derived: reportFacts.Derived,
|
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{
|
return promptinput.Metadata{
|
||||||
RunID: metadata.RunID,
|
RunID: identity.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: identity.ReportID,
|
||||||
Variant: metadata.Variant,
|
Variant: identity.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: identity.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: identity.GeneratedAt,
|
||||||
Timezone: metadata.Timezone,
|
Timezone: identity.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: identity.ValidPeriod,
|
||||||
SourceWarnings: metadata.SourceWarnings,
|
SourceWarnings: identity.SourceWarnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
|
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(),
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
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)
|
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 != "" {
|
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) {
|
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
||||||
bundle := generationBundle(t)
|
bundle := generationBundle(t)
|
||||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
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) {
|
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -42,46 +42,64 @@ type batchNotifier interface {
|
|||||||
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
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 {
|
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||||
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
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 {
|
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
|
||||||
if !cfg.Notify.Distributor.Enabled {
|
if !input.cfg.Notify.Distributor.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
if !input.cfg.Notify.Distributor.Batch.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if result == nil {
|
if input.result == nil {
|
||||||
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
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{
|
return &BatchNotificationResult{
|
||||||
Status: "skipped",
|
Status: "skipped",
|
||||||
Reason: "one or more reports failed",
|
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 {
|
if err != nil {
|
||||||
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failedBatchNotificationResult(req, err)
|
return failedBatchNotificationResult(req, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
|
||||||
wrappedErr := notifyErr
|
wrappedErr := notifyErr
|
||||||
if notifyErr != nil {
|
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)
|
batchResult := batchNotificationResult(req, notification)
|
||||||
if wrappedErr != nil {
|
if wrappedErr != nil {
|
||||||
batchResult.Status = "failed"
|
batchResult.Status = "failed"
|
||||||
batchResult.Error = wrappedErr.Error()
|
batchResult.Error = safeDistributorNotificationFailure(wrappedErr)
|
||||||
return batchResult
|
return batchResult
|
||||||
}
|
}
|
||||||
return batchResult
|
return batchResult
|
||||||
@@ -233,7 +251,7 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
|
|||||||
notification.IdempotencyKey = result.IdempotencyKey
|
notification.IdempotencyKey = result.IdempotencyKey
|
||||||
}
|
}
|
||||||
if result.Error != "" {
|
if result.Error != "" {
|
||||||
notification.Error = result.Error
|
notification.Error = safeDistributorRunError(result.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if notification.Status == "" {
|
if notification.Status == "" {
|
||||||
@@ -246,11 +264,18 @@ func failedBatchNotificationResult(req batchNotificationRequest, err error) *Bat
|
|||||||
notification := batchNotificationResult(req, nil)
|
notification := batchNotificationResult(req, nil)
|
||||||
notification.Status = "failed"
|
notification.Status = "failed"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notification.Error = err.Error()
|
notification.Error = safeDistributorNotificationFailure(err)
|
||||||
}
|
}
|
||||||
return notification
|
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) {
|
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
||||||
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
|
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func compareDetailed(ctx context.Context, req ComparisonRequest, publish compari
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.OutputDirectory = outputDirectory
|
result.OutputDirectory = outputDirectory
|
||||||
_, err = comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, fmt.Errorf("preflight comparison destination: %w", err)
|
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 {
|
if err != nil {
|
||||||
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = debugWriter.Close() }()
|
||||||
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
|
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
|
||||||
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor, LookupEnv: os.LookupEnv,
|
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 {
|
if err != nil {
|
||||||
return result, err
|
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 {
|
if err != nil {
|
||||||
return result, fmt.Errorf("prepare comparison report: %w", err)
|
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 {
|
if err := bundle.Validate(); err != nil {
|
||||||
return result, fmt.Errorf("build comparison bundle: %w", err)
|
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)
|
publication, err := publish(ctx, publicationPlan, bundle)
|
||||||
if publication.Committed {
|
if publication.Committed {
|
||||||
result.OutputDirectory = publicationPlan.Target
|
result.OutputDirectory = publicationPlan.Target
|
||||||
|
|||||||
@@ -35,11 +35,21 @@ type comparisonProfileOutcome struct {
|
|||||||
Markdown []byte
|
Markdown []byte
|
||||||
LLMDebugPath string
|
LLMDebugPath string
|
||||||
Error *comparison.SafeError
|
Error *comparison.SafeError
|
||||||
|
canceled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type comparisonProfileExecutionState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
comparisonProfilePending comparisonProfileExecutionState = iota
|
||||||
|
comparisonProfileRunning
|
||||||
|
comparisonProfileComplete
|
||||||
|
)
|
||||||
|
|
||||||
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
|
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
|
||||||
profiles := req.Inspection.Profiles
|
profiles := req.Inspection.Profiles
|
||||||
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
|
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
|
||||||
|
states := make([]comparisonProfileExecutionState, len(profiles))
|
||||||
for index, profile := range profiles {
|
for index, profile := range profiles {
|
||||||
result.Outcomes[index] = comparisonProfileOutcome{
|
result.Outcomes[index] = comparisonProfileOutcome{
|
||||||
Position: index + 1,
|
Position: index + 1,
|
||||||
@@ -54,21 +64,22 @@ func executeComparisonProfiles(ctx context.Context, req comparisonExecutionReque
|
|||||||
for index, profile := range profiles {
|
for index, profile := range profiles {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
result.Canceled = true
|
result.Canceled = true
|
||||||
markUnstartedComparisonOutcomes(result.Outcomes[index:], err)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
index, profile := index, profile
|
index, profile := index, profile
|
||||||
|
states[index] = comparisonProfileRunning
|
||||||
waitGroup.Add(1)
|
waitGroup.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer waitGroup.Done()
|
defer waitGroup.Done()
|
||||||
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
|
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
|
||||||
|
states[index] = comparisonProfileComplete
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
waitGroup.Wait()
|
waitGroup.Wait()
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
result.Canceled = true
|
result.Canceled = true
|
||||||
for index := range result.Outcomes {
|
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)
|
markCanceledComparisonOutcome(&result.Outcomes[index], err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +110,7 @@ func executeComparisonProfile(ctx context.Context, req comparisonExecutionReques
|
|||||||
outcome.ValidationStatus = execution.ValidationStatus
|
outcome.ValidationStatus = execution.ValidationStatus
|
||||||
outcome.LLMDebugPath = execution.LLMDebugPath
|
outcome.LLMDebugPath = execution.LLMDebugPath
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
outcome.canceled = cancellationError(err)
|
||||||
safe := comparisonSafeExecutionError(err)
|
safe := comparisonSafeExecutionError(err)
|
||||||
outcome.Error = &safe
|
outcome.Error = &safe
|
||||||
return outcome
|
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))
|
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) {
|
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
|
||||||
outcome.Status = comparison.StatusFailed
|
outcome.Status = comparison.StatusFailed
|
||||||
outcome.ValidationStatus = promptexec.ValidationSkipped
|
outcome.ValidationStatus = promptexec.ValidationSkipped
|
||||||
@@ -134,6 +140,12 @@ func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error)
|
|||||||
outcome.Error = &safe
|
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 {
|
func comparisonSafeExecutionError(err error) comparison.SafeError {
|
||||||
category := promptexec.CategoryOf(err)
|
category := promptexec.CategoryOf(err)
|
||||||
if category == "" {
|
if category == "" {
|
||||||
|
|||||||
@@ -20,12 +20,9 @@ func TestExecuteComparisonProfilesRunsOrderedProfilesConcurrently(t *testing.T)
|
|||||||
prepared, prompt := preparedDailyProfile(t)
|
prepared, prompt := preparedDailyProfile(t)
|
||||||
profiles := comparisonProfiles(10)
|
profiles := comparisonProfiles(10)
|
||||||
executor := newBarrierExecutor(profiles)
|
executor := newBarrierExecutor(profiles)
|
||||||
results := make(chan comparisonExecutionResult, 1)
|
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||||
go func() {
|
|
||||||
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
|
|
||||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||||
})
|
}, executor)
|
||||||
}()
|
|
||||||
waitForProfileStarts(t, executor, profiles, results)
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
if executor.maximumInFlight() < 2 {
|
if executor.maximumInFlight() < 2 {
|
||||||
t.Fatalf("maximum in-flight executions = %d, want overlap", executor.maximumInFlight())
|
t.Fatalf("maximum in-flight executions = %d, want overlap", executor.maximumInFlight())
|
||||||
@@ -58,12 +55,9 @@ func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
|
|||||||
profiles := comparisonProfiles(3)
|
profiles := comparisonProfiles(3)
|
||||||
executor := newBarrierExecutor(profiles)
|
executor := newBarrierExecutor(profiles)
|
||||||
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
|
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
|
||||||
results := make(chan comparisonExecutionResult, 1)
|
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||||
go func() {
|
|
||||||
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
|
|
||||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||||
})
|
}, executor)
|
||||||
}()
|
|
||||||
waitForProfileStarts(t, executor, profiles, results)
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
for _, profile := range profiles {
|
for _, profile := range profiles {
|
||||||
executor.release(profile.ProfileID)
|
executor.release(profile.ProfileID)
|
||||||
@@ -84,12 +78,9 @@ func TestExecuteComparisonProfilesPropagatesCancellationAndJoins(t *testing.T) {
|
|||||||
executor := newBarrierExecutor(profiles)
|
executor := newBarrierExecutor(profiles)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
results := make(chan comparisonExecutionResult, 1)
|
results := startComparisonExecution(t, ctx, comparisonExecutionRequest{
|
||||||
go func() {
|
|
||||||
results <- executeComparisonProfiles(ctx, comparisonExecutionRequest{
|
|
||||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||||
})
|
}, executor)
|
||||||
}()
|
|
||||||
waitForProfileStarts(t, executor, profiles, results)
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
cancel()
|
cancel()
|
||||||
result := <-results
|
result := <-results
|
||||||
@@ -110,16 +101,16 @@ func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *te
|
|||||||
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
|
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
|
||||||
}
|
}
|
||||||
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
}
|
}
|
||||||
executor := newBarrierExecutor(profiles)
|
executor := newBarrierExecutor(profiles)
|
||||||
results := make(chan comparisonExecutionResult, 1)
|
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||||
go func() {
|
|
||||||
results <- executeComparisonProfiles(context.Background(), comparisonExecutionRequest{
|
|
||||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
|
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
|
||||||
})
|
}, executor)
|
||||||
}()
|
|
||||||
waitForProfileStarts(t, executor, profiles, results)
|
waitForProfileStarts(t, executor, profiles, results)
|
||||||
for _, profile := range profiles {
|
for _, profile := range profiles {
|
||||||
executor.release(profile.ProfileID)
|
executor.release(profile.ProfileID)
|
||||||
@@ -148,18 +139,21 @@ type barrierExecutor struct {
|
|||||||
releases map[string]chan struct{}
|
releases map[string]chan struct{}
|
||||||
requests map[string]promptexec.ExecuteRequest
|
requests map[string]promptexec.ExecuteRequest
|
||||||
errors map[string]error
|
errors map[string]error
|
||||||
|
profiles map[string]ComparisonProfileInspection
|
||||||
inFlight int
|
inFlight int
|
||||||
maximum int
|
maximum int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBarrierExecutor(profiles []ComparisonProfileInspection) *barrierExecutor {
|
func newBarrierExecutor(profiles []ComparisonProfileInspection) *barrierExecutor {
|
||||||
releases := make(map[string]chan struct{}, len(profiles))
|
releases := make(map[string]chan struct{}, len(profiles))
|
||||||
|
identities := make(map[string]ComparisonProfileInspection, len(profiles))
|
||||||
for _, profile := range profiles {
|
for _, profile := range profiles {
|
||||||
releases[profile.ProfileID] = make(chan struct{})
|
releases[profile.ProfileID] = make(chan struct{})
|
||||||
|
identities[profile.ProfileID] = profile
|
||||||
}
|
}
|
||||||
return &barrierExecutor{
|
return &barrierExecutor{
|
||||||
started: make(chan string, len(profiles)), callbackFailures: make(chan error, len(profiles)), releases: releases,
|
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) {
|
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)
|
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
|
e.callbackFailures <- err
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -202,8 +199,8 @@ func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteReq
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &promptexec.Execution{
|
return &promptexec.Execution{
|
||||||
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash,
|
||||||
ProfileID: req.ProfileID, BackendID: "backend-" + req.ProfileID, ModelName: "model-" + req.ProfileID,
|
ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||||
StartedAt: stamp, EndedAt: stamp, RawOutput: comparisonRawOutput(),
|
StartedAt: stamp, EndedAt: stamp, RawOutput: comparisonRawOutput(),
|
||||||
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -248,9 +245,32 @@ func (e *barrierExecutor) inFlightCount() int {
|
|||||||
return e.inFlight
|
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) {
|
func waitForProfileStarts(t *testing.T, executor *barrierExecutor, profiles []ComparisonProfileInspection, results <-chan comparisonExecutionResult) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
timeout := time.NewTimer(5 * time.Second)
|
timeout := time.NewTimer(comparisonExecutionTestTimeout)
|
||||||
defer timeout.Stop()
|
defer timeout.Stop()
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for range profiles {
|
for range profiles {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -87,11 +88,24 @@ func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
|
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
state comparison.BackupRecoveryState
|
||||||
|
path bool
|
||||||
|
}{
|
||||||
|
{name: "complete recovery bundle", state: comparison.BackupRecoveryComplete, path: true},
|
||||||
|
{name: "partial remnants", state: comparison.BackupRecoveryPartial, path: true},
|
||||||
|
{name: "absent backup", state: comparison.BackupRecoveryAbsent},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
bundle := generationBundle(t)
|
bundle := generationBundle(t)
|
||||||
backupPath := filepath.Join(t.TempDir(), ".comparison-daily.backup-retained")
|
recoveryPath := ""
|
||||||
|
if test.path {
|
||||||
|
recoveryPath = filepath.Join(t.TempDir(), ".comparison-daily.backup-recovery")
|
||||||
|
}
|
||||||
cleanupCause := errors.New("backup cleanup failed")
|
cleanupCause := errors.New("backup cleanup failed")
|
||||||
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
|
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
|
||||||
return comparison.PublicationResult{Committed: true, RetainedBackupPath: backupPath}, &comparison.PublicationCleanupError{RetainedBackupPath: backupPath, Err: cleanupCause}
|
return comparison.PublicationResult{Committed: true, RecoveryState: test.state, RecoveryPath: recoveryPath}, &comparison.PublicationCleanupError{RecoveryState: test.state, RecoveryPath: recoveryPath, Err: cleanupCause}
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := compareDetailed(context.Background(), ComparisonRequest{
|
result, err := compareDetailed(context.Background(), ComparisonRequest{
|
||||||
@@ -101,7 +115,7 @@ func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T
|
|||||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||||
}, publish)
|
}, publish)
|
||||||
var cleanupErr *comparison.PublicationCleanupError
|
var cleanupErr *comparison.PublicationCleanupError
|
||||||
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RetainedBackupPath != backupPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RecoveryState != test.state || cleanupErr.RecoveryPath != recoveryPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
||||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||||
}
|
}
|
||||||
for _, profile := range result.Results {
|
for _, profile := range result.Results {
|
||||||
@@ -109,6 +123,8 @@ func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T
|
|||||||
t.Fatalf("published profile result = %#v", profile)
|
t.Fatalf("published profile result = %#v", profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
|
func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
|
||||||
@@ -292,6 +308,57 @@ func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompareDetailedPreservesCompletedProfileFailureWhenCanceled(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
failureStarted := make(chan struct{})
|
||||||
|
var signalFailure sync.Once
|
||||||
|
executor := &generationExecutor{
|
||||||
|
validations: map[string]promptexec.ValidationStatus{"weather-light": promptexec.ValidationFailed},
|
||||||
|
waitForCancellation: map[string]bool{"weather-deep": true},
|
||||||
|
beforeExecute: func(request promptexec.ExecuteRequest) {
|
||||||
|
if request.ProfileID == "weather-light" {
|
||||||
|
signalFailure.Do(func() { close(failureStarted) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
results := make(chan struct {
|
||||||
|
result *ComparisonResult
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
go func() {
|
||||||
|
result, err := CompareDetailed(ctx, ComparisonRequest{
|
||||||
|
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||||
|
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||||
|
})
|
||||||
|
results <- struct {
|
||||||
|
result *ComparisonResult
|
||||||
|
err error
|
||||||
|
}{result: result, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-failureStarted:
|
||||||
|
cancel()
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for the completed profile failure")
|
||||||
|
}
|
||||||
|
completed := <-results
|
||||||
|
if !errors.Is(completed.err, context.Canceled) || completed.result == nil || completed.result.ManifestPath != "" || completed.result.DataPackagePath != "" || completed.result.Succeeded != 0 || completed.result.Failed != 2 {
|
||||||
|
t.Fatalf("CompareDetailed() result/error = %#v/%v", completed.result, completed.err)
|
||||||
|
}
|
||||||
|
failed, canceled := completed.result.Results[0], completed.result.Results[1]
|
||||||
|
if failed.Error == nil || failed.Error.Category != string(promptexec.ValidationRejected) || failed.ValidationStatus != promptexec.ValidationFailed || failed.ReportPath != "" {
|
||||||
|
t.Fatalf("completed failure = %#v", failed)
|
||||||
|
}
|
||||||
|
if canceled.Error == nil || canceled.Error.Category != string(promptexec.Canceled) || canceled.ValidationStatus != promptexec.ValidationSkipped || canceled.ReportPath != "" {
|
||||||
|
t.Fatalf("canceled profile = %#v", canceled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
|
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
|
||||||
workingDir := t.TempDir()
|
workingDir := t.TempDir()
|
||||||
target := filepath.Join(workingDir, "comparison-output")
|
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"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"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/promptexec"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +28,25 @@ type generationCollector struct {
|
|||||||
beforeRun func()
|
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) {
|
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
|
||||||
if c.beforeRun != nil {
|
if c.beforeRun != nil {
|
||||||
c.beforeRun()
|
c.beforeRun()
|
||||||
@@ -46,8 +68,14 @@ type generationExecutor struct {
|
|||||||
beforeExecute func(promptexec.ExecuteRequest)
|
beforeExecute func(promptexec.ExecuteRequest)
|
||||||
cancelBeforeReturn context.CancelFunc
|
cancelBeforeReturn context.CancelFunc
|
||||||
validation promptexec.ValidationStatus
|
validation promptexec.ValidationStatus
|
||||||
|
validations map[string]promptexec.ValidationStatus
|
||||||
rawOutput []byte
|
rawOutput []byte
|
||||||
|
waitForCancellation map[string]bool
|
||||||
failedPrompt string
|
failedPrompt string
|
||||||
|
skipPreparation bool
|
||||||
|
preparationCalls int
|
||||||
|
prepare func(*promptexec.Preparation)
|
||||||
|
complete func(*promptexec.Execution)
|
||||||
}
|
}
|
||||||
|
|
||||||
var generationExecutorMu sync.Mutex
|
var generationExecutorMu sync.Mutex
|
||||||
@@ -71,11 +99,28 @@ func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promp
|
|||||||
}
|
}
|
||||||
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
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)
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
generationExecutorMu.Lock()
|
||||||
|
skipPreparation := e.skipPreparation
|
||||||
|
prepare := e.prepare
|
||||||
|
preparationCalls := e.preparationCalls
|
||||||
|
generationExecutorMu.Unlock()
|
||||||
|
if !skipPreparation {
|
||||||
|
calls := preparationCalls
|
||||||
|
if calls == 0 {
|
||||||
|
calls = 1
|
||||||
|
}
|
||||||
|
for range calls {
|
||||||
|
preparation := promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}
|
||||||
|
if prepare != nil {
|
||||||
|
prepare(&preparation)
|
||||||
|
}
|
||||||
|
if err := callback(preparation, nil); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
generationExecutorMu.Lock()
|
generationExecutorMu.Lock()
|
||||||
e.called = true
|
e.called = true
|
||||||
e.executeCalls++
|
e.executeCalls++
|
||||||
@@ -83,13 +128,22 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
|
|||||||
profileErr := e.executeErrors[req.ProfileID]
|
profileErr := e.executeErrors[req.ProfileID]
|
||||||
executeErr := e.executeErr
|
executeErr := e.executeErr
|
||||||
status := e.validation
|
status := e.validation
|
||||||
|
if profileStatus, ok := e.validations[req.ProfileID]; ok {
|
||||||
|
status = profileStatus
|
||||||
|
}
|
||||||
rawOutput := append([]byte(nil), e.rawOutput...)
|
rawOutput := append([]byte(nil), e.rawOutput...)
|
||||||
|
waitForCancellation := e.waitForCancellation[req.ProfileID]
|
||||||
failedPrompt := e.failedPrompt
|
failedPrompt := e.failedPrompt
|
||||||
cancelBeforeReturn := e.cancelBeforeReturn
|
cancelBeforeReturn := e.cancelBeforeReturn
|
||||||
|
complete := e.complete
|
||||||
generationExecutorMu.Unlock()
|
generationExecutorMu.Unlock()
|
||||||
if beforeExecute != nil {
|
if beforeExecute != nil {
|
||||||
beforeExecute(req)
|
beforeExecute(req)
|
||||||
}
|
}
|
||||||
|
if waitForCancellation {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
if profileErr != nil {
|
if profileErr != nil {
|
||||||
return nil, profileErr
|
return nil, profileErr
|
||||||
}
|
}
|
||||||
@@ -108,7 +162,11 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
|
|||||||
if cancelBeforeReturn != nil {
|
if cancelBeforeReturn != nil {
|
||||||
cancelBeforeReturn()
|
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"
|
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) {
|
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
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) {
|
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
|
||||||
cfg := generationConfig()
|
cfg := generationConfig()
|
||||||
cfg.Notify.Distributor.Enabled = true
|
cfg.Notify.Distributor.Enabled = true
|
||||||
@@ -360,10 +495,33 @@ func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
|
|||||||
if err := os.Mkdir(outputPath, 0o700); err != nil {
|
if err := os.Mkdir(outputPath, 0o700); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
info, statErr := os.Stat(outputPath)
|
||||||
if err == nil || result == nil || statErr != nil || !info.IsDir() {
|
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 = %#v/%v/%#v (%v)", result, err, info, statErr)
|
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"),
|
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{},
|
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 == "" {
|
if err != nil || result == nil || result.LLMDebugPath == "" {
|
||||||
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -184,10 +185,8 @@ func validateOutputPath(path string) (string, error) {
|
|||||||
if filepath.Dir(path) == path {
|
if filepath.Dir(path) == path {
|
||||||
return "", fmt.Errorf("final output path %q must not be a filesystem root", 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() {
|
if err := fileutil.ValidateAtomicPath(path); err != nil {
|
||||||
return "", fmt.Errorf("final output path %q is a directory", path)
|
return "", fmt.Errorf("validate final output path %q: %w", path, err)
|
||||||
} else if err != nil && !os.IsNotExist(err) {
|
|
||||||
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
|
|
||||||
}
|
}
|
||||||
return path, nil
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveComparisonOutputDirectory(t *testing.T) {
|
func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||||
@@ -46,9 +48,7 @@ func TestResolveComparisonOutputDirectory(t *testing.T) {
|
|||||||
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||||
workingDir := t.TempDir()
|
workingDir := t.TempDir()
|
||||||
dangling := filepath.Join(workingDir, "dangling")
|
dangling := filepath.Join(workingDir, "dangling")
|
||||||
if err := os.Symlink(filepath.Join(workingDir, "missing"), dangling); err != nil {
|
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
|
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
|
||||||
t.Run(filepath.Base(directory), func(t *testing.T) {
|
t.Run(filepath.Base(directory), func(t *testing.T) {
|
||||||
@@ -63,9 +63,7 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
|
|||||||
workingDir := t.TempDir()
|
workingDir := t.TempDir()
|
||||||
target := t.TempDir()
|
target := t.TempDir()
|
||||||
link := filepath.Join(workingDir, "linked")
|
link := filepath.Join(workingDir, "linked")
|
||||||
if err := os.Symlink(target, link); err != nil {
|
testutil.RequireSymlink(t, target, link)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
directory := filepath.Join(link, "reports")
|
directory := filepath.Join(link, "reports")
|
||||||
got, err := resolveOutputDir(workingDir, directory)
|
got, err := resolveOutputDir(workingDir, directory)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"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/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
@@ -18,9 +19,9 @@ import (
|
|||||||
// executions for one resolved report.
|
// executions for one resolved report.
|
||||||
type preparedReport struct {
|
type preparedReport struct {
|
||||||
resolved report.Resolved
|
resolved report.Resolved
|
||||||
reportFacts ReportFacts
|
derived facts.DerivedFacts
|
||||||
moduleSnapshot module.Snapshot
|
moduleSnapshot module.Snapshot
|
||||||
briefingMetadata briefing.Metadata
|
identity briefing.PreparedIdentity
|
||||||
sourceWarnings []weatherdata.SourceWarning
|
sourceWarnings []weatherdata.SourceWarning
|
||||||
dataPackage []byte
|
dataPackage []byte
|
||||||
handler generatedtext.Handler
|
handler generatedtext.Handler
|
||||||
@@ -30,6 +31,7 @@ type prepareReportRequest struct {
|
|||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Collection collect.Result
|
Collection collect.Result
|
||||||
|
handler generatedtext.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type preparationError struct {
|
type preparationError struct {
|
||||||
@@ -54,12 +56,13 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "build report facts", err: err}
|
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 {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
|
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(identity), Modules: moduleSnapshot})
|
||||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(metadata), Modules: moduleSnapshot})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "build data package", err: err}
|
return preparedReport{}, &preparationError{operation: "build data package", err: err}
|
||||||
}
|
}
|
||||||
@@ -67,31 +70,26 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
|
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
|
||||||
}
|
}
|
||||||
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
clonedDerived, err := clonePreparedValue(reportFacts.Derived)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "lookup generated text catalog", err: err}
|
return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
|
||||||
}
|
|
||||||
|
|
||||||
clonedFacts, err := clonePreparedValue(reportFacts)
|
|
||||||
if err != nil {
|
|
||||||
return preparedReport{}, &preparationError{operation: "copy prepared report facts", err: err}
|
|
||||||
}
|
}
|
||||||
clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
|
clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
|
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
|
||||||
}
|
}
|
||||||
clonedMetadata, err := clonePreparedValue(metadata)
|
clonedIdentity, err := clonePreparedValue(identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return preparedReport{}, &preparationError{operation: "copy prepared briefing metadata", err: err}
|
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
|
||||||
}
|
}
|
||||||
prepared := preparedReport{
|
prepared := preparedReport{
|
||||||
resolved: cloneResolved(req.Resolved),
|
resolved: cloneResolved(req.Resolved),
|
||||||
reportFacts: clonedFacts,
|
derived: clonedDerived,
|
||||||
moduleSnapshot: clonedSnapshot,
|
moduleSnapshot: clonedSnapshot,
|
||||||
briefingMetadata: clonedMetadata,
|
identity: clonedIdentity,
|
||||||
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedMetadata.SourceWarnings...),
|
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
|
||||||
dataPackage: append([]byte(nil), serializedDataPackage...),
|
dataPackage: append([]byte(nil), serializedDataPackage...),
|
||||||
handler: handler,
|
handler: req.handler,
|
||||||
}
|
}
|
||||||
return prepared, nil
|
return prepared, nil
|
||||||
}
|
}
|
||||||
@@ -119,20 +117,20 @@ func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
|
|||||||
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
|
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p preparedReport) renderInputs() (briefing.Metadata, module.Snapshot, ReportFacts, error) {
|
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
|
||||||
metadata, err := clonePreparedValue(p.briefingMetadata)
|
identity, err := clonePreparedValue(p.identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return briefing.Metadata{}, module.Snapshot{}, ReportFacts{}, err
|
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||||
}
|
}
|
||||||
snapshot, err := clonePreparedValue(p.moduleSnapshot)
|
snapshot, err := clonePreparedValue(p.moduleSnapshot)
|
||||||
if err != nil {
|
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 {
|
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) {
|
func clonePreparedValue[T any](value T) (T, error) {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"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"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +24,7 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
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)
|
prepared, err := prepareReport(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("prepareReport() error = %v", err)
|
t.Fatalf("prepareReport() error = %v", err)
|
||||||
@@ -29,20 +33,21 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second prepareReport() error = %v", err)
|
t.Fatalf("second prepareReport() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.briefingMetadata, repeated.briefingMetadata) {
|
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) {
|
||||||
t.Fatalf("prepared package/metadata are not deterministic: %q/%#v", prepared.dataPackage, prepared.briefingMetadata)
|
t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
|
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
|
||||||
originalMetadata := prepared.briefingMetadata
|
originalIdentity := prepared.identity
|
||||||
|
originalDerived := prepared.derived
|
||||||
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
|
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
|
||||||
metadata, snapshot, reportFacts, err := prepared.renderInputs()
|
identity, snapshot, derived, err := prepared.renderInputs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("renderInputs() error = %v", err)
|
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
|
snapshot.Outputs = nil
|
||||||
reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation"
|
derived.PrecipTiming.ThunderMentioned = false
|
||||||
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
|
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
|
||||||
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
|
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
|
||||||
if len(bundle.Sources) > 0 {
|
if len(bundle.Sources) > 0 {
|
||||||
@@ -52,13 +57,65 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
|||||||
bundle.Sources[0].Query["mutated"] = "true"
|
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)
|
t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
|
||||||
}
|
}
|
||||||
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" {
|
if len(prepared.moduleSnapshot.Outputs) == 0 {
|
||||||
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 {
|
|
||||||
t.Fatal("prepared report values retain consumer mutation")
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
)
|
)
|
||||||
@@ -48,10 +50,22 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
|||||||
if req.Executor == nil {
|
if req.Executor == nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", 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
|
callbackFailed := false
|
||||||
preparationCallback := func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
preparationCount := 0
|
||||||
outcome.ProfileID, outcome.BackendID, outcome.ModelName = preparation.ProfileID, preparation.BackendID, preparation.ModelName
|
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() {
|
if req.DebugWriter == nil || !req.DebugWriter.Enabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -59,7 +73,7 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
|||||||
callbackFailed = true
|
callbackFailed = true
|
||||||
return promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))
|
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 {
|
if err != nil {
|
||||||
callbackFailed = true
|
callbackFailed = true
|
||||||
return promptDebugWriteError(err)
|
return promptDebugWriteError(err)
|
||||||
@@ -85,8 +99,16 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
|
|||||||
if execution == nil {
|
if execution == nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.Generation, "prompt executor returned no 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
|
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.DebugWriter != nil && req.DebugWriter.Enabled() {
|
||||||
if req.DebugRef == nil {
|
if req.DebugRef == nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))}
|
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)}
|
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 {
|
if err != nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
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 {
|
if err != nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err}
|
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 {
|
if err != nil {
|
||||||
return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
|
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
|
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"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
"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/promptdebug"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
)
|
)
|
||||||
@@ -35,6 +37,9 @@ func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
|
|||||||
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
|
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
|
||||||
prepared, inspection := preparedDailyProfile(t)
|
prepared, inspection := preparedDailyProfile(t)
|
||||||
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
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) {
|
func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := generationConfig()
|
cfg := generationConfig()
|
||||||
@@ -62,9 +168,9 @@ func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult)
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
bundle := generationBundle(t)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("prepareReport() error = %v", err)
|
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 {
|
if result == nil {
|
||||||
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
|
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 {
|
if err != nil {
|
||||||
return result, generatedPreparationError(req.Resolved, result.RunID, err)
|
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 {
|
if err := publicationContextError(ctx); err != nil {
|
||||||
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", err)
|
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
|
return req.Result, err
|
||||||
}
|
}
|
||||||
req.Result.OutputPath = req.OutputPath
|
req.Result.OutputPath = req.OutputPath
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"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/promptexec"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,7 @@ type PromptInspectionResult struct {
|
|||||||
ProfileID string
|
ProfileID string
|
||||||
BackendID string
|
BackendID string
|
||||||
ModelName string
|
ModelName string
|
||||||
|
handler generatedtext.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
||||||
@@ -56,6 +58,7 @@ type ComparisonInspectionResult struct {
|
|||||||
PromptVersion string
|
PromptVersion string
|
||||||
PromptHash string
|
PromptHash string
|
||||||
Profiles []ComparisonProfileInspection
|
Profiles []ComparisonProfileInspection
|
||||||
|
handler generatedtext.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComparisonProfileInspection contains one requested profile's safe effective
|
// ComparisonProfileInspection contains one requested profile's safe effective
|
||||||
@@ -91,6 +94,10 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
|||||||
profiles := map[string]promptexec.ProfileInspection{}
|
profiles := map[string]promptexec.ProfileInspection{}
|
||||||
for _, resolved := range req.Resolved {
|
for _, resolved := range req.Resolved {
|
||||||
definition := resolved.Definition
|
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)
|
inspection, err := inspectPromptContract(ctx, req.Executor, definition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -113,6 +120,7 @@ func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspection
|
|||||||
results[definition.ID] = PromptInspectionResult{
|
results[definition.ID] = PromptInspectionResult{
|
||||||
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
||||||
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||||
|
handler: handler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results, nil
|
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)
|
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)
|
inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
|
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
|
||||||
@@ -139,6 +151,7 @@ func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionReq
|
|||||||
PromptVersion: inspection.PromptVersion,
|
PromptVersion: inspection.PromptVersion,
|
||||||
PromptHash: inspection.PromptHash,
|
PromptHash: inspection.PromptHash,
|
||||||
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
|
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
|
||||||
|
handler: handler,
|
||||||
}
|
}
|
||||||
for _, profileID := range req.ProfileIDs {
|
for _, profileID := range req.ProfileIDs {
|
||||||
profile, err := inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
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 {
|
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)
|
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) {
|
if !validPromptInput(inspection.Inputs) {
|
||||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
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)
|
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
|
return profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.
|
|||||||
}(),
|
}(),
|
||||||
wantCategory: promptexec.InvalidConfiguration,
|
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",
|
name: "direct key",
|
||||||
prompt: basePrompt,
|
prompt: basePrompt,
|
||||||
@@ -110,9 +125,7 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
|||||||
|
|
||||||
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||||
first := inspectionResolved(t)
|
first := inspectionResolved(t)
|
||||||
second := first
|
second := inspectionResolvedFor(t, report.Today)
|
||||||
second.Definition.ID = report.Today
|
|
||||||
second.Definition.PromptID = "weather.today"
|
|
||||||
executor := &inspectionExecutor{
|
executor := &inspectionExecutor{
|
||||||
prompt: validPromptInspection(first.Definition),
|
prompt: validPromptInspection(first.Definition),
|
||||||
profiles: map[string]promptexec.ProfileInspection{
|
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) {
|
func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) {
|
||||||
resolved := inspectionResolved(t)
|
resolved := inspectionResolved(t)
|
||||||
executor := &inspectionExecutor{
|
executor := &inspectionExecutor{
|
||||||
@@ -265,12 +339,16 @@ func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inspectionResolved(t *testing.T) report.Resolved {
|
func inspectionResolved(t *testing.T) report.Resolved {
|
||||||
|
return inspectionResolvedFor(t, report.Daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
|
||||||
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
if id == report.Daily {
|
||||||
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
|
||||||
Location: time.UTC,
|
}
|
||||||
})
|
resolved, err := report.DefaultRegistry().Resolve(id, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
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
|
override, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: writeProfileFile(t, `id: weather-light
|
||||||
endpoint: https://local.example/v1
|
endpoint: https://local.example/v1
|
||||||
|
backend: openrouter
|
||||||
model: local-weather
|
model: local-weather
|
||||||
`)})
|
`)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New(override) error = %v", err)
|
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 {
|
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": {
|
"categorical:TSTM": {
|
||||||
"plain_language": "General or non-severe thunderstorms.",
|
"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"
|
"relative_level": "0 of 5"
|
||||||
},
|
},
|
||||||
"categorical:MRGL": {
|
"categorical:MRGL": {
|
||||||
"plain_language": "Isolated severe storms possible.",
|
"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"
|
"relative_level": "1 of 5"
|
||||||
},
|
},
|
||||||
"categorical:SLGT": {
|
"categorical:SLGT": {
|
||||||
"plain_language": "Scattered severe storms possible.",
|
"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"
|
"relative_level": "2 of 5"
|
||||||
},
|
},
|
||||||
"categorical:ENH": {
|
"categorical:ENH": {
|
||||||
"plain_language": "Numerous severe storms possible.",
|
"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"
|
"relative_level": "3 of 5"
|
||||||
},
|
},
|
||||||
"categorical:MDT": {
|
"categorical:MDT": {
|
||||||
"plain_language": "Widespread severe storms likely.",
|
"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"
|
"relative_level": "4 of 5"
|
||||||
},
|
},
|
||||||
"categorical:HIGH": {
|
"categorical:HIGH": {
|
||||||
"plain_language": "Major severe outbreak expected.",
|
"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"
|
"relative_level": "5 of 5"
|
||||||
},
|
},
|
||||||
"tornado:CIG1": {
|
"tornado:CIG1": {
|
||||||
@@ -70,3 +88,4 @@
|
|||||||
"relative_level": "2 of 2"
|
"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-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||||
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
{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 {
|
if len(value) != 3 {
|
||||||
t.Fatalf("periods length = %d, want 3", len(value))
|
t.Fatalf("periods length = %d, want 3", len(value))
|
||||||
}
|
}
|
||||||
@@ -462,9 +462,17 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
options any
|
||||||
|
}{
|
||||||
|
{name: "value", options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
|
{name: "pointer", options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||||
ID: module.AreaForecastDiscussion,
|
ID: module.AreaForecastDiscussion,
|
||||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
Options: tt.options,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
@@ -476,6 +484,37 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeatherStoryModuleOmitsEmptyContent(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{OfficeID: "LSX", Priority: true, Order: 1}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want omitted weather story", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeatherStoryModulePreservesZeroPriorityAndOrder(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{Title: "Rain Chances"}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
story := moduleValue[WeatherStoryModule](t, output)
|
||||||
|
if !story.Available || story.Priority || story.Order != 0 {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want available story with zero priority and order", story)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
||||||
@@ -561,7 +600,7 @@ func testModuleContext() ModuleContext {
|
|||||||
hourlyHumidity := 66.0
|
hourlyHumidity := 66.0
|
||||||
hourlyWindMph := 14.0
|
hourlyWindMph := 14.0
|
||||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
return ModuleContext{
|
ctx := ModuleContext{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Collected: facts.CollectedFacts{
|
Collected: facts.CollectedFacts{
|
||||||
Current: &weatherdata.Current{
|
Current: &weatherdata.Current{
|
||||||
@@ -689,6 +728,14 @@ func testModuleContext() ModuleContext {
|
|||||||
Timezone: "America/Chicago",
|
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 {
|
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"`
|
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
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"`
|
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
Hazards []string `json:"hazards,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
|||||||
} else {
|
} else {
|
||||||
value.LowTempF = roundedInt(temperature.Min)
|
value.LowTempF = roundedInt(temperature.Min)
|
||||||
}
|
}
|
||||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
|
||||||
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||||
if narrativePrecipitation != nil {
|
if narrativePrecipitation != nil {
|
||||||
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -80,6 +79,9 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||||
key := daypartKey(daypart, prefixDates)
|
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)
|
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
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)
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
DisplayName: capitalizeFirst(strings.TrimSpace(daypart.Name)),
|
||||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
@@ -148,7 +150,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
DominantCondition: daypart.DominantCondition,
|
||||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
Snow: daypart.Indicators.Snow,
|
Snow: daypart.Indicators.Snow,
|
||||||
Ice: daypart.Indicators.Ice,
|
Ice: daypart.Indicators.Ice,
|
||||||
@@ -162,7 +164,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
value.MaxPopTimeLabel = hourMinuteLabel(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 {
|
if daypart.PeakWindGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
@@ -301,18 +303,8 @@ func celsiusToFahrenheit(value float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func temperatureBandIndex(value int) int {
|
func temperatureBandIndex(value int) int {
|
||||||
decade := (value / 10) * 10
|
decade, remainder := temperatureBandParts(value)
|
||||||
remainder := value - decade
|
band := temperatureBandQualifierIndex(remainder)
|
||||||
if remainder < 0 {
|
|
||||||
remainder = -remainder
|
|
||||||
}
|
|
||||||
band := 1
|
|
||||||
switch {
|
|
||||||
case remainder <= 3:
|
|
||||||
band = 0
|
|
||||||
case remainder >= 7:
|
|
||||||
band = 2
|
|
||||||
}
|
|
||||||
return decade*3 + band
|
return decade*3 + band
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,29 +340,47 @@ func temperaturePhraseF(value forecast.Range) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func temperatureBandPhrase(value int) string {
|
func temperatureBandPhrase(value int) string {
|
||||||
decade := (value / 10) * 10
|
if value < 0 {
|
||||||
remainder := value - decade
|
decade, remainder := temperatureBandParts(-value)
|
||||||
if remainder < 0 {
|
qualifier := temperatureBandQualifier(remainder)
|
||||||
remainder = -remainder
|
if decade == 0 {
|
||||||
|
return fmt.Sprintf("%s single digits below zero", qualifier)
|
||||||
}
|
}
|
||||||
qualifier := "mid"
|
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
|
||||||
switch {
|
|
||||||
case remainder <= 3:
|
|
||||||
qualifier = "low"
|
|
||||||
case remainder >= 7:
|
|
||||||
qualifier = "upper"
|
|
||||||
}
|
}
|
||||||
|
decade, remainder := temperatureBandParts(value)
|
||||||
|
qualifier := temperatureBandQualifier(remainder)
|
||||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sentenceCase(value string) string {
|
func temperatureBandParts(value int) (int, int) {
|
||||||
trimmed := strings.TrimSpace(value)
|
decade := value / 10
|
||||||
if trimmed == "" {
|
if value < 0 && value%10 != 0 {
|
||||||
return ""
|
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 {
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
@@ -382,7 +392,7 @@ func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||||
key := normalizedKey(daypart.Name)
|
key := forecast.CanonicalDaypartKey(daypart.Name)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = "unnamed"
|
key = "unnamed"
|
||||||
}
|
}
|
||||||
@@ -391,21 +401,3 @@ func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
|||||||
}
|
}
|
||||||
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
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 {
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
}
|
}
|
||||||
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
|
||||||
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal daily summary: %v", err)
|
t.Fatalf("marshal daily summary: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
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) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daily json = %s, want field %s", 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"} {
|
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
||||||
if strings.Contains(jsonText, removed) {
|
if strings.Contains(jsonText, removed) {
|
||||||
t.Fatalf("daily json = %s, want removed field %s omitted", 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) {
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Daily)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
@@ -96,7 +147,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||||
}
|
}
|
||||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
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)
|
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||||
}
|
}
|
||||||
if len(rainy.PrecipitationWindows) != 2 {
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
@@ -215,7 +266,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
value := precipTimingValue(forecast.PrecipTiming{
|
value := precipTimingValue(forecast.PrecipTiming{
|
||||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
ProbabilityThreshold: 40,
|
||||||
PrecipitationWindows: []forecast.PrecipitationWindow{
|
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||||
{
|
{
|
||||||
Start: now,
|
Start: now,
|
||||||
@@ -223,7 +274,7 @@ func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
|||||||
Value: tt.maxPop,
|
Value: tt.maxPop,
|
||||||
Time: now,
|
Time: now,
|
||||||
},
|
},
|
||||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
ProbabilityThreshold: 40,
|
||||||
TextDescriptions: tt.descriptions,
|
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) {
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Daily)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
@@ -390,6 +487,20 @@ func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
|||||||
wantTrend: "steady",
|
wantTrend: "steady",
|
||||||
wantSteady: "upper 70s",
|
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 {
|
for _, test := range tests {
|
||||||
@@ -460,6 +571,20 @@ func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
|||||||
wantTrend: "steady",
|
wantTrend: "steady",
|
||||||
wantSteady: "upper 70s",
|
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 {
|
for _, test := range tests {
|
||||||
@@ -506,6 +631,11 @@ func TestTemperaturePhraseF(t *testing.T) {
|
|||||||
value: forecast.Range{Max: floatPtr(84)},
|
value: forecast.Range{Max: floatPtr(84)},
|
||||||
want: "mid 80s",
|
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",
|
name: "empty",
|
||||||
value: forecast.Range{},
|
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) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Tomorrow)
|
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"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
const hourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||||
|
|
||||||
type HourlyForecastModule struct {
|
type HourlyForecastModule struct {
|
||||||
Product string `json:"product,omitempty"`
|
Product string `json:"product,omitempty"`
|
||||||
@@ -184,7 +184,7 @@ func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
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 {
|
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) {
|
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
metadata := ctx.Resolved.Metadata()
|
|
||||||
value := MetadataModule{
|
value := MetadataModule{
|
||||||
RunID: metadata.RunID,
|
RunID: ctx.Identity.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: ctx.Identity.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: ctx.Identity.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: ctx.Identity.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: ctx.Identity.GeneratedAt,
|
||||||
Units: ctx.Units,
|
Units: ctx.Identity.Units,
|
||||||
Timezone: ctx.Timezone,
|
Timezone: ctx.Identity.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: ctx.Identity.ValidPeriod,
|
||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Identity.Location),
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
SourceWarnings: sourceWarningSummaries(ctx.Identity.SourceWarnings),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ModuleContext struct {
|
type ModuleContext struct {
|
||||||
|
Identity PreparedIdentity
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Collected facts.CollectedFacts
|
Collected facts.CollectedFacts
|
||||||
Derived facts.DerivedFacts
|
Derived facts.DerivedFacts
|
||||||
@@ -27,8 +28,8 @@ type ModuleDefinition struct {
|
|||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
DefaultOptions any
|
DefaultOptions any
|
||||||
RequiredCollected []module.FactRequirement
|
RequiredCollected []*factRequirement
|
||||||
RequiredDerived []module.FactRequirement
|
RequiredDerived []*factRequirement
|
||||||
SupportedReports []report.ID
|
SupportedReports []report.ID
|
||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
@@ -75,6 +76,9 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
|||||||
if definition.MissingData == module.MissingDataWarn {
|
if definition.MissingData == module.MissingDataWarn {
|
||||||
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
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 {
|
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)
|
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) {
|
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)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if definition.Builder == nil {
|
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)
|
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
options := item.Options
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
options = definition.DefaultOptions
|
options = definition.DefaultOptions
|
||||||
}
|
}
|
||||||
@@ -152,60 +156,48 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||||
var missing []string
|
var missing []string
|
||||||
for _, requirement := range definition.RequiredCollected {
|
for _, requirement := range definition.RequiredCollected {
|
||||||
if !collectedFactAvailable(requirement, ctx) {
|
if !requirement.available(ctx) {
|
||||||
missing = append(missing, string(requirement))
|
missing = append(missing, requirement.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, requirement := range definition.RequiredDerived {
|
for _, requirement := range definition.RequiredDerived {
|
||||||
if !derivedFactAvailable(requirement, ctx) {
|
if !requirement.available(ctx) {
|
||||||
missing = append(missing, string(requirement))
|
missing = append(missing, requirement.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
func validateFactRequirements(definition ModuleDefinition) error {
|
||||||
switch requirement {
|
if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil {
|
||||||
case module.CollectedCurrentConditions:
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement)
|
||||||
}
|
}
|
||||||
|
|
||||||
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error {
|
||||||
switch requirement {
|
for _, requirement := range requirements {
|
||||||
case module.RequiresDerivedHourlyPeriods:
|
if requirement == nil {
|
||||||
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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)
|
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 {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
||||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
||||||
@@ -272,7 +282,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
StanzaName: "metadata",
|
StanzaName: "metadata",
|
||||||
DefaultOptions: module.MetadataOptions{},
|
DefaultOptions: module.MetadataOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
RequiredCollected: []*factRequirement{sourceMetadataRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildMetadataModule,
|
Builder: buildMetadataModule,
|
||||||
@@ -281,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.CurrentConditions,
|
ID: module.CurrentConditions,
|
||||||
StanzaName: "current_conditions",
|
StanzaName: "current_conditions",
|
||||||
DefaultOptions: module.CurrentConditionsOptions{},
|
DefaultOptions: module.CurrentConditionsOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
RequiredCollected: []*factRequirement{currentConditionsRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
@@ -291,8 +301,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.NarrativeForecast,
|
ID: module.NarrativeForecast,
|
||||||
StanzaName: "narrative_forecast",
|
StanzaName: "narrative_forecast",
|
||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []*factRequirement{narrativeForecastRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []*factRequirement{narrativePeriodsRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
@@ -301,8 +311,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.HourlyForecast,
|
ID: module.HourlyForecast,
|
||||||
StanzaName: "hourly_forecast",
|
StanzaName: "hourly_forecast",
|
||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []*factRequirement{hourlyForecastRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []*factRequirement{hourlyPeriodsRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
@@ -312,7 +322,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement, precipTimingRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
@@ -321,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.DerivedDaypartSummaries,
|
ID: module.DerivedDaypartSummaries,
|
||||||
StanzaName: "derived_daypart_summaries",
|
StanzaName: "derived_daypart_summaries",
|
||||||
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
|
||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
@@ -331,7 +341,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
StanzaName: "precip_timing",
|
StanzaName: "precip_timing",
|
||||||
DefaultOptions: module.PrecipTimingOptions{},
|
DefaultOptions: module.PrecipTimingOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []*factRequirement{precipTimingRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildPrecipTimingModule,
|
Builder: buildPrecipTimingModule,
|
||||||
@@ -340,8 +350,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.AlertDigest,
|
ID: module.AlertDigest,
|
||||||
StanzaName: "alert_digest",
|
StanzaName: "alert_digest",
|
||||||
DefaultOptions: module.AlertDigestOptions{},
|
DefaultOptions: module.AlertDigestOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
RequiredCollected: []*factRequirement{alertsRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
RequiredDerived: []*factRequirement{alertOverlapsRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildAlertDigestModule,
|
Builder: buildAlertDigestModule,
|
||||||
@@ -350,8 +360,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.SPCConvectiveOutlooks,
|
ID: module.SPCConvectiveOutlooks,
|
||||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildSPCConvectiveOutlooksModule,
|
Builder: buildSPCConvectiveOutlooksModule,
|
||||||
@@ -360,7 +370,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.AreaForecastDiscussion,
|
ID: module.AreaForecastDiscussion,
|
||||||
StanzaName: "area_forecast_discussion",
|
StanzaName: "area_forecast_discussion",
|
||||||
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
RequiredCollected: []*factRequirement{discussionRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildAreaForecastDiscussionModule,
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
@@ -369,8 +379,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.SPCConvectiveDiscussion,
|
ID: module.SPCConvectiveDiscussion,
|
||||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
RequiredCollected: []*factRequirement{spcOutlooksRequirement},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildSPCConvectiveDiscussionModule,
|
Builder: buildSPCConvectiveDiscussionModule,
|
||||||
@@ -379,7 +389,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.WeatherStory,
|
ID: module.WeatherStory,
|
||||||
StanzaName: "weather_story",
|
StanzaName: "weather_story",
|
||||||
DefaultOptions: module.WeatherStoryOptions{},
|
DefaultOptions: module.WeatherStoryOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
RequiredCollected: []*factRequirement{weatherStoryRequirement},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildWeatherStoryModule,
|
Builder: buildWeatherStoryModule,
|
||||||
@@ -388,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.OutdoorWindows,
|
ID: module.OutdoorWindows,
|
||||||
StanzaName: "outdoor_windows",
|
StanzaName: "outdoor_windows",
|
||||||
DefaultOptions: module.OutdoorWindowsOptions{},
|
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []*factRequirement{daypartSummariesRequirement},
|
||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildOutdoorWindowsModule,
|
Builder: buildOutdoorWindowsModule,
|
||||||
@@ -397,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.TodayPlanning,
|
ID: module.TodayPlanning,
|
||||||
StanzaName: "today_planning",
|
StanzaName: "today_planning",
|
||||||
DefaultOptions: module.TodayPlanningOptions{},
|
DefaultOptions: module.TodayPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||||
SupportedReports: []report.ID{report.Today},
|
SupportedReports: []report.ID{report.Today},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTodayPlanningModule,
|
Builder: buildTodayPlanningModule,
|
||||||
@@ -406,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.TomorrowPlanning,
|
ID: module.TomorrowPlanning,
|
||||||
StanzaName: "tomorrow_planning",
|
StanzaName: "tomorrow_planning",
|
||||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||||
SupportedReports: []report.ID{report.Tomorrow},
|
SupportedReports: []report.ID{report.Tomorrow},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
@@ -415,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.DailyPlanning,
|
ID: module.DailyPlanning,
|
||||||
StanzaName: "daily_planning",
|
StanzaName: "daily_planning",
|
||||||
DefaultOptions: module.DailyPlanningOptions{},
|
DefaultOptions: module.DailyPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []*factRequirement{dailySummariesRequirement},
|
||||||
SupportedReports: []report.ID{report.Daily},
|
SupportedReports: []report.ID{report.Daily},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildDailyPlanningModule,
|
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) {
|
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
for _, definition := range report.DefaultRegistry().All() {
|
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) {
|
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
{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{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
|
{ID: module.AreaForecastDiscussion, Options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateComposition() error = %v", err)
|
t.Fatalf("ValidateComposition() error = %v", err)
|
||||||
@@ -466,25 +562,25 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
|
|
||||||
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||||
ctx := ModuleContext{}
|
ctx := ModuleContext{}
|
||||||
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
if spcOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("collectedFactAvailable() = true, want false without source")
|
t.Fatal("SPC outlook requirement is available, want false without source")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||||
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
if !spcOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
t.Fatal("SPC outlook requirement is unavailable, want true with checked source")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||||
ctx := ModuleContext{}
|
ctx := ModuleContext{}
|
||||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
if spcDerivedOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
t.Fatal("derived SPC outlook requirement is available, want false without derived outlooks")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
if !spcDerivedOutlooksRequirement.available(ctx) {
|
||||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
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"
|
"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"`
|
RunID string `json:"runId"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
@@ -21,9 +23,7 @@ type Metadata struct {
|
|||||||
Location *LocationContext `json:"location,omitempty"`
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
|
||||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationContext struct {
|
type LocationContext struct {
|
||||||
@@ -33,24 +33,6 @@ type LocationContext struct {
|
|||||||
Timezone string `json:"timezone,omitempty"`
|
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 {
|
type BuildContext struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Bundle *weatherdata.Bundle
|
Bundle *weatherdata.Bundle
|
||||||
@@ -59,10 +41,10 @@ type BuildContext struct {
|
|||||||
Location *LocationContext
|
Location *LocationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMetadata(ctx BuildContext) Metadata {
|
func BuildPreparedIdentity(ctx BuildContext) PreparedIdentity {
|
||||||
metadata := ctx.Resolved.Metadata()
|
metadata := ctx.Resolved.Metadata()
|
||||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||||
return Metadata{
|
return PreparedIdentity{
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: variantForReport(metadata.ReportID),
|
||||||
@@ -74,9 +56,7 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Location),
|
||||||
SourceLocationID: sourceLocationID,
|
SourceLocationID: sourceLocationID,
|
||||||
SourceLocation: sourceLocation,
|
SourceLocation: sourceLocation,
|
||||||
Sources: sourceMetadata(ctx.Bundle),
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), sourceWarnings(ctx.Bundle)...),
|
||||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
|
||||||
Alerts: alertStatus(ctx.Bundle),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,26 +115,6 @@ func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
|||||||
return "", ""
|
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 {
|
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -162,27 +122,6 @@ func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
|||||||
return bundle.Warnings
|
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 {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.Daily, report.Today:
|
case report.Daily, report.Today:
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType s
|
|||||||
case maxPopPercent >= precipTimingExpectLowerBound:
|
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||||
return fmt.Sprintf("Expect %s.", precipitationType)
|
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||||
case maxPopPercent >= precipTimingLikelyLowerBound:
|
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||||
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
return fmt.Sprintf("%s likely.", capitalizeFirst(strings.TrimSpace(precipitationType)))
|
||||||
case maxPopPercent >= precipTimingChanceLowerBound:
|
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||||
return fmt.Sprintf("Chance of %s.", precipitationType)
|
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||||
default:
|
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 {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
||||||
}
|
}
|
||||||
var definitions map[string]SPCOutlookBackgroundDefinition
|
var asset spcOutlookBackgroundDefinitionAsset
|
||||||
if err := json.Unmarshal(data, &definitions); err != nil {
|
if err := json.Unmarshal(data, &asset); err != nil {
|
||||||
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
|
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 {
|
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" {
|
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||||
}
|
}
|
||||||
if got.BackgroundDefinition == nil ||
|
wantBackground := spcOutlookBackgroundDefinition("categorical", "SLGT")
|
||||||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
|
if got.BackgroundDefinition == nil || wantBackground == nil || *got.BackgroundDefinition != *wantBackground {
|
||||||
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." ||
|
t.Fatalf("background definition = %#v, want matching Slight Risk helper %#v", got.BackgroundDefinition, wantBackground)
|
||||||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
|
|
||||||
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
|
|
||||||
}
|
}
|
||||||
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" {
|
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)
|
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||||
@@ -193,12 +191,33 @@ func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
func TestSPCOutlookBackgroundDefinitionAssetRecordsProvenance(t *testing.T) {
|
||||||
if defaultSPCRiskDigestOutlookType != "categorical" {
|
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
|
||||||
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
|
var asset spcOutlookBackgroundDefinitionAsset
|
||||||
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
|
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"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -41,6 +42,15 @@ type TodayPlanning struct {
|
|||||||
LateDayChangeWatch []string
|
LateDayChangeWatch []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outdoorIndicatorRiskScore = 25
|
||||||
|
|
||||||
|
const (
|
||||||
|
morningDaypartIdentity = "morning"
|
||||||
|
afternoonDaypartIdentity = "afternoon"
|
||||||
|
eveningDaypartIdentity = "evening"
|
||||||
|
overnightDaypartIdentity = "overnight"
|
||||||
|
)
|
||||||
|
|
||||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||||
var best *OutdoorWindow
|
var best *OutdoorWindow
|
||||||
var worst *OutdoorWindow
|
var worst *OutdoorWindow
|
||||||
@@ -72,7 +82,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, daypart := range summary.Dayparts {
|
for _, daypart := range summary.Dayparts {
|
||||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
if isOutsideWorkday(daypart) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
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.")
|
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)
|
daypart := daypartNamed(summary.Dayparts, name)
|
||||||
if daypart != nil {
|
if daypart != nil {
|
||||||
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||||
@@ -124,7 +134,7 @@ func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morni
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, daypart := range summary.Dayparts {
|
for _, daypart := range summary.Dayparts {
|
||||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
if isOutsideWorkday(daypart) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||||
@@ -153,10 +163,10 @@ func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
|||||||
windows := buildOutdoorWindows(dayparts)
|
windows := buildOutdoorWindows(dayparts)
|
||||||
var notes []string
|
var notes []string
|
||||||
if windows.Best != nil {
|
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) {
|
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...)
|
return appendUnique(nil, notes...)
|
||||||
}
|
}
|
||||||
@@ -183,7 +193,7 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
|
|
||||||
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
prefix := titleWord(daypart.Name)
|
prefix := capitalizeFirst(daypart.Name)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = "Late-day"
|
prefix = "Late-day"
|
||||||
}
|
}
|
||||||
@@ -204,7 +214,7 @@ func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
|
|
||||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
prefix := titleWord(daypart.Name)
|
prefix := capitalizeFirst(daypart.Name)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = "Daytime"
|
prefix = "Daytime"
|
||||||
}
|
}
|
||||||
@@ -247,8 +257,9 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
|
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
|
||||||
|
identity := forecast.CanonicalDaypartKey(name)
|
||||||
for i := range dayparts {
|
for i := range dayparts {
|
||||||
if strings.EqualFold(dayparts[i].Name, name) {
|
if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity {
|
||||||
return &dayparts[i]
|
return &dayparts[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +286,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
reasons = append(reasons, "alert overlap")
|
reasons = append(reasons, "alert overlap")
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||||
score += 25
|
score += outdoorIndicatorRiskScore
|
||||||
if daypart.Indicators.Heat {
|
if daypart.Indicators.Heat {
|
||||||
reasons = append(reasons, "heat risk")
|
reasons = append(reasons, "heat risk")
|
||||||
}
|
}
|
||||||
@@ -283,6 +294,19 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
reasons = append(reasons, "cold risk")
|
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 {
|
if len(reasons) == 0 {
|
||||||
reasons = append(reasons, "quiet weather")
|
reasons = append(reasons, "quiet weather")
|
||||||
}
|
}
|
||||||
@@ -382,9 +406,20 @@ func appendUnique(values []string, candidates ...string) []string {
|
|||||||
return values
|
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 == "" {
|
if value == "" {
|
||||||
return ""
|
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) {
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
story := ctx.Collected.WeatherStory
|
story := ctx.Collected.WeatherStory
|
||||||
if story == nil {
|
if story == nil || !story.HasUsableContent() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
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) {
|
func TestResolveComparisonActionLeavesConfiguredOutputWithoutOverride(t *testing.T) {
|
||||||
workingDir := t.TempDir()
|
workingDir := t.TempDir()
|
||||||
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured/../reports\n")
|
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) {
|
func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
|
||||||
workingDir := t.TempDir()
|
workingDir := t.TempDir()
|
||||||
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
|
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: 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")},
|
{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")
|
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 := comparisonRunner(t, workingDir)
|
||||||
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
|
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
|
||||||
return result, cleanupErr
|
return result, cleanupErr
|
||||||
@@ -240,10 +244,10 @@ func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
|
|||||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||||
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
|
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)
|
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) {
|
if strings.Contains(stdout.String(), unsafe) {
|
||||||
t.Fatalf("summary contains unsafe recovery detail %q: %s", unsafe, stdout.String())
|
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) {
|
func TestCompareHelpIncludesCommand(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
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)
|
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)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
||||||
continue
|
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)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
||||||
}
|
}
|
||||||
if result.Notification != nil {
|
if result.Notification != nil {
|
||||||
@@ -58,5 +62,5 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
|||||||
}
|
}
|
||||||
_, _ = fmt.Fprintln(stderr)
|
_, _ = 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"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
|
Canceled int `json:"canceled,omitempty"`
|
||||||
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||||
Reports []app.BatchReportResult `json:"reports"`
|
Reports []app.BatchReportResult `json:"reports"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -161,7 +162,7 @@ func newGenerateNotificationSummary(result *app.NotificationResult) *generateNot
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBatchSummary(result *app.BatchResult) batchSummary {
|
func newBatchSummary(result *app.BatchResult, err error) batchSummary {
|
||||||
summary := batchSummary{Command: commandRun}
|
summary := batchSummary{Command: commandRun}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return summary
|
return summary
|
||||||
@@ -174,10 +175,11 @@ func newBatchSummary(result *app.BatchResult) batchSummary {
|
|||||||
summary.Total = result.Total
|
summary.Total = result.Total
|
||||||
summary.Succeeded = result.Succeeded
|
summary.Succeeded = result.Succeeded
|
||||||
summary.Failed = result.Failed
|
summary.Failed = result.Failed
|
||||||
|
summary.Canceled = result.Canceled
|
||||||
summary.Notification = result.Notification
|
summary.Notification = result.Notification
|
||||||
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||||
if summary.Status == summaryStatusFailed {
|
if summary.Status == summaryStatusFailed {
|
||||||
summary.Error = app.BatchError{Result: result}.Error()
|
summary.Error = app.BatchError{Result: result, Cause: err}.Error()
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
@@ -223,7 +225,7 @@ func safeComparisonSummaryError(err error) *comparison.SafeError {
|
|||||||
}
|
}
|
||||||
var cleanupErr *comparison.PublicationCleanupError
|
var cleanupErr *comparison.PublicationCleanupError
|
||||||
if errors.As(err, &cleanupErr) {
|
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
|
return &safe
|
||||||
}
|
}
|
||||||
var destinationErr *comparison.DestinationError
|
var destinationErr *comparison.DestinationError
|
||||||
@@ -247,6 +249,19 @@ func safeComparisonSummaryError(err error) *comparison.SafeError {
|
|||||||
return &safe
|
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) {
|
func comparisonAggregateErrorMessage(err error) (string, bool) {
|
||||||
const prefix = "comparison completed with "
|
const prefix = "comparison completed with "
|
||||||
const suffix = " failed profiles"
|
const suffix = " failed profiles"
|
||||||
@@ -267,7 +282,7 @@ func batchSummaryStatus(result *app.BatchResult) string {
|
|||||||
if result == nil {
|
if result == nil {
|
||||||
return ""
|
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 summaryStatusFailed
|
||||||
}
|
}
|
||||||
return summaryStatusSucceeded
|
return summaryStatusSucceeded
|
||||||
|
|||||||
@@ -179,12 +179,36 @@ func TestSafeComparisonSummaryErrorClassifiesWrappedFailures(t *testing.T) {
|
|||||||
message: "comparison destination preflight failed",
|
message: "comparison destination preflight failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "publication cleanup",
|
name: "complete cleanup recovery",
|
||||||
err: fmt.Errorf("outer wrapper: %w", &comparison.PublicationCleanupError{
|
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",
|
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",
|
name: "unknown",
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ Options:
|
|||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write the generated Markdown report to PATH.
|
--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.
|
--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.
|
--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.
|
--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 {
|
type Runner struct {
|
||||||
@@ -48,6 +49,7 @@ type Runner struct {
|
|||||||
ExecutorFactory ExecutorFactory
|
ExecutorFactory ExecutorFactory
|
||||||
Version string
|
Version string
|
||||||
WorkingDir string
|
WorkingDir string
|
||||||
|
generateDetailed func(context.Context, app.GenerateRequest) (*app.ReportResult, error)
|
||||||
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
|
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
|
||||||
compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, 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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if result != nil {
|
||||||
summary := newGenerateSummary(result, err)
|
summary := newGenerateSummary(result, err)
|
||||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
|
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)
|
result, err := runBatchDetailed(ctx, req)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
summary := newBatchSummary(result)
|
summary := newBatchSummary(result, err)
|
||||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
||||||
writeBatchStatus(w, result)
|
writeBatchStatus(w, result)
|
||||||
}); encodeErr != nil {
|
}); encodeErr != nil {
|
||||||
return encodeErr
|
return encodeErr
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if summary.Status == summaryStatusFailed {
|
if summary.Status == summaryStatusFailed {
|
||||||
return app.BatchError{Result: result}
|
return app.BatchError{Result: result}
|
||||||
}
|
}
|
||||||
@@ -192,6 +201,9 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, commonOptions{}, err
|
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{
|
cfg, err := config.Load(config.LoadOptions{
|
||||||
Path: opts.ConfigPath,
|
Path: opts.ConfigPath,
|
||||||
Units: opts.Units,
|
Units: opts.Units,
|
||||||
@@ -200,11 +212,19 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, commonOptions{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
executor, err := r.promptExecutor(cfg.Promptkit)
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, commonOptions{}, err
|
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 {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, commonOptions{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
@@ -217,36 +237,12 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, commonOptions{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
|
req.WorkingDir, req.OutputPath = workingDir, outputPath
|
||||||
req := app.GenerateRequest{
|
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||||
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 {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, commonOptions{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
case app.ReportToday:
|
req.Executor = executor
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return req, opts.commonOptions, nil
|
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...),
|
Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...),
|
||||||
WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock,
|
WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock,
|
||||||
}
|
}
|
||||||
switch reportKind {
|
req.Date, err = resolveActionReportDate("compare", reportKind, opts.Date, location, r.Clock.Now())
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.ComparisonRequest{}, commonOptions{}, err
|
return app.ComparisonRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
@@ -373,10 +357,8 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
|||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
opts := generateOptions{}
|
opts := generateOptions{}
|
||||||
addCommonFlags(fs, &opts.commonOptions, true)
|
addCommonFlags(fs, &opts.commonOptions, true)
|
||||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||||
if report == app.ReportDaily || report == app.ReportToday {
|
addReportDateFlag(fs, report, &opts.Date)
|
||||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return generateOptions{}, err
|
return generateOptions{}, err
|
||||||
}
|
}
|
||||||
@@ -392,7 +374,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
|||||||
opts := commonOptions{}
|
opts := commonOptions{}
|
||||||
addCommonFlags(fs, &opts, false)
|
addCommonFlags(fs, &opts, false)
|
||||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
|
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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return commonOptions{}, err
|
return commonOptions{}, err
|
||||||
}
|
}
|
||||||
@@ -409,11 +391,9 @@ func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptio
|
|||||||
addCommonFlags(fs, &opts.commonOptions, false)
|
addCommonFlags(fs, &opts.commonOptions, false)
|
||||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory")
|
fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory")
|
||||||
fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle")
|
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")
|
fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID")
|
||||||
if report == app.ReportDaily || report == app.ReportToday {
|
addReportDateFlag(fs, report, &opts.Date)
|
||||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return comparisonOptions{}, err
|
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.ConfigPath, "config", "", "configuration file path")
|
||||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||||
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
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 {
|
if includeOutput {
|
||||||
fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
|
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) {
|
func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr)
|
err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr)
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunFetchesBundle(t *testing.T) {
|
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()
|
defer server.Close()
|
||||||
|
|
||||||
cfg := config.Defaults()
|
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" {
|
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)
|
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) {
|
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||||
@@ -50,7 +59,7 @@ func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunWrapsFetchError(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()
|
defer server.Close()
|
||||||
|
|
||||||
cfg := config.Defaults()
|
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()
|
t.Helper()
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||||
http.Error(w, "upstream failure", status)
|
http.Error(w, "upstream failure", status)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -283,7 +283,11 @@ func (manifest Manifest) Validate() error {
|
|||||||
if result.ValidationStatus != "passed" {
|
if result.ValidationStatus != "passed" {
|
||||||
return fmt.Errorf("successful result %d did not pass validation", result.Position)
|
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)
|
return fmt.Errorf("successful result %d has invalid report details", result.Position)
|
||||||
}
|
}
|
||||||
if _, duplicate := reportPaths[result.ReportPath]; duplicate {
|
if _, duplicate := reportPaths[result.ReportPath]; duplicate {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package comparison
|
package comparison
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -165,9 +164,9 @@ func TestManifestEncodingAndRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("EncodeManifest() =\n%s\nwant\n%s", encoded, want)
|
t.Fatalf("EncodeManifest() =\n%s\nwant\n%s", encoded, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
var decoded Manifest
|
decoded, err := decodeManifest(encoded)
|
||||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
if err != nil {
|
||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("decodeManifest() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := decoded.Validate(); err != nil {
|
if err := decoded.Validate(); err != nil {
|
||||||
t.Fatalf("decoded manifest validation error = %v", err)
|
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: "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: "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: "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) {
|
{name: "successful result with error", mutate: func(manifest *Manifest) {
|
||||||
manifest.Results[0].Error = &SafeError{Category: "application", Message: "bad"}
|
manifest.Results[0].Error = &SafeError{Category: "application", Message: "bad"}
|
||||||
}},
|
}},
|
||||||
@@ -247,6 +250,12 @@ func TestLogicalBundleValidate(t *testing.T) {
|
|||||||
if err := bundle.Validate(); err == nil {
|
if err := bundle.Validate(); err == nil {
|
||||||
t.Fatal("LogicalBundle.Validate() accepted unordered report position")
|
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) {
|
func TestSHA256AndTruncateErrorMessage(t *testing.T) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const (
|
|||||||
DestinationNotEmpty DestinationErrorKind = "not_empty"
|
DestinationNotEmpty DestinationErrorKind = "not_empty"
|
||||||
DestinationUnrecognized DestinationErrorKind = "unrecognized"
|
DestinationUnrecognized DestinationErrorKind = "unrecognized"
|
||||||
DestinationInspection DestinationErrorKind = "inspection"
|
DestinationInspection DestinationErrorKind = "inspection"
|
||||||
|
|
||||||
|
maxComparisonComponentBytes = 255
|
||||||
|
temporaryRandomBytes = 10
|
||||||
|
stagingDirectoryPattern = ".weatherreporter-staging-*"
|
||||||
|
backupDirectoryPattern = ".weatherreporter-backup-*"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DestinationError provides inspectable context without making filesystem
|
// 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.
|
// PlanDestination performs the read-only comparison destination preflight.
|
||||||
func PlanDestination(workingDirectory, target string, replace bool) (DestinationPlan, error) {
|
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)
|
workingDirectory, err := absoluteCleanPath(workingDirectory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DestinationPlan{}, newDestinationError(DestinationInvalidPath, workingDirectory, err)
|
return DestinationPlan{}, newDestinationError(DestinationInvalidPath, workingDirectory, err)
|
||||||
@@ -82,8 +99,23 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
|
|||||||
if target == workingDirectory {
|
if target == workingDirectory {
|
||||||
return DestinationPlan{}, newDestinationError(DestinationWorkingDirectory, target, nil)
|
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)
|
info, err := os.Lstat(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -109,16 +141,34 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
|
|||||||
plan.state = destinationEmpty
|
plan.state = destinationEmpty
|
||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
if !replace {
|
if !plan.Replace {
|
||||||
return DestinationPlan{}, newDestinationError(DestinationNotEmpty, target, nil)
|
return DestinationPlan{}, newDestinationError(DestinationNotEmpty, target, nil)
|
||||||
}
|
}
|
||||||
if _, err := RecognizeBundle(target); err != nil {
|
if recognize != nil {
|
||||||
|
if _, err := recognize(target); err != nil {
|
||||||
return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err)
|
return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
plan.state = destinationBundle
|
plan.state = destinationBundle
|
||||||
return plan, nil
|
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) {
|
func absoluteCleanPath(value string) (string, error) {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return "", fmt.Errorf("path is required")
|
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
|
// RecognizeBundle verifies that directory contains exactly one valid current
|
||||||
// comparison bundle. It never follows bundle entries through symlinks.
|
// comparison bundle. It never follows bundle entries through symlinks.
|
||||||
func RecognizeBundle(directory string) (Manifest, error) {
|
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 {
|
if err != nil {
|
||||||
return Manifest{}, unrecognizedBundleError("inspect directory", err)
|
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)
|
return Manifest{}, unrecognizedBundleError("directory is not a real directory", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(directory)
|
entries, err := operations.readDir(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Manifest{}, unrecognizedBundleError("read directory", err)
|
return Manifest{}, unrecognizedBundleError("read directory", err)
|
||||||
}
|
}
|
||||||
manifestData, err := readBundleFile(directory, ManifestFilename)
|
manifestData, err := readBundleFile(directory, ManifestFilename, operations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
@@ -204,12 +272,15 @@ func RecognizeBundle(directory string) (Manifest, error) {
|
|||||||
if _, ok := expected[entry.Name()]; !ok {
|
if _, ok := expected[entry.Name()]; !ok {
|
||||||
return Manifest{}, unrecognizedBundleError("directory has an undeclared entry", nil)
|
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
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dataPackage, err := readBundleFile(directory, DataPackageFilename)
|
dataPackage, err := readBundleFile(directory, DataPackageFilename, operations)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
@@ -219,26 +290,53 @@ func RecognizeBundle(directory string) (Manifest, error) {
|
|||||||
return manifest, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBundleFile(directory, name string) ([]byte, error) {
|
func readBundleFile(directory, name string, operations recognitionOperations) ([]byte, error) {
|
||||||
if !isArtifactBasename(name) {
|
file, err := openBundleFile(directory, name, operations)
|
||||||
return nil, unrecognizedBundleError("bundle entry name is unsafe", nil)
|
|
||||||
}
|
|
||||||
filePath := filepath.Join(directory, name)
|
|
||||||
info, err := os.Lstat(filePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, unrecognizedBundleError("inspect bundle entry", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
defer file.Close()
|
||||||
return nil, unrecognizedBundleError("bundle entry is not a regular file", nil)
|
data, err := io.ReadAll(file)
|
||||||
}
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, unrecognizedBundleError("read bundle entry", err)
|
return nil, unrecognizedBundleError("read bundle entry", err)
|
||||||
}
|
}
|
||||||
return data, nil
|
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) {
|
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 := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
var manifest Manifest
|
var manifest Manifest
|
||||||
@@ -255,6 +353,162 @@ func decodeManifest(data []byte) (Manifest, error) {
|
|||||||
return manifest, nil
|
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 {
|
func unrecognizedBundleError(action string, err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return fmt.Errorf("%w: %s", ErrUnrecognizedBundle, action)
|
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)
|
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.
|
// PublicationResult describes the durable state of a publication attempt.
|
||||||
type PublicationResult struct {
|
type PublicationResult struct {
|
||||||
Committed bool
|
Committed bool
|
||||||
RetainedBackupPath string
|
RecoveryState BackupRecoveryState
|
||||||
|
RecoveryPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicationCleanupError reports that a committed bundle could not remove its
|
// PublicationCleanupError reports that a committed bundle could not completely
|
||||||
// prior sibling backup. The new bundle remains installed and the backup path
|
// remove its prior sibling backup. The new bundle remains installed; recovery
|
||||||
// is retained for operator recovery.
|
// fields describe the state observed after cleanup failed.
|
||||||
type PublicationCleanupError struct {
|
type PublicationCleanupError struct {
|
||||||
RetainedBackupPath string
|
RecoveryState BackupRecoveryState
|
||||||
|
RecoveryPath string
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (err *PublicationCleanupError) Error() string {
|
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 {
|
func (err *PublicationCleanupError) Unwrap() error {
|
||||||
@@ -294,6 +570,7 @@ type publishOperations struct {
|
|||||||
rename func(string, string) error
|
rename func(string, string) error
|
||||||
removeAll func(string) error
|
removeAll func(string) error
|
||||||
beforeCommit func()
|
beforeCommit func()
|
||||||
|
recognize func(string) (Manifest, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, operations publishOperations) (PublicationResult, 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 {
|
if operations.removeAll == nil {
|
||||||
operations.removeAll = os.RemoveAll
|
operations.removeAll = os.RemoveAll
|
||||||
}
|
}
|
||||||
|
if operations.recognize == nil {
|
||||||
|
operations.recognize = RecognizeBundle
|
||||||
|
}
|
||||||
if err := bundle.Validate(); err != nil {
|
if err := bundle.Validate(); err != nil {
|
||||||
return PublicationResult{}, fmt.Errorf("validate comparison bundle: %w", err)
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return PublicationResult{}, err
|
return PublicationResult{}, err
|
||||||
}
|
}
|
||||||
plan, err = PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace)
|
plan, err = reauthorizeDestination(plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PublicationResult{}, err
|
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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
return PublicationResult{}, fmt.Errorf("create comparison staging directory: %w", err)
|
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
|
return PublicationResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPlan, err := PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace)
|
currentPlan, err := reauthorizeDestination(plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PublicationResult{}, err
|
return PublicationResult{}, err
|
||||||
}
|
}
|
||||||
@@ -357,14 +637,17 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
|||||||
return PublicationResult{Committed: true}, nil
|
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 {
|
if err != nil {
|
||||||
return PublicationResult{}, err
|
return PublicationResult{}, err
|
||||||
}
|
}
|
||||||
if err := operations.rename(currentPlan.Target, backupDirectory); err != nil {
|
if err := operations.rename(currentPlan.Target, backupDirectory); err != nil {
|
||||||
return PublicationResult{}, fmt.Errorf("back up comparison destination %q: %w", currentPlan.Target, err)
|
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)
|
return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err)
|
||||||
}
|
}
|
||||||
if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil {
|
if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil {
|
||||||
@@ -372,14 +655,28 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
|
|||||||
}
|
}
|
||||||
temporaryDirectory = ""
|
temporaryDirectory = ""
|
||||||
if err := operations.removeAll(backupDirectory); err != nil {
|
if err := operations.removeAll(backupDirectory); err != nil {
|
||||||
cleanupErr := &PublicationCleanupError{RetainedBackupPath: backupDirectory, Err: err}
|
recoveryState, recoveryPath := inspectBackupRecovery(backupDirectory)
|
||||||
return PublicationResult{Committed: true, RetainedBackupPath: backupDirectory}, cleanupErr
|
cleanupErr := &PublicationCleanupError{RecoveryState: recoveryState, RecoveryPath: recoveryPath, Err: err}
|
||||||
|
return PublicationResult{Committed: true, RecoveryState: recoveryState, RecoveryPath: recoveryPath}, cleanupErr
|
||||||
}
|
}
|
||||||
return PublicationResult{Committed: true}, nil
|
return PublicationResult{Committed: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizeMovedDestination(plan DestinationPlan, backupDirectory string) error {
|
func inspectBackupRecovery(backupDirectory string) (BackupRecoveryState, string) {
|
||||||
backupPlan, err := PlanDestination(plan.WorkingDirectory, backupDirectory, plan.Replace)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("authorize moved comparison destination %q: %w", backupDirectory, err)
|
return fmt.Errorf("authorize moved comparison destination %q: %w", backupDirectory, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanDestination(t *testing.T) {
|
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, string(os.PathSeparator), false, DestinationFilesystemRoot)
|
||||||
assertDestinationErrorKind(t, workingDirectory, "relative", false, DestinationInvalidPath)
|
assertDestinationErrorKind(t, workingDirectory, "relative", false, DestinationInvalidPath)
|
||||||
|
|
||||||
|
t.Run("symbolic links", func(t *testing.T) {
|
||||||
link := filepath.Join(workingDirectory, "link")
|
link := filepath.Join(workingDirectory, "link")
|
||||||
if err := os.Symlink(empty, link); err != nil {
|
testutil.RequireSymlink(t, empty, link)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
|
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
|
||||||
|
|
||||||
dangling := filepath.Join(workingDirectory, "dangling")
|
dangling := filepath.Join(workingDirectory, "dangling")
|
||||||
if err := os.Symlink(filepath.Join(workingDirectory, "missing"), dangling); err != nil {
|
testutil.RequireSymlink(t, filepath.Join(workingDirectory, "missing"), dangling)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
|
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
|
||||||
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
|
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
|
||||||
|
})
|
||||||
|
|
||||||
nonempty := filepath.Join(workingDirectory, "nonempty")
|
nonempty := filepath.Join(workingDirectory, "nonempty")
|
||||||
if err := os.Mkdir(nonempty, 0o755); err != nil {
|
if err := os.Mkdir(nonempty, 0o755); err != nil {
|
||||||
@@ -116,9 +118,7 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
|||||||
if err := os.Remove(path); err != nil {
|
if err := os.Remove(path); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.Symlink(filepath.Join(directory, DataPackageFilename), path); err != nil {
|
testutil.RequireSymlink(t, filepath.Join(directory, DataPackageFilename), path)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}},
|
}},
|
||||||
{name: "digest mismatch", mutate: func(t *testing.T, directory string) {
|
{name: "digest mismatch", mutate: func(t *testing.T, directory string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -138,6 +138,14 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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) {
|
{name: "traversal report path", mutate: func(t *testing.T, directory string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(directory, ManifestFilename)
|
path := filepath.Join(directory, ManifestFilename)
|
||||||
@@ -158,6 +166,29 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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 {
|
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) {
|
func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) {
|
||||||
directory := publishTestBundle(t, testBundle())
|
directory := publishTestBundle(t, testBundle())
|
||||||
plan, err := PlanDestination(filepath.Dir(directory), directory, true)
|
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) {
|
func TestPublishReauthorizesMovedDestination(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -254,9 +367,7 @@ func TestPublishReauthorizesMovedDestination(t *testing.T) {
|
|||||||
},
|
},
|
||||||
mutate: func(t *testing.T, target string) {
|
mutate: func(t *testing.T, target string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.Symlink("unrelated-target", target); err != nil {
|
testutil.RequireSymlink(t, "unrelated-target", target)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
verify: func(t *testing.T, target string) {
|
verify: func(t *testing.T, target string) {
|
||||||
t.Helper()
|
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) {
|
func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
|
||||||
workingDirectory := t.TempDir()
|
workingDirectory := t.TempDir()
|
||||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||||
@@ -490,7 +644,7 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
var cleanupErr *PublicationCleanupError
|
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)
|
t.Fatalf("publish() result/error = %#v/%v", result, err)
|
||||||
}
|
}
|
||||||
if _, err := RecognizeBundle(target); err != nil {
|
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) {
|
func TestPublishRestoresExistingBundleAfterReplacementFailure(t *testing.T) {
|
||||||
workingDirectory := t.TempDir()
|
workingDirectory := t.TempDir()
|
||||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
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) {
|
func assertDestinationErrorKind(t *testing.T, workingDirectory, target string, replace bool, want DestinationErrorKind) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, err := PlanDestination(workingDirectory, target, replace)
|
_, 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) {
|
func assertMode(t *testing.T, path string, want os.FileMode) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
|
|||||||
@@ -21,6 +21,33 @@ const (
|
|||||||
NotifyFailureError NotifyFailurePolicy = "error"
|
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 {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
Location LocationConfig `yaml:"location"`
|
Location LocationConfig `yaml:"location"`
|
||||||
@@ -246,7 +273,11 @@ func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c ReportDistributorConfig) PathTemplatesSet() bool {
|
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 {
|
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/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||||
"gopkg.in/yaml.v3"
|
"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) {
|
func TestOutputDirectoryLoading(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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) {
|
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||||
retiredDailyKey := retiredDailyReportKeyForTest()
|
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||||
tests := []struct {
|
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) {
|
func TestLoadAppliesOverrides(t *testing.T) {
|
||||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1183,6 +1315,7 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
mutate func(*Config)
|
mutate func(*Config)
|
||||||
wantErr string
|
wantErr string
|
||||||
|
wantAbsent string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Endpoint",
|
name: "Endpoint",
|
||||||
@@ -1191,6 +1324,35 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "notify.distributor.endpoint",
|
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",
|
name: "TokenEnvEmpty",
|
||||||
mutate: func(cfg *Config) {
|
mutate: func(cfg *Config) {
|
||||||
@@ -1247,6 +1409,13 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "notify.distributor.bundle_id_template",
|
wantErr: "notify.distributor.bundle_id_template",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "BundleTemplateRenderedEmpty",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.BundleIDTemplate = " "
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.bundle_id_template",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "IdempotencyTemplate",
|
name: "IdempotencyTemplate",
|
||||||
mutate: func(cfg *Config) {
|
mutate: func(cfg *Config) {
|
||||||
@@ -1254,6 +1423,13 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "notify.distributor.idempotency_key_template",
|
wantErr: "notify.distributor.idempotency_key_template",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "IdempotencyTemplateRenderedEmpty",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.IdempotencyKeyTemplate = " "
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.idempotency_key_template",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "BatchTemplate",
|
name: "BatchTemplate",
|
||||||
mutate: func(cfg *Config) {
|
mutate: func(cfg *Config) {
|
||||||
@@ -1277,6 +1453,27 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
t.Fatalf("error = %q, want %q", 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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
template string
|
template string
|
||||||
|
render func(string) error
|
||||||
}{
|
}{
|
||||||
{name: "Unknown", template: "{report_id}"},
|
{
|
||||||
{name: "Unclosed", template: "{batch"},
|
name: "SingleReport",
|
||||||
{name: "Unopened", template: "batch}"},
|
template: "{unknown}",
|
||||||
{name: "Empty", template: "{}"},
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
_, err := RenderDistributorBatchBundleID(tt.template, DistributorBatchTemplateValues{})
|
err := tt.render(tt.template)
|
||||||
if err == nil {
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
template string
|
template string
|
||||||
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{name: "Unknown", template: "{unknown}"},
|
{name: "Unclosed", template: "{location_id", wantErr: name + " contains an unclosed template variable"},
|
||||||
{name: "Unclosed", template: "{location_id"},
|
{name: "Unopened", template: "location_id}", wantErr: name + " contains an unopened template variable"},
|
||||||
{name: "Unopened", template: "location_id}"},
|
{name: "Empty", template: "{}", wantErr: name + " contains an empty template variable"},
|
||||||
{name: "Empty", template: "{}"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
_, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{})
|
_, err := renderDistributorTemplate(name, tt.template, func(variable string) (string, bool) {
|
||||||
if err == nil {
|
return "value", variable == "location_id"
|
||||||
t.Fatal("RenderDistributorBundleID() error = nil, want error")
|
})
|
||||||
|
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) {
|
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
|
||||||
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
|
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)
|
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" {
|
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
|
||||||
t.Fatalf("environment value = %q, want original", got)
|
t.Fatalf("environment value = %q, want original", got)
|
||||||
}
|
}
|
||||||
@@ -1706,9 +1923,13 @@ func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Setenv("WEATHERREPORTER_SECRET", "existing")
|
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)
|
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" {
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||||
t.Fatalf("environment value = %q, want from-file", got)
|
t.Fatalf("environment value = %q, want from-file", got)
|
||||||
}
|
}
|
||||||
@@ -1735,9 +1956,13 @@ func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Setenv("WEATHERREPORTER_SECRET", "")
|
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)
|
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 {
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
|
||||||
t.Fatalf("environment value = %q, want %q", 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 {
|
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
|
||||||
t.Fatalf("write target: %v", err)
|
t.Fatalf("write target: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
|
testutil.RequireSymlink(t, target, filepath.Join(dir, "SYMLINK"))
|
||||||
t.Fatalf("create symlink: %v", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
wantErr: "not a symlink",
|
wantErr: "not a symlink",
|
||||||
},
|
},
|
||||||
@@ -1808,7 +2031,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
tt.setup(t, dir)
|
tt.setup(t, dir)
|
||||||
|
|
||||||
err := loadSecrets(SecretsConfig{Directory: dir})
|
_, err := loadSecrets(SecretsConfig{Directory: dir})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("loadSecrets() error = nil, want error")
|
t.Fatal("loadSecrets() error = nil, want error")
|
||||||
}
|
}
|
||||||
@@ -1823,7 +2046,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadSecretsRejectsMissingDirectory(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 {
|
if err == nil {
|
||||||
t.Fatal("loadSecrets() error = nil, want missing directory error")
|
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())
|
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
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := loadSecrets(cfg.Secrets); err != nil {
|
secrets, err := loadSecrets(cfg.Secrets)
|
||||||
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
if err := applySecrets(secrets); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,11 +78,18 @@ var distributorBatchIdempotencyTemplateVariables = map[string]struct{}{
|
|||||||
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
|
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
|
||||||
|
|
||||||
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -93,11 +100,18 @@ func RenderDistributorPipelineID(template string, values DistributorTemplateValu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RenderDistributorIdempotencyKey(template string, values DistributorTemplateValues) (string, error) {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -108,7 +122,7 @@ func RenderDistributorBatchBundleID(template string, values DistributorBatchTemp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RenderDistributorBatchPipelineID(template string, values DistributorBatchTemplateValues) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -119,7 +133,7 @@ func RenderDistributorBatchPipelineID(template string, values DistributorBatchTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RenderDistributorBatchIdempotencyKey(template string, values DistributorBatchTemplateValues) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -137,7 +151,7 @@ func RenderDistributorReportPaths(name string, templates []string, values Distri
|
|||||||
seen := make(map[string]struct{}, len(templates))
|
seen := make(map[string]struct{}, len(templates))
|
||||||
for i, template := range templates {
|
for i, template := range templates {
|
||||||
itemName := fmt.Sprintf("%s[%d]", name, i)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDistributorBatchTemplate(name, template string, allowed map[string]struct{}) error {
|
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
|
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
|
var rendered strings.Builder
|
||||||
for i := 0; i < len(template); {
|
for i := 0; i < len(template); {
|
||||||
switch template[i] {
|
switch template[i] {
|
||||||
@@ -176,10 +192,11 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
|
|||||||
if variable == "" {
|
if variable == "" {
|
||||||
return "", fmt.Errorf("%s contains an empty template variable", name)
|
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)
|
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
||||||
}
|
}
|
||||||
rendered.WriteString(distributorTemplateValue(variable, values))
|
rendered.WriteString(value)
|
||||||
i += end + 2
|
i += end + 2
|
||||||
case '}':
|
case '}':
|
||||||
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
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
|
return rendered.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderDistributorBatchTemplate(name, template string, values DistributorBatchTemplateValues, allowed map[string]struct{}) (string, error) {
|
func newDistributorTemplateResolver(values DistributorTemplateValues, allowed map[string]struct{}) distributorTemplateResolver {
|
||||||
var rendered strings.Builder
|
return func(variable string) (string, bool) {
|
||||||
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 {
|
if _, ok := allowed[variable]; !ok {
|
||||||
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
return "", false
|
||||||
}
|
}
|
||||||
rendered.WriteString(distributorBatchTemplateValue(variable, values))
|
return distributorTemplateValue(variable, values), true
|
||||||
i += end + 2
|
|
||||||
case '}':
|
|
||||||
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
|
||||||
default:
|
|
||||||
rendered.WriteByte(template[i])
|
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rendered.String(), nil
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
|
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 {
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
}
|
}
|
||||||
if !reportCfg.deterministicModulesSet {
|
if !reportCfg.deterministicModulesConfigured() {
|
||||||
continue
|
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)
|
items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -110,7 +113,7 @@ func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string,
|
|||||||
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
}
|
}
|
||||||
if !reportCfg.Distributor.pathTemplatesSet {
|
if !reportCfg.Distributor.PathTemplatesSet() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
|
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)
|
return nil, fmt.Errorf("module %q does not accept options", id)
|
||||||
}
|
}
|
||||||
if err := definition.ValidateOptions(raw); err == nil {
|
if err := definition.ValidateOptions(raw); err == nil {
|
||||||
return raw, nil
|
return definition.CanonicalOptions(raw)
|
||||||
}
|
}
|
||||||
optionType := reflect.TypeOf(definition.DefaultOptions)
|
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||||
normalized, err := decodeKnownOptions(raw, optionType)
|
normalized, err := decodeKnownOptions(raw, optionType)
|
||||||
|
|||||||
@@ -10,43 +10,54 @@ import (
|
|||||||
|
|
||||||
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
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 == "" {
|
if cfg.Directory == "" {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(cfg.Directory)
|
entries, err := os.ReadDir(cfg.Directory)
|
||||||
if err != nil {
|
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 {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
if 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) {
|
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 {
|
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() {
|
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()
|
info, err := entry.Info()
|
||||||
if err != nil {
|
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() {
|
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)
|
path := filepath.Join(cfg.Directory, name)
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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)
|
value := string(data)
|
||||||
if strings.HasSuffix(value, "\r\n") {
|
if strings.HasSuffix(value, "\r\n") {
|
||||||
@@ -54,10 +65,44 @@ func loadSecrets(cfg SecretsConfig) error {
|
|||||||
} else {
|
} else {
|
||||||
value = strings.TrimSuffix(value, "\n")
|
value = strings.TrimSuffix(value, "\n")
|
||||||
}
|
}
|
||||||
if err := os.Setenv(name, value); err != nil {
|
secrets = append(secrets, secretValue{name: name, value: value})
|
||||||
return fmt.Errorf("set environment variable from secret file %q: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ func Validate(cfg Config) error {
|
|||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
return fmt.Errorf("weather_api.base_url must be an absolute URL")
|
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 {
|
if cfg.WeatherAPI.Timeout <= 0 {
|
||||||
return fmt.Errorf("weather_api.timeout must be greater than zero")
|
return fmt.Errorf("weather_api.timeout must be greater than zero")
|
||||||
@@ -53,6 +57,9 @@ func Validate(cfg Config) error {
|
|||||||
if strings.TrimSpace(source) == "" {
|
if strings.TrimSpace(source) == "" {
|
||||||
return fmt.Errorf("missing_source.sources contains an empty source name")
|
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 {
|
if err := validatePolicy("missing_source.sources."+source, policy); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -68,10 +75,16 @@ func Validate(cfg Config) error {
|
|||||||
if len(cfg.Dayparts) == 0 {
|
if len(cfg.Dayparts) == 0 {
|
||||||
return fmt.Errorf("dayparts must contain at least one entry")
|
return fmt.Errorf("dayparts must contain at least one entry")
|
||||||
}
|
}
|
||||||
|
daypartNames := make(map[string]int, len(cfg.Dayparts))
|
||||||
for i, daypart := range cfg.Dayparts {
|
for i, daypart := range cfg.Dayparts {
|
||||||
if strings.TrimSpace(daypart.Name) == "" {
|
if strings.TrimSpace(daypart.Name) == "" {
|
||||||
return fmt.Errorf("dayparts[%d].name is required", i)
|
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 {
|
if _, err := timeutil.ParseClock(daypart.Start); err != nil {
|
||||||
return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err)
|
return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err)
|
||||||
}
|
}
|
||||||
@@ -106,9 +119,8 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := url.Parse(cfg.Endpoint)
|
if err := ValidateDistributorEndpoint(cfg.Endpoint); err != nil {
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
return fmt.Errorf("notify.distributor.endpoint %w", err)
|
||||||
return fmt.Errorf("notify.distributor.endpoint must be an absolute URL when enabled")
|
|
||||||
}
|
}
|
||||||
if cfg.TokenEnv == "" {
|
if cfg.TokenEnv == "" {
|
||||||
return fmt.Errorf("notify.distributor.token_env is required when enabled")
|
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 {
|
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := RenderDistributorIdempotencyKey(cfg.IdempotencyKeyTemplate, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
|
if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -156,6 +171,25 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
|||||||
return nil
|
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 {
|
func validateDistributorBatchNotify(cfg DistributorBatchNotifyConfig) error {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
return nil
|
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