diff --git a/internal/app/app.go b/internal/app/app.go index d456ba0..243e1ab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -334,20 +334,19 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro if err != nil { return nil, err } + if err := prepareBatchOutputs(req.OutputDir, plannedReports); err != nil { + return nil, err + } if req.Batch == BatchEvening || req.Batch == BatchMorning { startedAt := now result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} for _, planned := range plannedReports { resolved := planned.Resolved item := batchReportResult(planned) - outputPath, err := plannedBatchOutputPath(req.OutputDir, planned) - if err != nil { - return nil, err - } reportResult, err := generatePromptReport(ctx, promptReportRequest{ GenerateRequest: GenerateRequest{ Config: req.Config, - OutputPath: outputPath, + OutputPath: planned.OutputPath, Notifier: req.Notifier, Executor: req.Executor, }, @@ -451,6 +450,17 @@ func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (strin return validateOutputPath(filepath.Join(outputDir, outputName)) } +func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error { + for index := range plannedReports { + outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index]) + if err != nil { + return err + } + plannedReports[index].OutputPath = outputPath + } + return nil +} + func resolveReportOutputPath(workingDir, override string, resolved report.Resolved) (string, error) { outputName, err := resolved.OutputName() if err != nil { diff --git a/internal/app/batch_generation_test.go b/internal/app/batch_generation_test.go index 935397c..f2be9e7 100644 --- a/internal/app/batch_generation_test.go +++ b/internal/app/batch_generation_test.go @@ -59,6 +59,42 @@ func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) { } } +func TestRunBatchDetailedPreflightsAllOutputPaths(t *testing.T) { + bundle := generationBundle(t) + bundle.Hourly.Periods = bundle.Hourly.Periods[:1] + outputDir := t.TempDir() + if err := os.Mkdir(filepath.Join(outputDir, "tomorrow.md"), 0o700); err != nil { + t.Fatal(err) + } + todayPath := filepath.Join(outputDir, "today.md") + const previousReport = "previous report" + if err := os.WriteFile(todayPath, []byte(previousReport), 0o600); err != nil { + t.Fatal(err) + } + executor := &generationExecutor{} + promptInspectedBeforeCollection := false + collector := &generationCollector{ + bundle: &bundle, + beforeRun: func() { + promptInspectedBeforeCollection = executor.promptInspections > 0 + }, + } + result, err := RunBatchDetailed(context.Background(), BatchRequest{ + Config: generationDistributorConfig(), Batch: BatchMorning, + Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir, + Collector: collector, Executor: executor, Notifier: &generationNotifier{}, + }) + if err == nil || result != nil || !collector.called || !promptInspectedBeforeCollection || executor.called { + t.Fatalf("RunBatchDetailed() result/error/collection/inspection/execution = %#v/%v/%t/%t/%t", result, err, collector.called, promptInspectedBeforeCollection, executor.called) + } + if data, readErr := os.ReadFile(todayPath); readErr != nil || string(data) != previousReport { + t.Fatalf("earlier output = %q, error = %v", data, readErr) + } + if info, statErr := os.Stat(filepath.Join(outputDir, "tomorrow.md")); statErr != nil || !info.IsDir() { + t.Fatalf("blocked output info/error = %#v/%v", info, statErr) + } +} + func generationDistributorConfig() config.Config { cfg := generationConfig() cfg.Notify.Distributor.Enabled = true diff --git a/internal/app/batch_plan.go b/internal/app/batch_plan.go index d8bc542..49e08db 100644 --- a/internal/app/batch_plan.go +++ b/internal/app/batch_plan.go @@ -11,7 +11,8 @@ import ( ) type plannedBatchReport struct { - Resolved report.Resolved + Resolved report.Resolved + OutputPath string } func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) { diff --git a/internal/app/generation_test.go b/internal/app/generation_test.go index 522fc1d..4702c56 100644 --- a/internal/app/generation_test.go +++ b/internal/app/generation_test.go @@ -17,18 +17,23 @@ import ( ) type generationCollector struct { - bundle *weatherdata.Bundle - err error - called bool + bundle *weatherdata.Bundle + err error + called bool + beforeRun func() } func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) { + if c.beforeRun != nil { + c.beforeRun() + } c.called = true return &collect.Result{Bundle: c.bundle}, c.err } type generationExecutor struct { called bool + promptInspections int inspectErr error executeErr error cancelBeforeReturn context.CancelFunc @@ -37,14 +42,15 @@ type generationExecutor struct { failedPrompt string } -func (e generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) { +func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) { + e.promptInspections++ if e.inspectErr != nil { return promptexec.PromptInspection{}, e.inspectErr } definition := generationDefinitionForPrompt(id) return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "fixture", Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"}}, nil } -func (generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { +func (*generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil } func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {