Compare commits
8 Commits
6915bf1ba2
...
8089f62806
| Author | SHA1 | Date | |
|---|---|---|---|
| 8089f62806 | |||
| a34aec1dd2 | |||
| 448bd1e510 | |||
| 4e23e1e11f | |||
| 5d3b850e46 | |||
| 4f45dee332 | |||
| 1355605e70 | |||
| 7dc2ac9253 |
@@ -4,35 +4,46 @@ This document describes the implemented workflow coordinator in `internal/app`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/app` coordinates top-level use cases: generating one report, running
|
||||
morning or evening batches, building inspectable briefing artifacts, fetching
|
||||
weather bundles, and reading existing artifacts for inspection.
|
||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||
loading are complete. It resolves report definitions, fetches weather data,
|
||||
builds briefing and prompt-input artifacts, invokes Scriptorium through the
|
||||
adapter boundary, persists managed state, runs batches, and reads existing
|
||||
artifacts for inspection.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- app request structs containing config, report or batch selection, clock time,
|
||||
optional report date, optional Storm Report bounds, output paths, renderer
|
||||
fakes, or state-store fakes
|
||||
- `GenerateRequest` for one report command
|
||||
- `BatchRequest` for morning or evening batch commands
|
||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
||||
and internal composition
|
||||
- resolved report definitions from `internal/report`
|
||||
- forecast bundles from the Weather API adapter
|
||||
- prior briefing snapshots from `internal/state`
|
||||
- forecast bundles from `internal/adapters/weatherapi`
|
||||
- prior snapshots loaded from `internal/state`
|
||||
- optional renderer and state-store fakes for tests
|
||||
|
||||
Outputs:
|
||||
|
||||
- report results with briefing, data package, preflight, report, metadata,
|
||||
prior snapshot, Recent Changes, and Scriptorium results
|
||||
- batch summaries with per-report status and artifact paths
|
||||
- generated report results with briefing, data package, preflight, report,
|
||||
metadata, prior snapshot, Recent Changes, and Scriptorium result details
|
||||
- batch summaries with per-report status, artifact paths, and error text
|
||||
- saved Weather API bundle JSON for fetch workflows
|
||||
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
||||
snapshots, and source provenance
|
||||
|
||||
## Boundaries
|
||||
|
||||
- The package coordinates workflow order.
|
||||
- It does not parse CLI flags, load YAML files directly, implement HTTP calls,
|
||||
derive forecast facts, define report periods, compare Markdown, or construct
|
||||
Scriptorium argv.
|
||||
`internal/app` owns workflow order and request composition. It does not parse
|
||||
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
|
||||
facts, define report periods, compare rendered Markdown, or construct
|
||||
Scriptorium argv.
|
||||
|
||||
Report selection and report identity policy come from `internal/report`.
|
||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
|
||||
and persisted metadata stay in `internal/state`.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
@@ -42,36 +53,57 @@ Outputs:
|
||||
- `dayparts` for daily and outlook summarization
|
||||
- `recent_change.*` for structured Recent Changes thresholds
|
||||
|
||||
## External Adapters Used
|
||||
Output copy flags are command request fields. They are not configuration
|
||||
defaults.
|
||||
|
||||
- `internal/adapters/weatherapi` for forecast bundle fetching
|
||||
- `internal/adapters/scriptorium` for render preflight and report generation
|
||||
- `internal/state` filesystem store for persisted artifacts
|
||||
## Generation Workflow
|
||||
|
||||
## State Or Manifest Behavior
|
||||
Single-report generation follows this order:
|
||||
|
||||
Generation saves the briefing snapshot, data package, preflight result when
|
||||
available, rendered report, and metadata. Metadata links all managed artifact
|
||||
paths. Inspection workflows read existing state and do not fetch weather data or
|
||||
invoke Scriptorium.
|
||||
1. Resolve the command report to a `report.Resolved` value.
|
||||
2. Create or use a filesystem store.
|
||||
3. Locate any prior compatible snapshot through `internal/state`.
|
||||
4. Fetch a Weather API bundle.
|
||||
5. Build a report-specific briefing package.
|
||||
6. Save the briefing snapshot.
|
||||
7. Compute Recent Changes from structured prior and current briefings.
|
||||
8. Build and save the Scriptorium `data_package`.
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
12. Run Scriptorium report generation to the managed report path.
|
||||
13. Copy the managed report to the requested `--out` path when provided.
|
||||
14. Save metadata with the managed report path.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
If render preflight returns both a result and an error, preflight JSON and
|
||||
metadata are persisted before the error is returned. If Scriptorium report
|
||||
generation returns an error after writing output, the managed report and
|
||||
metadata remain inspectable.
|
||||
|
||||
There is no resume workflow. Batch generation continues remaining independent
|
||||
reports after one report fails, then reports aggregate success and failure
|
||||
counts.
|
||||
## Batch Workflow
|
||||
|
||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
||||
from report definitions. Batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
|
||||
## Inspection Workflow
|
||||
|
||||
Inspection workflows load existing filesystem state only. They do not fetch
|
||||
weather data or invoke Scriptorium. Run-specific inspect commands share the same
|
||||
store and metadata lookup path, then load the requested artifact or derived
|
||||
inspection view.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Resolve errors stop the requested workflow before fetching weather data.
|
||||
- Weather API or briefing errors stop that report before Scriptorium is called.
|
||||
- Render preflight runs before Scriptorium report generation.
|
||||
- If render preflight returns a result and an error, preflight JSON and metadata
|
||||
are persisted before the error is returned.
|
||||
- If Scriptorium report generation returns an error after writing output,
|
||||
metadata and the managed report path remain inspectable.
|
||||
- Batch failures are recorded per report and surfaced through aggregate batch
|
||||
failure.
|
||||
- Weather API and briefing errors stop that report before Scriptorium runs.
|
||||
- Prompt input validation fails before render preflight.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
- Metadata and artifact path errors include filesystem context.
|
||||
- Batch failures are recorded per report and surfaced through an aggregate
|
||||
batch error.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -84,6 +116,8 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Report behavior is resolved through `internal/report`.
|
||||
- Generated reports use the same app request and result types regardless of
|
||||
report ID.
|
||||
- Render preflight precedes Scriptorium report generation.
|
||||
- Recent Changes are computed from structured briefing snapshots.
|
||||
- Metadata links artifacts produced for a run.
|
||||
|
||||
@@ -1,57 +1,81 @@
|
||||
# Report Registry Internals
|
||||
|
||||
This document describes report identity, valid-period resolution, batch
|
||||
membership, and comparison declarations in `internal/report`.
|
||||
membership, output naming, artifact grouping, and comparison declarations in
|
||||
`internal/report`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/report` centralizes report definitions so report IDs, prompt IDs,
|
||||
default output names, comparison strategies, and valid periods are declared in
|
||||
one package.
|
||||
`internal/report` is the canonical source for report definitions. App, state,
|
||||
briefing, and CLI wiring consume resolved definitions instead of owning report
|
||||
identity policy themselves.
|
||||
|
||||
## Inputs And Outputs
|
||||
## Definition Fields
|
||||
|
||||
Inputs:
|
||||
Each report definition declares:
|
||||
|
||||
- report ID or batch name
|
||||
- generation time
|
||||
- timezone
|
||||
- optional Daily date override
|
||||
- optional Storm Report start and end times
|
||||
- report ID and display name
|
||||
- Scriptorium prompt ID
|
||||
- valid-period resolver
|
||||
- comparison strategy
|
||||
- managed artifact group
|
||||
- batch output copy filename
|
||||
- generated-report eligibility
|
||||
- prior-report compatibility list
|
||||
- morning or evening batch membership
|
||||
|
||||
Outputs:
|
||||
## Implemented Reports
|
||||
|
||||
- `report.Resolved` values with definition metadata and half-open valid periods
|
||||
- `report.Metadata` values used by briefing and persisted metadata builders
|
||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
|
||||
All implemented report definitions are eligible for generation.
|
||||
|
||||
## Valid Periods
|
||||
|
||||
- Daily Today covers the selected local civil day, or the current local civil
|
||||
day when no date override is supplied.
|
||||
- Daily Tomorrow covers the next local civil day from generation time.
|
||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||
three days later.
|
||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||
Sunday morning batch resolution.
|
||||
- Storm Report covers an explicit event window supplied by the caller.
|
||||
|
||||
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
||||
the configured timezone or RFC3339 timestamps with explicit offsets. End time
|
||||
must be after start time.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package defines report identity and time coverage only.
|
||||
- It does not fetch weather data, build briefings, compare snapshots, write
|
||||
state, parse CLI flags, or invoke Scriptorium.
|
||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||
weather data, build briefings, compare briefing contents, write state, parse CLI
|
||||
flags, or invoke Scriptorium.
|
||||
|
||||
The CLI owns public command names. The app maps those command names to report
|
||||
IDs, then uses the registry for report policy.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Report
|
||||
output path copying uses default output names from report definitions.
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||
output path copying uses batch output names from report definitions.
|
||||
|
||||
## External Adapters Used
|
||||
## State And App Usage
|
||||
|
||||
None.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None directly. Resolved metadata contributes RunID, report ID, prompt ID,
|
||||
generation time, timezone, and valid period to later briefing and state
|
||||
metadata.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. Morning batch resolution skips Weekend Outlook on Sunday.
|
||||
- State paths use `ArtifactGroup`.
|
||||
- Batch output copies use `BatchOutputName`.
|
||||
- Generation checks `Generated`.
|
||||
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||
- RunIDs include the resolved report ID.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Unknown reports and batch names return actionable errors.
|
||||
- Unknown report IDs and batch names return actionable errors.
|
||||
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
||||
- Storm Report resolution requires start and end, with end after start.
|
||||
|
||||
@@ -68,4 +92,6 @@ Inspect:
|
||||
- Report selection goes through the registry.
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||
- Valid periods are half-open intervals independent of rendered report text.
|
||||
- Comparison strategy is declared by report definition.
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
comparison compatibility, and comparison strategy are declared by report
|
||||
definition.
|
||||
|
||||
@@ -6,8 +6,9 @@ This document describes the subprocess adapter in
|
||||
## Purpose
|
||||
|
||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||
for Markdown report generation while isolating subprocess details from domain
|
||||
packages.
|
||||
for Markdown report generation. It isolates subprocess execution, argv
|
||||
construction, timeout handling, output capture, and exit-code interpretation
|
||||
from app and domain packages.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -17,19 +18,25 @@ Inputs:
|
||||
- prompt input data package path
|
||||
- report output path for `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
- context for cancellation
|
||||
|
||||
Outputs:
|
||||
|
||||
- argv used for execution
|
||||
- captured stdout and stderr with truncation flags
|
||||
- captured stdout and stderr
|
||||
- truncation flags for captured output
|
||||
- exit code
|
||||
- report output path for `run`
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This adapter owns Scriptorium argv construction and subprocess execution.
|
||||
- It does not choose report types, build prompt input, fetch weather data,
|
||||
decide workflow order, or persist workflow metadata.
|
||||
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||
subprocess execution. It does not choose report types, build prompt input,
|
||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||
|
||||
The adapter exposes request and result structs for render and run operations.
|
||||
State persistence uses a state-owned preflight artifact shape; app
|
||||
orchestration converts render results before saving.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
@@ -39,29 +46,43 @@ Outputs:
|
||||
- `scriptorium.timeout`
|
||||
- `scriptorium.extra_args`
|
||||
|
||||
## External Adapters Used
|
||||
## Commands
|
||||
|
||||
- external `scriptorium` CLI
|
||||
Render preflight argv starts with:
|
||||
|
||||
See [Scriptorium integration](../integrations/scriptorium.md) for the external
|
||||
CLI contract used by this project.
|
||||
```text
|
||||
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||
```
|
||||
|
||||
## State Or Manifest Behavior
|
||||
Report generation argv starts with:
|
||||
|
||||
`SaveRenderResult` can write render results atomically. The app and state store
|
||||
own managed preflight paths and metadata links.
|
||||
```text
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||
```
|
||||
|
||||
## Skip And Resume Behavior
|
||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||
and before prompt-specific arguments. Extra arguments are appended after the
|
||||
built-in arguments.
|
||||
|
||||
None. Context cancellation and configured timeout stop subprocess execution.
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs commands without shell interpolation. The same private
|
||||
execution path is used by render and run after command-specific request
|
||||
validation and argv construction.
|
||||
|
||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||
1 MiB each, and marked as truncated when the cap is reached.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Missing prompt ID, data package path, or run output path returns an error.
|
||||
- Subprocess start or context errors are wrapped with operation context.
|
||||
- Nonzero render and run exits return captured output plus an error containing
|
||||
exit code and stderr.
|
||||
- Captured stdout and stderr are size-limited and marked when truncated.
|
||||
- Missing prompt ID or data package path returns an error before subprocess
|
||||
execution.
|
||||
- Missing run output path returns an error before subprocess execution.
|
||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||
operation context by the caller-facing method.
|
||||
- Nonzero render and run exits return the captured result plus an error
|
||||
containing the exit code and stderr.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -75,4 +96,5 @@ Inspect:
|
||||
|
||||
- No shell interpolation is used.
|
||||
- The Scriptorium input name is `data_package`.
|
||||
- Render and run preserve command-specific result structs.
|
||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes filesystem state in `internal/state`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/state` owns managed artifact paths, atomic JSON writes, persisted
|
||||
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
||||
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
||||
|
||||
## Inputs And Outputs
|
||||
@@ -15,7 +15,7 @@ Inputs:
|
||||
- resolved report definition and valid period
|
||||
- briefing package
|
||||
- prompt input data package
|
||||
- Scriptorium render result
|
||||
- preflight artifact
|
||||
- rendered report path preparation request
|
||||
- RunID for inspection lookups
|
||||
|
||||
@@ -32,10 +32,13 @@ Outputs:
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns managed workspace layout, path validation, filesystem
|
||||
writes, and metadata reads.
|
||||
- It does not fetch weather data, derive forecasts, build prompt input content,
|
||||
compare briefing contents, invoke Scriptorium, or parse CLI flags.
|
||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||
data, derive forecasts, build prompt input content, compare briefing contents,
|
||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||
|
||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||
converts adapter render results into that shape before saving.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
@@ -48,27 +51,48 @@ Outputs:
|
||||
Workspace subdirectories must be relative paths that stay under
|
||||
`workspace.root`.
|
||||
|
||||
## External Adapters Used
|
||||
## Managed Layout
|
||||
|
||||
- local filesystem
|
||||
Paths are derived from the resolved report definition's artifact group, the
|
||||
valid-period start date for JSON artifacts, and the RunID.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
```text
|
||||
<workspace.root>/
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||
reports/<artifact_group>/<run_id>.md
|
||||
```
|
||||
|
||||
Managed paths are grouped by report family and valid-period start date for JSON
|
||||
artifacts. Reports are written under the report group. Metadata is stored beside
|
||||
briefing snapshots and links briefing, data package, preflight, and report
|
||||
paths. Report listing walks metadata files under the snapshots directory.
|
||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||
package, preflight, and report paths. Report listing walks metadata files under
|
||||
the snapshots directory.
|
||||
|
||||
Prior snapshot lookup reads metadata and selects the latest earlier compatible
|
||||
snapshot. Daily Today and Daily Tomorrow are compatible with each other for the
|
||||
same valid local date. 3-Day Outlook compares with prior 3-Day snapshots for
|
||||
the same valid local date. Weekend Outlook compares with prior Weekend snapshots
|
||||
for the same weekend window. Storm Report currently has no prior lookup.
|
||||
## Prior Lookup
|
||||
|
||||
## Skip And Resume Behavior
|
||||
Prior snapshot lookup reads stored metadata through the shared lookup path and
|
||||
selects the latest earlier snapshot whose report ID is compatible with the
|
||||
current report definition.
|
||||
|
||||
There is no resume workflow. Missing metadata directories return no inspection
|
||||
records or no prior snapshot rather than creating state.
|
||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
||||
valid local date.
|
||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||
date.
|
||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||
window.
|
||||
- Storm Report currently has no prior lookup because explicit event-window
|
||||
comparison is not searched by the filesystem store.
|
||||
|
||||
## Writes And Inspection
|
||||
|
||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
||||
prepared by creating their parent directory; Scriptorium writes the report body
|
||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
||||
|
||||
Inspection helpers read existing metadata, briefing, and data package files.
|
||||
Missing metadata directories return no inspection records or no prior snapshot
|
||||
rather than creating state.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
@@ -88,5 +112,6 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Managed paths stay under the configured workspace root.
|
||||
- Artifact grouping comes from report definitions.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Prior lookup is based on structured metadata, not rendered report text.
|
||||
|
||||
@@ -12,6 +12,7 @@ Developers and LLM coding agents should use it with
|
||||
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
|
||||
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
||||
validation.
|
||||
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
|
||||
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
||||
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||
- `internal/forecast`: normalized bundle types and deterministic forecast
|
||||
|
||||
@@ -3,12 +3,9 @@ package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
||||
if req.DataPackagePath == "" {
|
||||
return nil, fmt.Errorf("data package path is required")
|
||||
}
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
}
|
||||
commands := r.Commands
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
args := r.renderArgs(req)
|
||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
execution, err := r.execute(ctx, r.renderArgs(req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||
}
|
||||
result := &RenderResult{
|
||||
Command: append([]string{binary}, args...),
|
||||
Stdout: string(commandResult.Stdout),
|
||||
Stderr: string(commandResult.Stderr),
|
||||
StdoutTruncated: commandResult.StdoutTruncated,
|
||||
StderrTruncated: commandResult.StderrTruncated,
|
||||
ExitCode: commandResult.ExitCode,
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
StdoutTruncated: execution.result.StdoutTruncated,
|
||||
StderrTruncated: execution.result.StderrTruncated,
|
||||
ExitCode: execution.result.ExitCode,
|
||||
}
|
||||
if commandResult.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||
if execution.result.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if req.OutputPath == "" {
|
||||
return nil, fmt.Errorf("output path is required")
|
||||
}
|
||||
execution, err := r.execute(ctx, r.runArgs(req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
}
|
||||
result := &RunResult{
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
StdoutTruncated: execution.result.StdoutTruncated,
|
||||
StderrTruncated: execution.result.StderrTruncated,
|
||||
ExitCode: execution.result.ExitCode,
|
||||
OutputPath: req.OutputPath,
|
||||
}
|
||||
if execution.result.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
binary string
|
||||
args []string
|
||||
result CommandResult
|
||||
}
|
||||
|
||||
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
args := r.runArgs(req)
|
||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
result, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
return execution{}, err
|
||||
}
|
||||
result := &RunResult{
|
||||
Command: append([]string{binary}, args...),
|
||||
Stdout: string(commandResult.Stdout),
|
||||
Stderr: string(commandResult.Stderr),
|
||||
StdoutTruncated: commandResult.StdoutTruncated,
|
||||
StderrTruncated: commandResult.StderrTruncated,
|
||||
ExitCode: commandResult.ExitCode,
|
||||
OutputPath: req.OutputPath,
|
||||
}
|
||||
if commandResult.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
return execution{binary: binary, args: args, result: result}, nil
|
||||
}
|
||||
|
||||
func (e execution) argv() []string {
|
||||
return append([]string{e.binary}, e.args...)
|
||||
}
|
||||
|
||||
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func SaveRenderResult(path string, result *RenderResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("render result is required")
|
||||
}
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal render result: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary preflight file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary preflight file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary preflight file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save preflight %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedBuffer struct {
|
||||
data []byte
|
||||
limit int
|
||||
|
||||
@@ -11,14 +11,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
)
|
||||
|
||||
@@ -398,29 +397,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
}
|
||||
|
||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||
data, err := json.MarshalIndent(bundle, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal forecast bundle: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary bundle file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary bundle file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save bundle %q: %w", path, err)
|
||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||
return fmt.Errorf("save bundle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
@@ -14,6 +12,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -68,8 +67,6 @@ type BriefingRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyBriefingRequest = BriefingRequest
|
||||
|
||||
type ReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
@@ -78,15 +75,11 @@ type ReportRequest struct {
|
||||
Store state.Store
|
||||
}
|
||||
|
||||
type DailyReportRequest = ReportRequest
|
||||
|
||||
type BriefingResult struct {
|
||||
Package briefing.Package
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyBriefingResult = BriefingResult
|
||||
|
||||
type ReportResult struct {
|
||||
Briefing briefing.Package
|
||||
BriefingPath string
|
||||
@@ -103,8 +96,6 @@ type ReportResult struct {
|
||||
RunResult *scriptorium.RunResult
|
||||
}
|
||||
|
||||
type DailyReportResult = ReportResult
|
||||
|
||||
type BatchResult struct {
|
||||
Batch BatchKind `json:"batch"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isGeneratedReport(resolved.Definition.ID) {
|
||||
if resolved.Definition.Generated {
|
||||
_, err := GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
startedAt := now
|
||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||
for _, resolved := range resolvedReports {
|
||||
if !isGeneratedReport(resolved.Definition.ID) {
|
||||
if !resolved.Definition.Generated {
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
}
|
||||
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
|
||||
}
|
||||
|
||||
func batchOutputPath(outputDir string, definition report.Definition) string {
|
||||
if outputDir == "" || definition.DefaultOutputName == "" {
|
||||
if outputDir == "" || definition.BatchOutputName == "" {
|
||||
return ""
|
||||
}
|
||||
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
|
||||
return filepath.Join(outputDir, name)
|
||||
}
|
||||
|
||||
func isGeneratedReport(id report.ID) bool {
|
||||
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
|
||||
}
|
||||
|
||||
func isDailyReport(id report.ID) bool {
|
||||
return id == report.DailyToday || id == report.DailyTomorrow
|
||||
return filepath.Join(outputDir, definition.BatchOutputName)
|
||||
}
|
||||
|
||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
|
||||
return GenerateBriefing(ctx, req)
|
||||
}
|
||||
|
||||
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
||||
if err != nil {
|
||||
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
|
||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||
}
|
||||
|
||||
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
|
||||
return GenerateReport(ctx, req)
|
||||
}
|
||||
|
||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
preflightPath := paths.Preflight
|
||||
if renderResult != nil {
|
||||
var err error
|
||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
|
||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
OutputPath: reportPath,
|
||||
})
|
||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
||||
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||
return BuildBriefing(req, bundle)
|
||||
}
|
||||
|
||||
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
@@ -588,10 +558,6 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
return state.NewFilesystemStore(cfg.Workspace)
|
||||
}
|
||||
|
||||
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
return recentChanges(ctx, store, priorSnapshot, current, cfg)
|
||||
}
|
||||
|
||||
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
if priorSnapshot == nil {
|
||||
return nil, nil
|
||||
@@ -618,29 +584,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
}
|
||||
}
|
||||
|
||||
func copyFileAtomic(source string, target string) error {
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read rendered report %q: %w", source, err)
|
||||
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
|
||||
if result == nil {
|
||||
return state.PreflightArtifact{}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
|
||||
return state.PreflightArtifact{
|
||||
Command: append([]string(nil), result.Command...),
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary report output file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary report output file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary report output file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return fmt.Errorf("save report output %q: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -70,11 +70,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
||||
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg := dailyTestConfig(t, server)
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
@@ -85,13 +83,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
||||
|
||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: path,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||
}
|
||||
if result.OutputPath != path {
|
||||
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
||||
@@ -108,11 +106,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
||||
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -123,23 +119,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
||||
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -165,14 +159,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if renderer.renderCalls != 1 {
|
||||
@@ -193,14 +187,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||
}
|
||||
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected artifact %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(outputPath); err != nil {
|
||||
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
||||
}
|
||||
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||
data, err := os.ReadFile(result.DataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
@@ -236,7 +223,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -259,13 +246,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
err: errors.New("scriptorium render exited with code 1: render failed"),
|
||||
}
|
||||
|
||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateDailyReport() error = nil, want render error")
|
||||
t.Fatal("GenerateReport() error = nil, want render error")
|
||||
}
|
||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||
if err != nil {
|
||||
@@ -291,7 +278,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -315,13 +302,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
|
||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateDailyReport() error = nil, want run error")
|
||||
t.Fatal("GenerateReport() error = nil, want run error")
|
||||
}
|
||||
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
||||
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
||||
@@ -342,7 +329,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -393,14 +380,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if len(result.RecentChanges) == 0 {
|
||||
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
||||
@@ -433,13 +420,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
runBody: "# Tomorrow Planning Brief\n",
|
||||
}
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
||||
@@ -506,14 +493,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
runBody: "# Tomorrow Planning Brief\n",
|
||||
}
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if result.PriorSnapshot == nil {
|
||||
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
||||
@@ -1021,6 +1008,23 @@ func mustParse(value string) time.Time {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
||||
t.Helper()
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assertPathsExist(t *testing.T, paths ...string) {
|
||||
t.Helper()
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected artifact %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||
low := 50.0
|
||||
high := 58.0
|
||||
|
||||
@@ -40,59 +40,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
|
||||
}
|
||||
|
||||
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return state.Metadata{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
return metadata, err
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
return inspection.metadata, err
|
||||
}
|
||||
|
||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return store.LoadBriefing(ctx, metadata.BriefingPath)
|
||||
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
||||
}
|
||||
|
||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
|
||||
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
|
||||
}
|
||||
|
||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
resolved, err := resolvedFromMetadata(inspection.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := resolvedFromMetadata(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.FindPriorSnapshot(ctx, resolved)
|
||||
return inspection.store.FindPriorSnapshot(ctx, resolved)
|
||||
}
|
||||
|
||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
||||
metadata, err := InspectMetadata(ctx, req)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return SourceInspection{}, err
|
||||
}
|
||||
metadata := inspection.metadata
|
||||
return SourceInspection{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
|
||||
}, nil
|
||||
}
|
||||
|
||||
type runInspection struct {
|
||||
store *state.FilesystemStore
|
||||
metadata state.Metadata
|
||||
}
|
||||
|
||||
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return runInspection{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return runInspection{}, err
|
||||
}
|
||||
return runInspection{store: store, metadata: metadata}, nil
|
||||
}
|
||||
|
||||
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -78,29 +76,8 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal briefing package: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary briefing file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary briefing file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary briefing file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save briefing %q: %w", path, err)
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
return fmt.Errorf("save briefing package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
@@ -72,7 +73,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
||||
result, err := app.RunBatchDetailed(ctx, req)
|
||||
if result != nil {
|
||||
writeRunLogs(stderr, result)
|
||||
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
|
||||
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
@@ -108,6 +109,29 @@ type inspectOptions struct {
|
||||
RunID string
|
||||
}
|
||||
|
||||
type inspectRunCommand struct {
|
||||
Name string
|
||||
Inspect func(context.Context, app.InspectRunRequest) (any, error)
|
||||
}
|
||||
|
||||
var inspectRunCommands = []inspectRunCommand{
|
||||
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectMetadata(ctx, req)
|
||||
}},
|
||||
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectBriefing(ctx, req)
|
||||
}},
|
||||
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectDataPackage(ctx, req)
|
||||
}},
|
||||
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectPriorSnapshot(ctx, req)
|
||||
}},
|
||||
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectSources(ctx, req)
|
||||
}},
|
||||
}
|
||||
|
||||
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("inspect requires a command")
|
||||
@@ -128,81 +152,32 @@ func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer)
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, records)
|
||||
case "metadata":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, metadata)
|
||||
case "briefing":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, pkg)
|
||||
case "data-package":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, pkg)
|
||||
case "prior":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, prior)
|
||||
case "sources":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, sources)
|
||||
default:
|
||||
for _, candidate := range inspectRunCommands {
|
||||
if candidate.Name == command {
|
||||
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown inspect command %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
|
||||
opts, err := parseInspectRunFlags(command.Name, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, value)
|
||||
}
|
||||
|
||||
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if r.Clock == nil {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if len(args) == 0 {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||
}
|
||||
report, ok := reportKind(args[0])
|
||||
reportKind, ok := reportKind(args[0])
|
||||
if !ok {
|
||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||
}
|
||||
|
||||
opts, err := parseGenerateFlags(report, args[1:])
|
||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
}
|
||||
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
Path: opts.ConfigPath,
|
||||
Units: opts.Units,
|
||||
Timezone: opts.Timezone,
|
||||
Output: opts.Output,
|
||||
})
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
|
||||
req := app.GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: report,
|
||||
Report: reportKind,
|
||||
OutputPath: opts.Output,
|
||||
Now: r.Clock.Now(),
|
||||
}
|
||||
|
||||
switch report {
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if opts.End == "" {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
|
||||
}
|
||||
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location)
|
||||
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
}
|
||||
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
}
|
||||
if !req.StormEnd.After(req.StormStart) {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
|
||||
}
|
||||
req.StormStart = period.Start
|
||||
req.StormEnd = period.End
|
||||
}
|
||||
|
||||
return req, nil
|
||||
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
func writeJSON(stdout io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||
outPath := filepath.Join(tempDir, "storm.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
data, err := os.ReadFile(dataPackageMatches[0])
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||
outPath := filepath.Join(tempDir, "tomorrow.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
data, err := os.ReadFile(dataPackageMatches[0])
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
@@ -581,6 +561,45 @@ func TestRunInspectMissingMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
|
||||
|
||||
for _, command := range commands {
|
||||
t.Run(command+" requires run id", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want missing run id error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires a run id") {
|
||||
t.Fatalf("error = %q, want missing run id context", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(command+" accepts config", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want missing metadata error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "metadata for run id") {
|
||||
t.Fatalf("error = %q, want missing metadata context", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateCommands(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
tests := []struct {
|
||||
@@ -643,7 +662,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
||||
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
|
||||
_, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
|
||||
if err == nil {
|
||||
t.Fatal("resolveGenerate() error = nil, want missing start error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --start") {
|
||||
t.Fatalf("error = %q, want missing start", err.Error())
|
||||
}
|
||||
|
||||
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
|
||||
if err == nil {
|
||||
t.Fatal("resolveGenerate() error = nil, want missing end error")
|
||||
}
|
||||
@@ -652,6 +679,26 @@ func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
req, err := runner.resolveGenerate([]string{
|
||||
"storm",
|
||||
"--tz", "America/Chicago",
|
||||
"--start", "2026-05-29T18:00",
|
||||
"--end", "2026-05-30T06:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGenerate() error = %v", err)
|
||||
}
|
||||
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
|
||||
t.Fatalf("StormStart = %q, want local Chicago time", got)
|
||||
}
|
||||
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
|
||||
t.Fatalf("StormEnd = %q, want local Chicago time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
@@ -668,6 +715,22 @@ func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
_, err := runner.resolveGenerate([]string{
|
||||
"storm",
|
||||
"--start", "2026-05-30T06:00",
|
||||
"--end", "2026-05-29T18:00",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "end time after start time") {
|
||||
t.Fatalf("error = %q, want invalid bounds context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRunCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -739,6 +802,28 @@ func dailyServer(t *testing.T) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
|
||||
t.Helper()
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
|
||||
func oneArtifact(t *testing.T, root string, parts ...string) string {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatalf("glob artifact: %v", err)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("artifact matches = %#v, want one", matches)
|
||||
}
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "scriptorium")
|
||||
|
||||
@@ -17,7 +17,6 @@ type Config struct {
|
||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Reports ReportOutputConfig `yaml:"reports"`
|
||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||
}
|
||||
@@ -52,11 +51,6 @@ type WorkspaceConfig struct {
|
||||
PreflightDir string `yaml:"preflight_dir"`
|
||||
}
|
||||
|
||||
type ReportOutputConfig struct {
|
||||
OutputDir string `yaml:"output_dir"`
|
||||
Paths map[string]string `yaml:"paths"`
|
||||
}
|
||||
|
||||
type DaypartConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Start string `yaml:"start"`
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadAppliesOverrides(t *testing.T) {
|
||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
|
||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
@@ -102,7 +102,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
||||
if cfg.WeatherAPI.Timezone != "+09:30" {
|
||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
if cfg.Reports.OutputDir != "./out" {
|
||||
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,6 @@ func Defaults() Config {
|
||||
DataPackagesDir: "data-packages",
|
||||
PreflightDir: "preflight",
|
||||
},
|
||||
Reports: ReportOutputConfig{
|
||||
OutputDir: "reports",
|
||||
Paths: map[string]string{},
|
||||
},
|
||||
Dayparts: []DaypartConfig{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
|
||||
@@ -12,7 +12,6 @@ type LoadOptions struct {
|
||||
Path string
|
||||
Units string
|
||||
Timezone string
|
||||
Output string
|
||||
}
|
||||
|
||||
func Load(opts LoadOptions) (Config, error) {
|
||||
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
|
||||
if opts.Timezone != "" {
|
||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||
}
|
||||
if opts.Output != "" {
|
||||
cfg.Reports.OutputDir = opts.Output
|
||||
}
|
||||
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
|
||||
if cfg.MissingSource.Sources == nil {
|
||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||
}
|
||||
if cfg.Reports.Paths == nil {
|
||||
cfg.Reports.Paths = map[string]string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
|
||||
if cfg.Workspace.Root == "" {
|
||||
return fmt.Errorf("workspace.root is required")
|
||||
}
|
||||
if cfg.Reports.OutputDir == "" {
|
||||
return fmt.Errorf("reports.output_dir is required")
|
||||
}
|
||||
if len(cfg.Dayparts) == 0 {
|
||||
return fmt.Errorf("dayparts must contain at least one entry")
|
||||
}
|
||||
|
||||
48
internal/fileutil/fileutil.go
Normal file
48
internal/fileutil/fileutil.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package fileutil provides narrow filesystem helpers for durable artifacts.
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func WriteFileAtomic(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary file for %q: %w", path, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary file for %q: %w", path, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary file for %q: %w", path, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteJSONAtomic(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %q: %w", path, err)
|
||||
}
|
||||
return WriteFileAtomic(path, data)
|
||||
}
|
||||
|
||||
func CopyFileAtomic(source string, target string) error {
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %q: %w", source, err)
|
||||
}
|
||||
return WriteFileAtomic(target, data)
|
||||
}
|
||||
103
internal/fileutil/fileutil_test.go
Normal file
103
internal/fileutil/fileutil_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package fileutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteFileAtomicCreatesParentDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||
|
||||
if err := WriteFileAtomic(path, []byte("artifact")); err != nil {
|
||||
t.Fatalf("WriteFileAtomic() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "artifact" {
|
||||
t.Fatalf("data = %q, want artifact", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicOverwritesTarget(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "artifact.txt")
|
||||
if err := WriteFileAtomic(path, []byte("old")); err != nil {
|
||||
t.Fatalf("WriteFileAtomic() initial error = %v", err)
|
||||
}
|
||||
|
||||
if err := WriteFileAtomic(path, []byte("new")); err != nil {
|
||||
t.Fatalf("WriteFileAtomic() overwrite error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "new" {
|
||||
t.Fatalf("data = %q, want new", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target")
|
||||
if err := os.Mkdir(target, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir() error = %v", err)
|
||||
}
|
||||
|
||||
err := WriteFileAtomic(target, []byte("data"))
|
||||
if err == nil {
|
||||
t.Fatal("WriteFileAtomic() error = nil, want rename error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "save") {
|
||||
t.Fatalf("error = %q, want save context", err.Error())
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, ".target.*.tmp"))
|
||||
if err != nil {
|
||||
t.Fatalf("Glob() error = %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary files = %v, want none", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONAtomic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "artifact.json")
|
||||
|
||||
if err := WriteJSONAtomic(path, map[string]string{"status": "ok"}); err != nil {
|
||||
t.Fatalf("WriteJSONAtomic() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "{\n \"status\": \"ok\"\n}" {
|
||||
t.Fatalf("json = %q, want indented object", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileAtomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.txt")
|
||||
target := filepath.Join(dir, "nested", "target.txt")
|
||||
if err := os.WriteFile(source, []byte("copied"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if err := CopyFileAtomic(source, target); err != nil {
|
||||
t.Fatalf("CopyFileAtomic() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != "copied" {
|
||||
t.Fatalf("data = %q, want copied", data)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@
|
||||
package promptinput
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -117,29 +115,8 @@ func Save(path string, pkg Package) error {
|
||||
if err := Validate(pkg); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal data package: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary data package file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary data package file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary data package file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save data package %q: %w", path, err)
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
return fmt.Errorf("save data package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ type Definition struct {
|
||||
PromptID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
DefaultOutputName string
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
@@ -51,6 +55,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||
return d.resolve(req)
|
||||
}
|
||||
|
||||
func (d Definition) CompatibleWithPrior(id ID) bool {
|
||||
for _, compatibleID := range d.CompatiblePriorIDs {
|
||||
if id == compatibleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ResolveRequest struct {
|
||||
Now time.Time
|
||||
Location *time.Location
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
id ID
|
||||
artifactGroup string
|
||||
batchOutputName string
|
||||
generated bool
|
||||
compatiblePriorIDs []ID
|
||||
}{
|
||||
{
|
||||
id: DailyToday,
|
||||
artifactGroup: "daily",
|
||||
batchOutputName: "daily.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
artifactGroup: "daily",
|
||||
batchOutputName: "tomorrow.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
},
|
||||
{
|
||||
id: ThreeDay,
|
||||
artifactGroup: "three-day",
|
||||
batchOutputName: "three-day.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{ThreeDay},
|
||||
},
|
||||
{
|
||||
id: Weekend,
|
||||
artifactGroup: "weekend",
|
||||
batchOutputName: "weekend.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Weekend},
|
||||
},
|
||||
{
|
||||
id: Storm,
|
||||
artifactGroup: "storm",
|
||||
batchOutputName: "storm.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Storm},
|
||||
},
|
||||
}
|
||||
|
||||
registry := DefaultRegistry()
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
definition, err := registry.Lookup(tt.id)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
if definition.ArtifactGroup != tt.artifactGroup {
|
||||
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
|
||||
}
|
||||
if definition.BatchOutputName != tt.batchOutputName {
|
||||
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
|
||||
}
|
||||
if definition.Generated != tt.generated {
|
||||
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||
}
|
||||
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||
}
|
||||
for _, id := range tt.compatiblePriorIDs {
|
||||
if !definition.CompatibleWithPrior(id) {
|
||||
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedMetadata(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
||||
|
||||
@@ -14,6 +14,10 @@ func DefaultRegistry() Registry {
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "daily.md",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Morning: true,
|
||||
resolve: resolveDailyToday,
|
||||
},
|
||||
@@ -23,6 +27,10 @@ func DefaultRegistry() Registry {
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "tomorrow.md",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "tomorrow.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Evening: true,
|
||||
resolve: resolveDailyTomorrow,
|
||||
},
|
||||
@@ -32,6 +40,10 @@ func DefaultRegistry() Registry {
|
||||
PromptID: "weather.three_day_outlook",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "three_day.md",
|
||||
ArtifactGroup: "three-day",
|
||||
BatchOutputName: "three-day.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{ThreeDay},
|
||||
Morning: true,
|
||||
resolve: resolveThreeDay,
|
||||
},
|
||||
@@ -41,6 +53,10 @@ func DefaultRegistry() Registry {
|
||||
PromptID: "weather.weekend_outlook",
|
||||
ComparisonStrategy: CompareWeekendWindow,
|
||||
DefaultOutputName: "weekend.md",
|
||||
ArtifactGroup: "weekend",
|
||||
BatchOutputName: "weekend.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Weekend},
|
||||
Morning: true,
|
||||
resolve: resolveWeekend,
|
||||
},
|
||||
@@ -50,6 +66,10 @@ func DefaultRegistry() Registry {
|
||||
PromptID: "weather.storm_report",
|
||||
ComparisonStrategy: CompareExplicitWindow,
|
||||
DefaultOutputName: "storm.md",
|
||||
ArtifactGroup: "storm",
|
||||
BatchOutputName: "storm.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Storm},
|
||||
resolve: resolveStorm,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
||||
if metadata.RunID == "" {
|
||||
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
||||
}
|
||||
group, err := reportGroup(resolved.Definition.ID)
|
||||
if err != nil {
|
||||
return ArtifactPaths{}, err
|
||||
group := resolved.Definition.ArtifactGroup
|
||||
if group == "" {
|
||||
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||
}
|
||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
filenameBase := metadata.RunID
|
||||
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Briefing, nil
|
||||
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
|
||||
if err := promptinput.Validate(pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.DataPackage, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
|
||||
if result == nil {
|
||||
return "", fmt.Errorf("render result is required")
|
||||
}
|
||||
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Preflight, nil
|
||||
@@ -161,23 +158,19 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("metadata path cannot be resolved")
|
||||
}
|
||||
if err := writeJSONAtomic(path, metadata); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(path, metadata); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
return s.FindPriorSnapshot(ctx, resolved)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
||||
return nil, nil
|
||||
}
|
||||
group, err := reportGroup(resolved.Definition.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
group := resolved.Definition.ArtifactGroup
|
||||
if group == "" {
|
||||
return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||
}
|
||||
dirs, err := s.metadataDirectories(resolved, group)
|
||||
if err != nil {
|
||||
@@ -205,7 +198,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
||||
if metadata.RunID == resolved.Metadata().RunID {
|
||||
continue
|
||||
}
|
||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
||||
if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
|
||||
continue
|
||||
}
|
||||
if !comparablePeriod(metadata, resolved) {
|
||||
@@ -343,19 +336,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
|
||||
switch group {
|
||||
case "daily":
|
||||
return prior == report.DailyToday || prior == report.DailyTomorrow
|
||||
case "three-day":
|
||||
return prior == report.ThreeDay && current == report.ThreeDay
|
||||
case "weekend":
|
||||
return prior == report.Weekend && current == report.Weekend
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) join(parts ...string) string {
|
||||
all := append([]string{s.root}, parts...)
|
||||
return filepath.Join(all...)
|
||||
@@ -375,48 +355,6 @@ func validateRelativeDir(name string, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func reportGroup(id report.ID) (string, error) {
|
||||
switch id {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
return "daily", nil
|
||||
case report.ThreeDay:
|
||||
return "three-day", nil
|
||||
case report.Weekend:
|
||||
return "weekend", nil
|
||||
case report.Storm:
|
||||
return "storm", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown report %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %q: %w", path, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary file for %q: %w", path, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary file for %q: %w", path, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary file for %q: %w", path, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readJSON(path string, target any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDataPackage() error = %v", err)
|
||||
}
|
||||
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`})
|
||||
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
|
||||
if err != nil {
|
||||
t.Fatalf("SavePreflight() error = %v", err)
|
||||
}
|
||||
@@ -67,6 +66,17 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
||||
t.Fatalf("write rendered report: %v", err)
|
||||
}
|
||||
var preflight PreflightArtifact
|
||||
preflightData, err := os.ReadFile(preflightPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read preflight: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(preflightData, &preflight); err != nil {
|
||||
t.Fatalf("decode preflight: %v", err)
|
||||
}
|
||||
if preflight.Stdout != `{"ok":true}` {
|
||||
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
|
||||
}
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
@@ -114,7 +124,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorDailySnapshot(t *testing.T) {
|
||||
func TestFindPriorSnapshot(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||
@@ -138,12 +148,12 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
prior, err := store.FindPriorDailySnapshot(context.Background(), second)
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior == nil {
|
||||
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
|
||||
t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
|
||||
}
|
||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||
@@ -153,7 +163,7 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
||||
func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
@@ -177,12 +187,12 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate)
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior)
|
||||
t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ package state
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -14,11 +13,10 @@ type Store interface {
|
||||
Paths(report.Resolved) (ArtifactPaths, error)
|
||||
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
|
||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||
SaveMetadata(context.Context, Metadata) (string, error)
|
||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||
}
|
||||
|
||||
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
|
||||
Metadata Metadata
|
||||
BriefingPath string
|
||||
}
|
||||
|
||||
type PreflightArtifact struct {
|
||||
Command []string `json:"command"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user