Write reports to operator-selected outputs
This commit is contained in:
@@ -4,7 +4,9 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
@@ -42,6 +44,7 @@ const (
|
|||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Report ReportKind
|
Report ReportKind
|
||||||
|
WorkingDir string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
LLMDebugDir string
|
LLMDebugDir string
|
||||||
Now time.Time
|
Now time.Time
|
||||||
@@ -56,6 +59,7 @@ type BatchRequest struct {
|
|||||||
Config config.Config
|
Config config.Config
|
||||||
Batch BatchKind
|
Batch BatchKind
|
||||||
Now time.Time
|
Now time.Time
|
||||||
|
WorkingDir string
|
||||||
OutputDir string
|
OutputDir string
|
||||||
LLMDebugDir string
|
LLMDebugDir string
|
||||||
Collector Collector
|
Collector Collector
|
||||||
@@ -258,6 +262,11 @@ func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
outputPath, err := resolveReportOutputPath(req.WorkingDir, req.OutputPath, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.OutputPath = outputPath
|
||||||
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
@@ -302,6 +311,11 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
outputDir, err := resolveOutputDir(req.WorkingDir, req.OutputDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.OutputDir = outputDir
|
||||||
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||||
@@ -340,7 +354,10 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
for _, planned := range plannedReports {
|
for _, planned := range plannedReports {
|
||||||
resolved := planned.Resolved
|
resolved := planned.Resolved
|
||||||
item := batchReportResult(planned)
|
item := batchReportResult(planned)
|
||||||
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
outputPath, err := plannedBatchOutputPath(req.OutputDir, planned)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||||
GenerateRequest: GenerateRequest{
|
GenerateRequest: GenerateRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
@@ -440,18 +457,91 @@ func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
|
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
|
||||||
if outputDir == "" {
|
outputName, err := planned.Resolved.OutputName()
|
||||||
return ""
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
outputCopyName := planned.OutputCopyName
|
return validateOutputPath(filepath.Join(outputDir, outputName))
|
||||||
if outputCopyName == "" {
|
}
|
||||||
outputCopyName = planned.Resolved.Definition.BatchOutputName
|
|
||||||
|
func resolveReportOutputPath(workingDir, override string, resolved report.Resolved) (string, error) {
|
||||||
|
outputName, err := resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
if outputCopyName == "" {
|
return resolveOutputPath(workingDir, override, outputName)
|
||||||
return ""
|
}
|
||||||
|
|
||||||
|
func resolveOutputDir(workingDir, override string) (string, error) {
|
||||||
|
workingDir, err := validateWorkingDir(workingDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(outputDir, outputCopyName)
|
if override == "" {
|
||||||
|
return workingDir, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(override) == "" {
|
||||||
|
return "", fmt.Errorf("output directory is required")
|
||||||
|
}
|
||||||
|
directory := override
|
||||||
|
if !filepath.IsAbs(directory) {
|
||||||
|
directory = filepath.Join(workingDir, directory)
|
||||||
|
}
|
||||||
|
directory = filepath.Clean(directory)
|
||||||
|
if info, err := os.Stat(directory); err == nil && !info.IsDir() {
|
||||||
|
return "", fmt.Errorf("output directory %q is not a directory", directory)
|
||||||
|
} else if err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("inspect output directory %q: %w", directory, err)
|
||||||
|
}
|
||||||
|
return directory, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
|
||||||
|
workingDir, err := validateWorkingDir(workingDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := override
|
||||||
|
if path == "" {
|
||||||
|
path = defaultName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("final output path is required")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
path = filepath.Join(workingDir, path)
|
||||||
|
}
|
||||||
|
return validateOutputPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWorkingDir(workingDir string) (string, error) {
|
||||||
|
if strings.TrimSpace(workingDir) == "" {
|
||||||
|
return "", fmt.Errorf("working directory is required")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(workingDir) {
|
||||||
|
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
|
||||||
|
}
|
||||||
|
return filepath.Clean(workingDir), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateOutputPath(path string) (string, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return "", fmt.Errorf("final output path is required")
|
||||||
|
}
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return "", fmt.Errorf("final output path %q must be absolute", path)
|
||||||
|
}
|
||||||
|
if filepath.Dir(path) == path {
|
||||||
|
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||||
|
return "", fmt.Errorf("final output path %q is a directory", path)
|
||||||
|
} else if err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
@@ -590,7 +680,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
|
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata, req.Notifier, req.Store)
|
||||||
if notificationPath != "" {
|
if notificationPath != "" {
|
||||||
result.NotificationPath = notificationPath
|
result.NotificationPath = notificationPath
|
||||||
result.Notification = notification
|
result.Notification = notification
|
||||||
@@ -636,7 +726,7 @@ func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, notificationPath, &NotificationError{
|
return result, notificationPath, &NotificationError{
|
||||||
Request: notificationRequest,
|
Request: notificationRequest,
|
||||||
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
|
Err: fmt.Errorf("notify report %q run %q from output %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, notificationPath, nil
|
return result, notificationPath, nil
|
||||||
@@ -655,7 +745,7 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
|
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
|
||||||
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
|
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, filepath.Base(reportPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -688,16 +778,20 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) {
|
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, outputName string) (config.DistributorTemplateValues, error) {
|
||||||
values := config.DistributorTemplateValues{
|
values := config.DistributorTemplateValues{
|
||||||
LocationID: cfg.Location.ID,
|
LocationID: cfg.Location.ID,
|
||||||
ReportID: string(resolved.Definition.ID),
|
ReportID: string(resolved.Definition.ID),
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
||||||
BatchOutputName: batchOutputName,
|
BatchOutputName: outputName,
|
||||||
}
|
}
|
||||||
if values.BatchOutputName == "" {
|
if values.BatchOutputName == "" {
|
||||||
values.BatchOutputName = resolved.Definition.BatchOutputName
|
var err error
|
||||||
|
values.BatchOutputName, err = resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
return config.DistributorTemplateValues{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
||||||
return config.DistributorTemplateValues{}, err
|
return config.DistributorTemplateValues{}, err
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) {
|
|||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
now := mustParse(test.now)
|
now := mustParse(test.now)
|
||||||
req := BatchRequest{Config: cfg, Batch: test.batch, Now: now}
|
req := BatchRequest{Config: cfg, Batch: test.batch, Now: now, WorkingDir: t.TempDir()}
|
||||||
candidates, err := batchInspectionCandidates(req, now)
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
@@ -153,15 +154,15 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
|
|||||||
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
if item.ReportPath == "" {
|
if item.OutputPath == "" {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing output path", item.ReportID, item.RunID)
|
||||||
}
|
}
|
||||||
|
|
||||||
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName)
|
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, filepath.Base(item.OutputPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.OutputPath, err)
|
||||||
}
|
}
|
||||||
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.OutputPath, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batchNotificationRequest{}, err
|
return batchNotificationRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -169,18 +170,18 @@ func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID str
|
|||||||
included := BatchNotificationReport{
|
included := BatchNotificationReport{
|
||||||
ReportID: item.ReportID,
|
ReportID: item.ReportID,
|
||||||
RunID: item.RunID,
|
RunID: item.RunID,
|
||||||
SourcePath: item.ReportPath,
|
SourcePath: item.OutputPath,
|
||||||
BundlePaths: append([]string(nil), bundlePaths...),
|
BundlePaths: append([]string(nil), bundlePaths...),
|
||||||
}
|
}
|
||||||
for _, bundlePath := range bundlePaths {
|
for _, bundlePath := range bundlePaths {
|
||||||
file := batchNotificationFile{
|
file := batchNotificationFile{
|
||||||
ReportID: item.ReportID,
|
ReportID: item.ReportID,
|
||||||
RunID: item.RunID,
|
RunID: item.RunID,
|
||||||
SourcePath: item.ReportPath,
|
SourcePath: item.OutputPath,
|
||||||
BundlePath: bundlePath,
|
BundlePath: bundlePath,
|
||||||
}
|
}
|
||||||
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
||||||
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.OutputPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
||||||
}
|
}
|
||||||
seenBundlePaths[bundlePath] = file
|
seenBundlePaths[bundlePath] = file
|
||||||
req.Files = append(req.Files, file)
|
req.Files = append(req.Files, file)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
type plannedBatchReport struct {
|
type plannedBatchReport struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
OutputCopyName string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
||||||
@@ -36,16 +35,16 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
|
|||||||
var planned []plannedBatchReport
|
var planned []plannedBatchReport
|
||||||
switch batch {
|
switch batch {
|
||||||
case report.Morning:
|
case report.Morning:
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
|
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
case report.Evening:
|
case report.Evening:
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -60,8 +59,7 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
|
|||||||
for _, date := range eligibleDailyDates(hourly, now, location) {
|
for _, date := range eligibleDailyDates(hourly, now, location) {
|
||||||
dailyReq := resolveReq
|
dailyReq := resolveReq
|
||||||
dailyReq.Date = date
|
dailyReq.Date = date
|
||||||
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
|
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq)
|
||||||
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -69,15 +67,12 @@ func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([
|
|||||||
return planned, nil
|
return planned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
|
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest) ([]plannedBatchReport, error) {
|
||||||
resolved, err := registry.Resolve(id, req)
|
resolved, err := registry.Resolve(id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return append(planned, plannedBatchReport{
|
return append(planned, plannedBatchReport{Resolved: resolved}), nil
|
||||||
Resolved: resolved,
|
|
||||||
OutputCopyName: outputCopyName,
|
|
||||||
}), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
|||||||
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
func TestPlanBatchRunUsesResolvedOutputNames(t *testing.T) {
|
||||||
location := mustLoadTestLocation(t, "America/Chicago")
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
@@ -68,11 +68,19 @@ func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
|||||||
if len(daily) != 1 {
|
if len(daily) != 1 {
|
||||||
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
||||||
}
|
}
|
||||||
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
|
outputName, err := daily[0].Resolved.OutputName()
|
||||||
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
|
if err != nil {
|
||||||
|
t.Fatalf("OutputName() error = %v", err)
|
||||||
}
|
}
|
||||||
if planned[0].OutputCopyName != "" {
|
if outputName != "daily-2026-05-31.md" {
|
||||||
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
|
t.Fatalf("Daily output name = %q, want date-qualified name", outputName)
|
||||||
|
}
|
||||||
|
outputName, err = planned[0].Resolved.OutputName()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OutputName() error = %v", err)
|
||||||
|
}
|
||||||
|
if outputName != "tomorrow.md" {
|
||||||
|
t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
|
|||||||
outputDir := filepath.Join(t.TempDir(), "output")
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
debugRoot := filepath.Join(t.TempDir(), "debug")
|
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: test.batch, Now: test.now, OutputDir: outputDir, LLMDebugDir: debugRoot,
|
Config: cfg, Batch: test.batch, Now: test.now, WorkingDir: t.TempDir(), OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||||
Collector: collector, Executor: executor,
|
Collector: collector, Executor: executor,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -166,7 +166,7 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertBatchItemMatchesMetadata(t, item)
|
assertBatchItemMatchesMetadata(t, item)
|
||||||
if filepath.Base(item.OutputPath) != test.wantCopies[index] {
|
if filepath.Base(item.OutputPath) != test.wantCopies[index] {
|
||||||
t.Fatalf("output copy = %q, want %q", item.OutputPath, test.wantCopies[index])
|
t.Fatalf("output = %q, want %q", item.OutputPath, test.wantCopies[index])
|
||||||
}
|
}
|
||||||
assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath)
|
assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath)
|
||||||
managed, readErr := os.ReadFile(item.ReportPath)
|
managed, readErr := os.ReadFile(item.ReportPath)
|
||||||
@@ -175,7 +175,7 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
|
|||||||
}
|
}
|
||||||
copied, readErr := os.ReadFile(item.OutputPath)
|
copied, readErr := os.ReadFile(item.OutputPath)
|
||||||
if readErr != nil || !bytes.Equal(managed, copied) {
|
if readErr != nil || !bytes.Equal(managed, copied) {
|
||||||
t.Fatalf("output copy mismatch/error = %v", readErr)
|
t.Fatalf("output mismatch/error = %v", readErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -190,7 +190,7 @@ func TestRunBatchDetailedContinuesAfterCapacityRejection(t *testing.T) {
|
|||||||
executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
||||||
notifier := &assembledBatchNotifier{}
|
notifier := &assembledBatchNotifier{}
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"),
|
OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"),
|
||||||
Collector: collector, Executor: executor, Notifier: notifier,
|
Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
})
|
})
|
||||||
@@ -226,7 +226,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
notifier := &assembledBatchNotifier{}
|
notifier := &assembledBatchNotifier{}
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||||
@@ -240,7 +240,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
notifier := &assembledBatchNotifier{}
|
notifier := &assembledBatchNotifier{}
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||||
@@ -257,7 +257,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
outputDir := filepath.Join(t.TempDir(), "output")
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), OutputDir: outputDir,
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,9 +266,9 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 {
|
if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 {
|
||||||
t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests))
|
t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
}
|
}
|
||||||
managedPaths := make(map[string]struct{}, len(result.Reports))
|
outputPaths := make(map[string]struct{}, len(result.Reports))
|
||||||
for _, item := range result.Reports {
|
for _, item := range result.Reports {
|
||||||
managedPaths[item.ReportPath] = struct{}{}
|
outputPaths[item.OutputPath] = struct{}{}
|
||||||
if item.NotificationPath != "" {
|
if item.NotificationPath != "" {
|
||||||
t.Fatalf("report item contains per-report notification path: %#v", item)
|
t.Fatalf("report item contains per-report notification path: %#v", item)
|
||||||
}
|
}
|
||||||
@@ -278,8 +278,8 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports))
|
t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports))
|
||||||
}
|
}
|
||||||
for _, file := range request.Files {
|
for _, file := range request.Files {
|
||||||
if _, ok := managedPaths[file.SourcePath]; !ok || strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" {
|
if _, ok := outputPaths[file.SourcePath]; !ok || !strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" {
|
||||||
t.Fatalf("notification file = %#v, want managed Markdown source", file)
|
t.Fatalf("notification file = %#v, want selected Markdown output source", file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
@@ -293,7 +293,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")}
|
notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")}
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,7 +321,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
|||||||
StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
}}
|
}}
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
|
if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
|
||||||
@@ -340,7 +340,7 @@ func TestRunBatchDetailedKeepsDynamicDailyArtifactsDistinct(t *testing.T) {
|
|||||||
outputDir := filepath.Join(t.TempDir(), "output")
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
debugRoot := filepath.Join(t.TempDir(), "debug")
|
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, LLMDebugDir: debugRoot,
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(),
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
})
|
})
|
||||||
if err != nil || result.Failed != 0 || len(result.Reports) != 3 {
|
if err != nil || result.Failed != 0 || len(result.Reports) != 3 {
|
||||||
|
|||||||
129
internal/app/output_test.go
Normal file
129
internal/app/output_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveReportOutputPath(t *testing.T) {
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
report ReportKind
|
||||||
|
date string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{report: ReportDaily, date: "2026-05-30T12:00:00-05:00", name: "daily-2026-05-30.md"},
|
||||||
|
{report: ReportToday, date: "2026-05-29T12:00:00-05:00", name: "today.md"},
|
||||||
|
{report: ReportTomorrow, name: "tomorrow.md"},
|
||||||
|
{report: ReportHourly, name: "hourly.md"},
|
||||||
|
} {
|
||||||
|
t.Run(string(test.report), func(t *testing.T) {
|
||||||
|
req := GenerateRequest{Config: cfg, Report: test.report}
|
||||||
|
if test.date != "" {
|
||||||
|
req.Date = workflowTime(test.date)
|
||||||
|
}
|
||||||
|
resolved, err := ResolveGenerate(req, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
path, err := resolveReportOutputPath(workingDir, "", resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveReportOutputPath() error = %v", err)
|
||||||
|
}
|
||||||
|
if path != filepath.Join(workingDir, test.name) {
|
||||||
|
t.Fatalf("path = %q, want %q", path, filepath.Join(workingDir, test.name))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-30T12:00:00-05:00"),
|
||||||
|
}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
absoluteDir := t.TempDir()
|
||||||
|
for _, test := range []struct {
|
||||||
|
override string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{override: filepath.Join("reports", "custom.md"), want: filepath.Join(workingDir, "reports", "custom.md")},
|
||||||
|
{override: filepath.Join(absoluteDir, "custom.md"), want: filepath.Join(absoluteDir, "custom.md")},
|
||||||
|
} {
|
||||||
|
path, err := resolveReportOutputPath(workingDir, test.override, resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveReportOutputPath(%q) error = %v", test.override, err)
|
||||||
|
}
|
||||||
|
if path != filepath.Clean(test.want) {
|
||||||
|
t.Fatalf("path = %q, want %q", path, filepath.Clean(test.want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsInvalidOutputBeforeCollection(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
collector := &workflowCollector{err: errors.New("collection must not run")}
|
||||||
|
_, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: t.TempDir(),
|
||||||
|
Collector: collector,
|
||||||
|
})
|
||||||
|
if err == nil || collector.calls != 0 {
|
||||||
|
t.Fatalf("GenerateDetailed() error/calls = %v/%d, want invalid output before collection", err, collector.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPreservesExistingOutputWhenGenerationFails(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
outputPath := filepath.Join(workingDir, "daily-2026-05-29.md")
|
||||||
|
if err := os.WriteFile(outputPath, []byte("existing report"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write existing output: %v", err)
|
||||||
|
}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"),
|
||||||
|
Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir,
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
|
||||||
|
Executor: &workflowExecutor{definition: report.DefaultRegistry().MustLookup(report.Daily), raw: []byte(`{}`)},
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || result.OutputPath != "" {
|
||||||
|
t.Fatalf("result/error/output = %#v/%v/%q, want failed generation without output publication", result, err, result.OutputPath)
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(outputPath)
|
||||||
|
if readErr != nil || string(data) != "existing report" {
|
||||||
|
t.Fatalf("output after failure = %q, error %v, want preserved content", data, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedUsesWorkingDirectoryForOutput(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
workingDir := t.TempDir()
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: workingDir,
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
|
||||||
|
Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.Failed != 0 {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
if len(result.Reports) != 2 {
|
||||||
|
t.Fatalf("reports = %#v, want Tomorrow and Daily", result.Reports)
|
||||||
|
}
|
||||||
|
if result.Reports[0].OutputPath != filepath.Join(workingDir, "tomorrow.md") ||
|
||||||
|
result.Reports[1].OutputPath != filepath.Join(workingDir, "daily-2026-05-31.md") {
|
||||||
|
t.Fatalf("output paths = %#v, want working-directory defaults", result.Reports)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
failOperation string
|
failOperation string
|
||||||
failMetadataCall int
|
failMetadataCall int
|
||||||
outputCopy bool
|
output bool
|
||||||
notify bool
|
notify bool
|
||||||
want reachedPromptArtifacts
|
want reachedPromptArtifacts
|
||||||
}{
|
}{
|
||||||
@@ -151,7 +151,7 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
|
|||||||
{name: "normalized output then metadata", failOperation: failMetadata, failMetadataCall: 3, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true}},
|
{name: "normalized output then metadata", failOperation: failMetadata, failMetadataCall: 3, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true}},
|
||||||
{name: "render context then metadata", failOperation: failMetadata, failMetadataCall: 4, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}},
|
{name: "render context then metadata", failOperation: failMetadata, failMetadataCall: 4, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}},
|
||||||
{name: "managed report then metadata", failOperation: failMetadata, failMetadataCall: 5, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}},
|
{name: "managed report then metadata", failOperation: failMetadata, failMetadataCall: 5, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}},
|
||||||
{name: "output copy then metadata", failOperation: failMetadata, failMetadataCall: 5, outputCopy: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}},
|
{name: "output then metadata", failOperation: failMetadata, failMetadataCall: 5, output: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}},
|
||||||
{name: "notification then metadata", failOperation: failMetadata, failMetadataCall: 6, notify: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, notification: true}},
|
{name: "notification then metadata", failOperation: failMetadata, failMetadataCall: 6, notify: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, notification: true}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
|
|||||||
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
store := &failingPersistenceStore{Store: req.Store, failOperation: test.failOperation, failMetadataCall: test.failMetadataCall}
|
store := &failingPersistenceStore{Store: req.Store, failOperation: test.failOperation, failMetadataCall: test.failMetadataCall}
|
||||||
req.Store = store
|
req.Store = store
|
||||||
if test.outputCopy {
|
if test.output {
|
||||||
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
paths.output = req.OutputPath
|
paths.output = req.OutputPath
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T)
|
|||||||
failExecutionCall int
|
failExecutionCall int
|
||||||
failMetadataCall int
|
failMetadataCall int
|
||||||
requestOutput bool
|
requestOutput bool
|
||||||
failOutputCopy bool
|
failOutput bool
|
||||||
notify bool
|
notify bool
|
||||||
notificationFailure bool
|
notificationFailure bool
|
||||||
wantExecution reachedExecutionArtifacts
|
wantExecution reachedExecutionArtifacts
|
||||||
@@ -323,17 +323,17 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T)
|
|||||||
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "output copy write", requestOutput: true, failOutputCopy: true,
|
name: "output write", requestOutput: true, failOutput: true,
|
||||||
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
||||||
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "output copy checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true,
|
name: "output checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true,
|
||||||
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
||||||
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "output copy metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true,
|
name: "output metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true,
|
||||||
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
},
|
},
|
||||||
@@ -374,7 +374,7 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T)
|
|||||||
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
paths.output = req.OutputPath
|
paths.output = req.OutputPath
|
||||||
}
|
}
|
||||||
if test.failOutputCopy {
|
if test.failOutput {
|
||||||
blocker := filepath.Join(t.TempDir(), "not-a-directory")
|
blocker := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
|
if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
|
||||||
t.Fatalf("write output blocker: %v", err)
|
t.Fatalf("write output blocker: %v", err)
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
|
|||||||
outputPath := filepath.Join(t.TempDir(), test.name+".md")
|
outputPath := filepath.Join(t.TempDir(), test.name+".md")
|
||||||
|
|
||||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
|
OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,10 +203,10 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
copied, readErr := os.ReadFile(outputPath)
|
copied, readErr := os.ReadFile(outputPath)
|
||||||
if readErr != nil || !bytes.Equal(copied, managed) || result.OutputPath != outputPath {
|
if readErr != nil || !bytes.Equal(copied, managed) || result.OutputPath != outputPath {
|
||||||
t.Fatalf("output copy mismatch/error/path = %v/%q", readErr, result.OutputPath)
|
t.Fatalf("output mismatch/error/path = %v/%q", readErr, result.OutputPath)
|
||||||
}
|
}
|
||||||
if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath || notifier.requests[0].ReportPath == outputPath {
|
if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != outputPath {
|
||||||
t.Fatalf("notification requests = %#v, want managed report source", notifier.requests)
|
t.Fatalf("notification requests = %#v, want selected output source", notifier.requests)
|
||||||
}
|
}
|
||||||
wantPipeline := "reports." + string(test.id) + "." + definition.ArtifactGroup
|
wantPipeline := "reports." + string(test.id) + "." + definition.ArtifactGroup
|
||||||
if notifier.requests[0].PipelineID != wantPipeline {
|
if notifier.requests[0].PipelineID != wantPipeline {
|
||||||
@@ -257,7 +257,7 @@ func TestGenerateDetailedPreservesSelectedProfileThroughExecution(t *testing.T)
|
|||||||
}
|
}
|
||||||
bundle := workflowBundle(t)
|
bundle := workflowBundle(t)
|
||||||
_, err := GenerateDetailed(context.Background(), GenerateRequest{
|
_, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
Config: cfg, Report: test.kind, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
Config: cfg, Report: test.kind, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Notifier: &workflowNotifier{},
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Notifier: &workflowNotifier{},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,7 +344,7 @@ func TestGenerateDetailedStopsAtConsequentialPromptFailures(t *testing.T) {
|
|||||||
bundle := workflowBundle(t)
|
bundle := workflowBundle(t)
|
||||||
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: collector, Executor: executor,
|
Collector: collector, Executor: executor,
|
||||||
})
|
})
|
||||||
if err == nil || result == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
if err == nil || result == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||||
@@ -384,7 +384,7 @@ func TestGenerateDetailedRejectsInspectionAndCredentialsBeforeCollection(t *test
|
|||||||
test.configure(executor)
|
test.configure(executor)
|
||||||
collector := &workflowCollector{err: errors.New("collector must not run")}
|
collector := &workflowCollector{err: errors.New("collector must not run")}
|
||||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: collector, Executor: executor,
|
Collector: collector, Executor: executor,
|
||||||
})
|
})
|
||||||
if err == nil || result != nil || promptexec.CategoryOf(err) != test.wantCategory || collector.calls != 0 || executor.executeCalls != 0 {
|
if err == nil || result != nil || promptexec.CategoryOf(err) != test.wantCategory || collector.calls != 0 || executor.executeCalls != 0 {
|
||||||
@@ -412,7 +412,7 @@ func TestGenerateDetailedStopsProviderWhenPreparationCannotPersist(t *testing.T)
|
|||||||
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
|
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
bundle := workflowBundle(t)
|
bundle := workflowBundle(t)
|
||||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Store: preparationFailingStore{Store: filesystem},
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Store: preparationFailingStore{Store: filesystem},
|
||||||
})
|
})
|
||||||
if err == nil || result == nil || result.PreparationPath != "" || executor.providerCalls != 0 {
|
if err == nil || result == nil || result.PreparationPath != "" || executor.providerCalls != 0 {
|
||||||
@@ -424,7 +424,7 @@ func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T
|
|||||||
cfg := workflowConfig(t)
|
cfg := workflowConfig(t)
|
||||||
cfg.Notify.Distributor.Enabled = false
|
cfg.Notify.Distributor.Enabled = false
|
||||||
now := workflowTime("2026-05-29T08:30:00-05:00")
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
|
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now, WorkingDir: t.TempDir()}
|
||||||
resolved, err := ResolveGenerate(request, now)
|
resolved, err := ResolveGenerate(request, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
@@ -450,7 +450,7 @@ func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T
|
|||||||
request.Executor = executor
|
request.Executor = executor
|
||||||
request.Store = filesystem
|
request.Store = filesystem
|
||||||
result, err := GenerateDetailed(context.Background(), request)
|
result, err := GenerateDetailed(context.Background(), request)
|
||||||
if err != nil || result == nil || !checked || result.OutputPath != "" {
|
if err != nil || result == nil || !checked || result.OutputPath == "" {
|
||||||
t.Fatalf("result/error/checked/output = %#v/%v/%t/%q", result, err, checked, result.OutputPath)
|
t.Fatalf("result/error/checked/output = %#v/%v/%t/%q", result, err, checked, result.OutputPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,9 +482,6 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
|
|||||||
}
|
}
|
||||||
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderedReportPath, renderedReportPath: blocker}
|
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderedReportPath, renderedReportPath: blocker}
|
||||||
}, wantRaw: true, wantNormalized: true, wantContext: true},
|
}, wantRaw: true, wantNormalized: true, wantContext: true},
|
||||||
{name: "output copy", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
|
||||||
req.OutputPath = t.TempDir()
|
|
||||||
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true},
|
|
||||||
{name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) {
|
{name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) {
|
||||||
notifier.err = errors.New("notification rejected")
|
notifier.err = errors.New("notification rejected")
|
||||||
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true, wantOutput: true, wantNotify: true},
|
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true, wantOutput: true, wantNotify: true},
|
||||||
@@ -502,7 +499,7 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
|
|||||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
}
|
}
|
||||||
req := GenerateRequest{
|
req := GenerateRequest{
|
||||||
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(),
|
||||||
OutputPath: filepath.Join(t.TempDir(), "daily.md"), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
|
OutputPath: filepath.Join(t.TempDir(), "daily.md"), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
|
||||||
Executor: executor, Notifier: notifier, Store: filesystem,
|
Executor: executor, Notifier: notifier, Store: filesystem,
|
||||||
}
|
}
|
||||||
@@ -524,7 +521,7 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
|
|||||||
t.Fatalf("retained raw output = %q, error %v", persisted, readErr)
|
t.Fatalf("retained raw output = %q, error %v", persisted, readErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath) {
|
if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.OutputPath) {
|
||||||
t.Fatalf("notification requests = %#v", notifier.requests)
|
t.Fatalf("notification requests = %#v", notifier.requests)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -560,7 +557,7 @@ func TestGenerateDetailedDebugFailuresRespectProviderBoundary(t *testing.T) {
|
|||||||
cfg := workflowConfig(t)
|
cfg := workflowConfig(t)
|
||||||
cfg.Notify.Distributor.Enabled = false
|
cfg.Notify.Distributor.Enabled = false
|
||||||
now := workflowTime("2026-05-29T08:30:00-05:00")
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
|
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now, WorkingDir: t.TempDir()}
|
||||||
resolved, err := ResolveGenerate(request, now)
|
resolved, err := ResolveGenerate(request, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
|
||||||
@@ -37,9 +39,9 @@ Options:
|
|||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
--out PATH Write the generated Markdown report to PATH.
|
||||||
--llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace.
|
--llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace.
|
||||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
--out-dir PATH Write generated Markdown reports beneath PATH for run commands.
|
||||||
--quiet Suppress successful generate and run output.
|
--quiet Suppress successful generate and run output.
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ type Runner struct {
|
|||||||
Clock timeutil.Clock
|
Clock timeutil.Clock
|
||||||
ExecutorFactory ExecutorFactory
|
ExecutorFactory ExecutorFactory
|
||||||
Version string
|
Version string
|
||||||
|
WorkingDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||||
@@ -240,10 +243,20 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
|||||||
return app.GenerateRequest{}, commonOptions{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workingDir, err := r.workingDir()
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
outputPath, err := resolveOutputOverride(workingDir, opts.Output)
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
|
||||||
req := app.GenerateRequest{
|
req := app.GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: reportKind,
|
Report: reportKind,
|
||||||
OutputPath: opts.Output,
|
WorkingDir: workingDir,
|
||||||
|
OutputPath: outputPath,
|
||||||
LLMDebugDir: opts.LLMDebugDir,
|
LLMDebugDir: opts.LLMDebugDir,
|
||||||
Now: r.Clock.Now(),
|
Now: r.Clock.Now(),
|
||||||
Executor: executor,
|
Executor: executor,
|
||||||
@@ -304,7 +317,15 @@ func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return app.BatchRequest{}, commonOptions{}, err
|
return app.BatchRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
|
workingDir, err := r.workingDir()
|
||||||
|
if err != nil {
|
||||||
|
return app.BatchRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir)
|
||||||
|
if err != nil {
|
||||||
|
return app.BatchRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), WorkingDir: workingDir, OutputDir: outputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRun(args []string) (app.BatchRequest, error) {
|
func resolveRun(args []string) (app.BatchRequest, error) {
|
||||||
@@ -334,7 +355,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
|||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
opts := commonOptions{}
|
opts := commonOptions{}
|
||||||
addCommonFlags(fs, &opts, false)
|
addCommonFlags(fs, &opts, false)
|
||||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
|
fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
|
||||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return commonOptions{}, err
|
return commonOptions{}, err
|
||||||
@@ -384,6 +405,31 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
|||||||
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
||||||
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
|
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
|
||||||
if includeOutput {
|
if includeOutput {
|
||||||
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Runner) workingDir() (string, error) {
|
||||||
|
workingDir := r.WorkingDir
|
||||||
|
if workingDir == "" {
|
||||||
|
var err error
|
||||||
|
workingDir, err = os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get working directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(workingDir) {
|
||||||
|
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
|
||||||
|
}
|
||||||
|
return filepath.Clean(workingDir), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOutputOverride(workingDir, value string) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(value) {
|
||||||
|
value = filepath.Join(workingDir, value)
|
||||||
|
}
|
||||||
|
return filepath.Clean(value), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ func TestRunnerHelpListsOnlySupportedCommands(t *testing.T) {
|
|||||||
t.Fatalf("help contains retired command %q:\n%s", retired, output.stdout)
|
t.Fatalf("help contains retired command %q:\n%s", retired, output.stdout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, description := range []string{"Write the generated Markdown report to PATH.", "Write generated Markdown reports beneath PATH"} {
|
||||||
|
if !strings.Contains(output.stdout, description) {
|
||||||
|
t.Fatalf("help missing output description %q:\n%s", description, output.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerVersion(t *testing.T) {
|
func TestRunnerVersion(t *testing.T) {
|
||||||
@@ -185,6 +190,7 @@ func TestResolveSupportedCommandsAndFlags(t *testing.T) {
|
|||||||
func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
|
func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
|
||||||
configPath := writeCLIConfig(t, t.TempDir(), "")
|
configPath := writeCLIConfig(t, t.TempDir(), "")
|
||||||
runner, _ := countingRunner(cliExecutor{})
|
runner, _ := countingRunner(cliExecutor{})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
|
|
||||||
generate, generateOpts, err := runner.resolveGenerateAction([]string{
|
generate, generateOpts, err := runner.resolveGenerateAction([]string{
|
||||||
"daily", "--config", configPath, "--date", "2026-05-30", "--units", "metric", "--tz", "UTC",
|
"daily", "--config", configPath, "--date", "2026-05-30", "--units", "metric", "--tz", "UTC",
|
||||||
@@ -193,7 +199,7 @@ func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveGenerateAction() error = %v", err)
|
t.Fatalf("resolveGenerateAction() error = %v", err)
|
||||||
}
|
}
|
||||||
if generate.Config.WeatherAPI.Units != "metric" || generate.Config.WeatherAPI.Timezone != "UTC" || generate.OutputPath != "daily.md" || generate.LLMDebugDir != "/safe/debug" || !generateOpts.Quiet {
|
if generate.Config.WeatherAPI.Units != "metric" || generate.Config.WeatherAPI.Timezone != "UTC" || generate.OutputPath != filepath.Join(runner.WorkingDir, "daily.md") || generate.WorkingDir != runner.WorkingDir || generate.LLMDebugDir != "/safe/debug" || !generateOpts.Quiet {
|
||||||
t.Fatalf("generate request/options = %#v/%#v", generate, generateOpts)
|
t.Fatalf("generate request/options = %#v/%#v", generate, generateOpts)
|
||||||
}
|
}
|
||||||
if got := generate.Date.Format(timeutil.DateLayout); got != "2026-05-30" {
|
if got := generate.Date.Format(timeutil.DateLayout); got != "2026-05-30" {
|
||||||
@@ -207,7 +213,7 @@ func TestResolveGenerateAndRunApplySharedActionFlags(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveRunAction() error = %v", err)
|
t.Fatalf("resolveRunAction() error = %v", err)
|
||||||
}
|
}
|
||||||
if batch.Config.WeatherAPI.Units != "metric" || batch.Config.WeatherAPI.Timezone != "UTC" || batch.OutputDir != "reports" || batch.LLMDebugDir != "/safe/debug" || !batchOpts.Quiet {
|
if batch.Config.WeatherAPI.Units != "metric" || batch.Config.WeatherAPI.Timezone != "UTC" || batch.OutputDir != filepath.Join(runner.WorkingDir, "reports") || batch.WorkingDir != runner.WorkingDir || batch.LLMDebugDir != "/safe/debug" || !batchOpts.Quiet {
|
||||||
t.Fatalf("batch request/options = %#v/%#v", batch, batchOpts)
|
t.Fatalf("batch request/options = %#v/%#v", batch, batchOpts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,6 +286,7 @@ func TestRunnerSuccessfulSingleAndBatchActions(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fixture := newCLIFixture(t)
|
fixture := newCLIFixture(t)
|
||||||
runner, constructions := countingRunner(cliExecutor{})
|
runner, constructions := countingRunner(cliExecutor{})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
output, err := runCLICommand(runner, tt.args(fixture.configPath, fixture.path("copies"))...)
|
output, err := runCLICommand(runner, tt.args(fixture.configPath, fixture.path("copies"))...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
@@ -314,6 +321,7 @@ func TestRunnerSuccessfulSingleAndBatchActions(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
|
func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
|
||||||
runner, constructions := countingRunner(cliExecutor{})
|
runner, constructions := countingRunner(cliExecutor{})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
output, err := runCLICommand(runner, "generate", "daily")
|
output, err := runCLICommand(runner, "generate", "daily")
|
||||||
if err == nil || output.stdout != "" || output.stderr != "" {
|
if err == nil || output.stdout != "" || output.stderr != "" {
|
||||||
t.Fatalf("pre-run output/error = %#v/%v, want error without summary", output, err)
|
t.Fatalf("pre-run output/error = %#v/%v, want error without summary", output, err)
|
||||||
@@ -324,6 +332,7 @@ func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
|
|||||||
|
|
||||||
fixture := newCLIFixture(t)
|
fixture := newCLIFixture(t)
|
||||||
runner, _ = countingRunner(cliExecutor{})
|
runner, _ = countingRunner(cliExecutor{})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
output, err = runCLICommand(runner, "generate", "today", "--config", fixture.configPath, "--quiet")
|
output, err = runCLICommand(runner, "generate", "today", "--config", fixture.configPath, "--quiet")
|
||||||
if err != nil || output.stdout != "" || output.stderr != "" {
|
if err != nil || output.stdout != "" || output.stderr != "" {
|
||||||
t.Fatalf("quiet output/error = %#v/%v", output, err)
|
t.Fatalf("quiet output/error = %#v/%v", output, err)
|
||||||
@@ -333,6 +342,7 @@ func TestRunnerPreRunFailureAndQuietMode(t *testing.T) {
|
|||||||
func TestRunnerFailedActionReportsSafePartialSummary(t *testing.T) {
|
func TestRunnerFailedActionReportsSafePartialSummary(t *testing.T) {
|
||||||
fixture := newCLIFixture(t)
|
fixture := newCLIFixture(t)
|
||||||
runner, constructions := countingRunner(cliExecutor{fail: true})
|
runner, constructions := countingRunner(cliExecutor{fail: true})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
output, err := runCLICommand(runner, "generate", "today", "--config", fixture.configPath)
|
output, err := runCLICommand(runner, "generate", "today", "--config", fixture.configPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Run() error = nil, want execution failure")
|
t.Fatal("Run() error = nil, want execution failure")
|
||||||
@@ -364,6 +374,7 @@ func TestRunnerFailedActionReportsSafePartialSummary(t *testing.T) {
|
|||||||
func TestRunnerMixedBatchReportsSafePartialFailure(t *testing.T) {
|
func TestRunnerMixedBatchReportsSafePartialFailure(t *testing.T) {
|
||||||
fixture := newCLIFixture(t)
|
fixture := newCLIFixture(t)
|
||||||
runner, constructions := countingRunner(cliExecutor{failPrompt: "weather.tomorrow_generated_text"})
|
runner, constructions := countingRunner(cliExecutor{failPrompt: "weather.tomorrow_generated_text"})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
output, err := runCLICommand(runner, "run", "morning", "--config", fixture.configPath)
|
output, err := runCLICommand(runner, "run", "morning", "--config", fixture.configPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Run() error = nil, want aggregate batch failure")
|
t.Fatal("Run() error = nil, want aggregate batch failure")
|
||||||
@@ -399,6 +410,7 @@ func TestRunnerMixedBatchReportsSafePartialFailure(t *testing.T) {
|
|||||||
func TestRunnerInspectsReportsAndCurrentArtifacts(t *testing.T) {
|
func TestRunnerInspectsReportsAndCurrentArtifacts(t *testing.T) {
|
||||||
fixture := newCLIFixture(t)
|
fixture := newCLIFixture(t)
|
||||||
runner, _ := countingRunner(cliExecutor{})
|
runner, _ := countingRunner(cliExecutor{})
|
||||||
|
runner.WorkingDir = t.TempDir()
|
||||||
first := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC))
|
first := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC))
|
||||||
second := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC))
|
second := runSuccessfulGenerate(t, runner, fixture.configPath, time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func dailyDefinition() Definition {
|
|||||||
GeneratedTextSchemaID: "daily",
|
GeneratedTextSchemaID: "daily",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "daily.md",
|
OutputName: "daily.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
"daily/{valid_start_date}/{run_id}.md",
|
"daily/{valid_start_date}/{run_id}.md",
|
||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type Definition struct {
|
|||||||
GeneratedTextSchemaID string
|
GeneratedTextSchemaID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
ComparisonStrategy ComparisonStrategy
|
||||||
ArtifactGroup string
|
ArtifactGroup string
|
||||||
BatchOutputName string
|
OutputName string
|
||||||
DistributorPathTemplates []string
|
DistributorPathTemplates []string
|
||||||
CompatiblePriorIDs []ID
|
CompatiblePriorIDs []ID
|
||||||
Modules []module.ConfigItem
|
Modules []module.ConfigItem
|
||||||
@@ -52,6 +52,23 @@ type Definition struct {
|
|||||||
runIDDisambiguator func(Resolved) string
|
runIDDisambiguator func(Resolved) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Resolved) OutputName() (string, error) {
|
||||||
|
if r.Definition.ID == Daily {
|
||||||
|
if r.ValidPeriod.Start.IsZero() {
|
||||||
|
return "", fmt.Errorf("daily report has no valid-period start for output naming")
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(r.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load report timezone for output naming: %w", err)
|
||||||
|
}
|
||||||
|
return "daily-" + r.ValidPeriod.Start.In(location).Format(timeutil.DateLayout) + ".md", nil
|
||||||
|
}
|
||||||
|
if r.Definition.OutputName == "" {
|
||||||
|
return "", fmt.Errorf("report %q has no output name", r.Definition.ID)
|
||||||
|
}
|
||||||
|
return r.Definition.OutputName, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||||
if d.resolve == nil {
|
if d.resolve == nil {
|
||||||
return timeutil.Period{}, fmt.Errorf("report %q has no valid-period resolver", d.ID)
|
return timeutil.Period{}, fmt.Errorf("report %q has no valid-period resolver", d.ID)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func hourlyDefinition() Definition {
|
|||||||
GeneratedTextSchemaID: "hourly",
|
GeneratedTextSchemaID: "hourly",
|
||||||
ComparisonStrategy: CompareRollingWindow,
|
ComparisonStrategy: CompareRollingWindow,
|
||||||
ArtifactGroup: "hourly",
|
ArtifactGroup: "hourly",
|
||||||
BatchOutputName: "hourly.md",
|
OutputName: "hourly.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
"hourly/index.md",
|
"hourly/index.md",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func TestRegistryDefinitionsPreserveRetainedContracts(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(string(tt.id), func(t *testing.T) {
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
definition := registry.MustLookup(tt.id)
|
definition := registry.MustLookup(tt.id)
|
||||||
if definition.ComparisonStrategy != tt.comparison || definition.Morning != tt.morning || definition.Evening != tt.evening || definition.BatchOutputName != tt.outputName {
|
if definition.ComparisonStrategy != tt.comparison || definition.Morning != tt.morning || definition.Evening != tt.evening || definition.OutputName != tt.outputName {
|
||||||
t.Fatalf("definition = %#v, want retained report contract", definition)
|
t.Fatalf("definition = %#v, want retained report contract", definition)
|
||||||
}
|
}
|
||||||
if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") {
|
if strings.Join(definition.DistributorPathTemplates, "|") != strings.Join(tt.paths, "|") {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func todayDefinition() Definition {
|
|||||||
GeneratedTextSchemaID: "today",
|
GeneratedTextSchemaID: "today",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "today",
|
ArtifactGroup: "today",
|
||||||
BatchOutputName: "today.md",
|
OutputName: "today.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
"daily/{valid_start_date}/{run_id}.md",
|
"daily/{valid_start_date}/{run_id}.md",
|
||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func tomorrowDefinition() Definition {
|
|||||||
GeneratedTextSchemaID: "tomorrow",
|
GeneratedTextSchemaID: "tomorrow",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "tomorrow",
|
ArtifactGroup: "tomorrow",
|
||||||
BatchOutputName: "tomorrow.md",
|
OutputName: "tomorrow.md",
|
||||||
DistributorPathTemplates: []string{
|
DistributorPathTemplates: []string{
|
||||||
"daily/{valid_start_date}/{run_id}.md",
|
"daily/{valid_start_date}/{run_id}.md",
|
||||||
"daily/{valid_start_date}/index.md",
|
"daily/{valid_start_date}/index.md",
|
||||||
|
|||||||
Reference in New Issue
Block a user