Stop persisting notification receipts

This commit is contained in:
2026-08-01 19:40:51 +00:00
parent b184ca7cbd
commit 4bdba6f2b7
9 changed files with 94 additions and 310 deletions

View File

@@ -94,7 +94,6 @@ type ReportResult struct {
LLMDebugPath string LLMDebugPath string
ReportPath string ReportPath string
OutputPath string OutputPath string
NotificationPath string
Metadata state.Metadata Metadata state.Metadata
MetadataPath string MetadataPath string
GeneratedTextRawPath string GeneratedTextRawPath string
@@ -121,7 +120,6 @@ type BatchNotificationResult struct {
PipelineID string `json:"pipelineId,omitempty"` PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"` BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"`
Path string `json:"path,omitempty"`
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"` IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -144,7 +142,6 @@ type BatchReportResult struct {
NotificationRunID string `json:"notificationRunId,omitempty"` NotificationRunID string `json:"notificationRunId,omitempty"`
NotificationPipelineID string `json:"notificationPipelineId,omitempty"` NotificationPipelineID string `json:"notificationPipelineId,omitempty"`
NotificationError string `json:"notificationError,omitempty"` NotificationError string `json:"notificationError,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
DataPackagePath string `json:"dataPackagePath,omitempty"` DataPackagePath string `json:"dataPackagePath,omitempty"`
@@ -387,7 +384,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
result.Reports = append(result.Reports, item) result.Reports = append(result.Reports, item)
} }
result.Total = len(result.Reports) result.Total = len(result.Reports)
batchNotification, err := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, store, req.Notifier) batchNotification, err := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, req.Notifier)
if batchNotification != nil { if batchNotification != nil {
result.Notification = batchNotification result.Notification = batchNotification
} }
@@ -408,7 +405,6 @@ func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) {
item.ReportPath = result.ReportPath item.ReportPath = result.ReportPath
item.OutputPath = result.OutputPath item.OutputPath = result.OutputPath
item.MetadataPath = result.MetadataPath item.MetadataPath = result.MetadataPath
item.NotificationPath = result.NotificationPath
if result.Notification != nil { if result.Notification != nil {
item.NotificationStatus = result.Notification.Status item.NotificationStatus = result.Notification.Status
item.NotificationRunID = result.Notification.RunID item.NotificationRunID = result.Notification.RunID
@@ -617,33 +613,32 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
} }
type finalizeRenderedReportRequest struct { type finalizeRenderedReportRequest struct {
Config config.Config Config config.Config
Store state.Store Store state.Store
Resolved report.Resolved Resolved report.Resolved
Metadata state.Metadata Metadata state.Metadata
MetadataPath string MetadataPath string
ExecutionArtifact *state.PromptExecutionArtifact ExecutionArtifact *state.PromptExecutionArtifact
ManagedReportPath string RenderedReportPath string
OutputPath string OutputPath string
Notifier Notifier Notifier Notifier
GenerationErr error GenerationErr error
noNotify bool noNotify bool
} }
type finalizeRenderedReportResult struct { type finalizeRenderedReportResult struct {
OutputPath string OutputPath string
NotificationPath string Metadata state.Metadata
Metadata state.Metadata MetadataPath string
MetadataPath string Notification *NotificationResult
Notification *NotificationResult
} }
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) { func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
if req.Store == nil { if req.Store == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required") return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
} }
if req.ManagedReportPath == "" { if req.RenderedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID) return finalizeRenderedReportResult{}, fmt.Errorf("rendered report path is required for report %q", req.Resolved.Definition.ID)
} }
if req.ExecutionArtifact == nil { if req.ExecutionArtifact == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("prompt execution artifact is required for report %q", req.Resolved.Definition.ID) return finalizeRenderedReportResult{}, fmt.Errorf("prompt execution artifact is required for report %q", req.Resolved.Definition.ID)
@@ -651,8 +646,8 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
result := finalizeRenderedReportResult{Metadata: req.Metadata, MetadataPath: req.MetadataPath} result := finalizeRenderedReportResult{Metadata: req.Metadata, MetadataPath: req.MetadataPath}
if req.OutputPath != "" && req.GenerationErr == nil { if req.OutputPath != "" && req.GenerationErr == nil {
if req.OutputPath != req.ManagedReportPath { if req.OutputPath != req.RenderedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil { if err := fileutil.CopyFileAtomic(req.RenderedReportPath, req.OutputPath); err != nil {
return result, err return result, err
} }
result.OutputPath = req.OutputPath result.OutputPath = req.OutputPath
@@ -667,7 +662,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
} }
metadata := req.Metadata metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath metadata.RenderedReportPath = req.RenderedReportPath
metadataPath, err := req.Store.SaveMetadata(ctx, metadata) metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil { if err != nil {
return result, err return result, err
@@ -681,24 +676,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
return result, nil return result, nil
} }
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata, req.Notifier, req.Store) notification, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata.RunID, metadata.GeneratedAt, req.Notifier)
if notificationPath != "" {
result.NotificationPath = notificationPath
result.Notification = notification
metadata.NotificationPath = notificationPath
result.Metadata = metadata
if saveErr := persistReachedPromptPath(ctx, req.Store, req.Resolved, req.ExecutionArtifact, func(paths *state.PromptExecutionPaths) {
paths.NotificationPath = notificationPath
}); saveErr != nil {
return result, saveErr
}
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return result, saveErr
}
result.Metadata = metadata
result.MetadataPath = metadataPath
}
result.Notification = notification result.Notification = notification
if err != nil { if err != nil {
return result, err return result, err
@@ -706,31 +684,23 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
return result, nil return result, nil
} }
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) { func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time, notifier Notifier) (*NotificationResult, error) {
notifier, enabled := reportNotifier(cfg, notifier) notifier, enabled := reportNotifier(cfg, notifier)
if !enabled { if !enabled {
return nil, "", nil return nil, nil
} }
notificationRequest, err := buildNotificationRequest(cfg, resolved, reportPath, metadata) notificationRequest, err := buildNotificationRequest(cfg, resolved, outputPath, runID, generatedAt)
if err != nil { if err != nil {
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, NotificationRequest{}, nil, err) return nil, err
if saveErr != nil {
return nil, "", saveErr
}
return nil, notificationPath, err
} }
result, err := notifier.Notify(ctx, notificationRequest) result, err := notifier.Notify(ctx, notificationRequest)
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, notificationRequest, result, err)
if saveErr != nil {
return nil, "", saveErr
}
if err != nil { if err != nil {
return result, notificationPath, &NotificationError{ return result, &NotificationError{
Request: notificationRequest, Request: notificationRequest,
Err: fmt.Errorf("notify report %q run %q from output %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err), Err: fmt.Errorf("notify report %q run %q from output %q: %w", resolved.Definition.ID, runID, outputPath, err),
} }
} }
return result, notificationPath, nil return result, nil
} }
func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) { func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
@@ -745,8 +715,8 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
}, true }, true
} }
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) { func buildNotificationRequest(cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time) (NotificationRequest, error) {
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, filepath.Base(reportPath)) values, err := distributorTemplateValuesForReport(cfg, resolved, runID, filepath.Base(outputPath))
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
@@ -763,19 +733,19 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values) bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, runID, outputPath, values)
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
return NotificationRequest{ return NotificationRequest{
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
RunID: metadata.RunID, RunID: runID,
PipelineID: pipelineID, PipelineID: pipelineID,
BundleID: bundleID, BundleID: bundleID,
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
ReportPath: reportPath, ReportPath: outputPath,
BundlePaths: bundlePaths, BundlePaths: bundlePaths,
CreatedAt: metadata.GeneratedAt, CreatedAt: generatedAt,
}, nil }, nil
} }
@@ -849,54 +819,6 @@ func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, p
return nil return nil
} }
func saveNotificationArtifact(ctx context.Context, store state.Store, resolved report.Resolved, cfg config.Config, metadata state.Metadata, req NotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
if store == nil {
return "", fmt.Errorf("state store is required")
}
artifact := state.DistributorNotificationArtifact{
SchemaVersion: state.DistributorNotificationSchemaVersion,
RunID: metadata.RunID,
ReportID: resolved.Definition.ID,
AttemptedAt: time.Now(),
Endpoint: cfg.Notify.Distributor.Endpoint,
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
SourcePath: req.ReportPath,
BundlePaths: append([]string(nil), req.BundlePaths...),
BundleCreated: req.CreatedAt,
Status: "attempted",
}
if result != nil {
artifact.Status = result.Status
artifact.Upload = &state.DistributorUploadResult{
RunID: result.RunID,
Status: result.UploadStatus,
}
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
artifact.RunStatus = &state.DistributorRunStatus{
RunID: result.RunID,
PipelineID: result.PipelineID,
Status: result.Status,
AcceptedAt: result.AcceptedAt,
StartedAt: result.StartedAt,
FinishedAt: result.FinishedAt,
Report: append([]byte(nil), result.Report...),
Error: result.Error,
}
}
artifact.StatusError = result.StatusError
}
if notifyErr != nil {
artifact.Status = "failed"
artifact.Error = notifyErr.Error()
}
if artifact.Status == "" {
artifact.Status = "unknown"
}
return store.SaveDistributorNotification(ctx, resolved, artifact)
}
type noopNotifier struct{} type noopNotifier struct{}
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) { func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {

View File

@@ -9,7 +9,6 @@ import (
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -47,7 +46,7 @@ func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch) return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
} }
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) { func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) (*BatchNotificationResult, error) {
if !cfg.Notify.Distributor.Enabled { if !cfg.Notify.Distributor.Enabled {
return nil, nil return nil, nil
} }
@@ -66,20 +65,12 @@ func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned) req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
if err != nil { if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err) return failedBatchNotificationResult(batchNotificationRequest{}, err), err
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
} }
batchNotifier, err := resolveBatchNotifier(cfg, notifier) batchNotifier, err := resolveBatchNotifier(cfg, notifier)
if err != nil { if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err) return failedBatchNotificationResult(req, err), err
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(req, path, err), err
} }
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req) notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
@@ -87,12 +78,7 @@ func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID
if notifyErr != nil { if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr) wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
} }
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr) batchResult := batchNotificationResult(req, notification)
if saveErr != nil {
return nil, saveErr
}
batchResult := batchNotificationResult(req, notification, path)
if wrappedErr != nil { if wrappedErr != nil {
batchResult.Status = "failed" batchResult.Status = "failed"
batchResult.Error = wrappedErr.Error() batchResult.Error = wrappedErr.Error()
@@ -226,13 +212,12 @@ func batchDistributorUploadRequest(req batchNotificationRequest) distributoradap
} }
} }
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult, path string) *BatchNotificationResult { func batchNotificationResult(req batchNotificationRequest, result *NotificationResult) *BatchNotificationResult {
notification := &BatchNotificationResult{ notification := &BatchNotificationResult{
Status: "unknown", Status: "unknown",
PipelineID: req.PipelineID, PipelineID: req.PipelineID,
BundleID: req.BundleID, BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey, IdempotencyKey: req.IdempotencyKey,
Path: path,
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...), IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
} }
if result != nil { if result != nil {
@@ -257,8 +242,8 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
return notification return notification
} }
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult { func failedBatchNotificationResult(req batchNotificationRequest, err error) *BatchNotificationResult {
notification := batchNotificationResult(req, nil, path) notification := batchNotificationResult(req, nil)
notification.Status = "failed" notification.Status = "failed"
if err != nil { if err != nil {
notification.Error = err.Error() notification.Error = err.Error()
@@ -266,78 +251,6 @@ func failedBatchNotificationResult(req batchNotificationRequest, path string, er
return notification return notification
} }
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
if store == nil {
return "", fmt.Errorf("state store is required")
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return "", fmt.Errorf("load batch notification timezone: %w", err)
}
artifact := state.BatchDistributorNotificationArtifact{
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
Batch: string(batch),
BatchRunID: runID,
AttemptedAt: time.Now(),
Endpoint: cfg.Notify.Distributor.Endpoint,
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
BundleCreated: req.CreatedAt,
Reports: batchNotificationReportArtifacts(req.IncludedReports),
Status: "attempted",
}
if result != nil {
artifact.Status = result.Status
artifact.Upload = &state.DistributorUploadResult{
RunID: result.RunID,
Status: result.UploadStatus,
}
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
artifact.RunStatus = &state.DistributorRunStatus{
RunID: result.RunID,
PipelineID: result.PipelineID,
Status: result.Status,
AcceptedAt: result.AcceptedAt,
StartedAt: result.StartedAt,
FinishedAt: result.FinishedAt,
Report: append([]byte(nil), result.Report...),
Error: result.Error,
}
}
artifact.StatusError = result.StatusError
}
if notifyErr != nil {
artifact.Status = "failed"
artifact.Error = notifyErr.Error()
}
if artifact.Status == "" {
artifact.Status = "unknown"
}
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
Batch: string(batch),
BatchRunID: runID,
StartedAt: startedAt,
Location: location,
}, artifact)
}
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
if len(reports) == 0 {
return nil
}
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
for _, item := range reports {
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.SourcePath,
BundlePaths: append([]string(nil), item.BundlePaths...),
})
}
return artifacts
}
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) { func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt) values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
if err != nil { if err != nil {

View File

@@ -169,12 +169,12 @@ func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
t.Fatalf("output = %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) renderedReport, readErr := os.ReadFile(item.ReportPath)
if readErr != nil { if readErr != nil {
t.Fatalf("read managed report: %v", readErr) t.Fatalf("read rendered report: %v", readErr)
} }
copied, readErr := os.ReadFile(item.OutputPath) copied, readErr := os.ReadFile(item.OutputPath)
if readErr != nil || !bytes.Equal(managed, copied) { if readErr != nil || !bytes.Equal(renderedReport, copied) {
t.Fatalf("output mismatch/error = %v", readErr) t.Fatalf("output mismatch/error = %v", readErr)
} }
} }
@@ -263,15 +263,12 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) t.Fatalf("RunBatchDetailed() error = %v", err)
} }
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" || 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))
} }
outputPaths := 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 {
outputPaths[item.OutputPath] = struct{}{} outputPaths[item.OutputPath] = struct{}{}
if item.NotificationPath != "" {
t.Fatalf("report item contains per-report notification path: %#v", item)
}
} }
request := notifier.batchRequests[0] request := notifier.batchRequests[0]
if len(request.IncludedReports) != len(result.Reports) { if len(request.IncludedReports) != len(result.Reports) {
@@ -282,10 +279,6 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
t.Fatalf("notification file = %#v, want selected Markdown output source", file) t.Fatalf("notification file = %#v, want selected Markdown output source", file)
} }
} }
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-notification-run" || artifact.RunStatus == nil || len(artifact.Reports) != len(result.Reports) {
t.Fatalf("notification artifact = %#v", artifact)
}
}) })
t.Run("upload failure", func(t *testing.T) { t.Run("upload failure", func(t *testing.T) {
@@ -299,7 +292,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) t.Fatalf("RunBatchDetailed() error = %v", err)
} }
if result.Succeeded != 2 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "failed" || result.Notification.Path == "" { if result.Succeeded != 2 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "failed" {
t.Fatalf("result = %#v, want successful reports and failed notification", result) t.Fatalf("result = %#v, want successful reports and failed notification", result)
} }
for _, item := range result.Reports { for _, item := range result.Reports {
@@ -307,9 +300,8 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
t.Fatalf("report item = %#v, want success despite notification failure", item) t.Fatalf("report item = %#v, want success despite notification failure", item)
} }
} }
artifact := readBatchNotificationArtifact(t, result.Notification.Path) if !strings.Contains(result.Notification.Error, "batch upload rejected") {
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") { t.Fatalf("notification error = %#v", result.Notification)
t.Fatalf("notification artifact = %#v", artifact)
} }
}) })
@@ -324,12 +316,11 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), WorkingDir: t.TempDir(), 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 {
t.Fatalf("result/error = %#v/%v", result, err) t.Fatalf("result/error = %#v/%v", result, err)
} }
artifact := readBatchNotificationArtifact(t, result.Notification.Path) if result.Notification.Status != "accepted" || result.Notification.RunID != "batch-notification-run" {
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !bytes.Contains(artifact.RunStatus.Report, []byte("replace_older")) { t.Fatalf("notification = %#v", result.Notification)
t.Fatalf("notification artifact = %#v", artifact)
} }
}) })
} }
@@ -439,25 +430,11 @@ func assertBatchItemMatchesMetadata(t *testing.T, item BatchReportResult) {
} }
if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID || if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID ||
item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath || item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath ||
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath || item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath {
item.NotificationPath != metadata.NotificationPath {
t.Fatalf("batch item paths do not exactly match metadata: item=%#v metadata=%#v", item, metadata) t.Fatalf("batch item paths do not exactly match metadata: item=%#v metadata=%#v", item, metadata)
} }
} }
func readBatchNotificationArtifact(t *testing.T, path string) state.BatchDistributorNotificationArtifact {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read batch notification artifact: %v", err)
}
var artifact state.BatchDistributorNotificationArtifact
if err := json.Unmarshal(data, &artifact); err != nil {
t.Fatalf("decode batch notification artifact: %v", err)
}
return artifact
}
func loadBatchDataPackage(t *testing.T, path string) promptinput.Package { func loadBatchDataPackage(t *testing.T, path string) promptinput.Package {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)

View File

@@ -20,12 +20,11 @@ import (
) )
const ( const (
failPromptExecution = "prompt execution" failPromptExecution = "prompt execution"
failMetadata = "metadata" failMetadata = "metadata"
failGeneratedText = "generated text" failGeneratedText = "generated text"
failRenderContext = "render context" failRenderContext = "render context"
failRenderedReportPath = "rendered report path" failRenderedReportPath = "rendered report path"
failDistributorNotification = "distributor notification"
) )
type failingPersistenceStore struct { type failingPersistenceStore struct {
@@ -67,13 +66,6 @@ func (s *failingPersistenceStore) PrepareRenderedReport(ctx context.Context, res
return s.Store.PrepareRenderedReport(ctx, resolved) return s.Store.PrepareRenderedReport(ctx, resolved)
} }
func (s *failingPersistenceStore) SaveDistributorNotification(ctx context.Context, resolved report.Resolved, artifact state.DistributorNotificationArtifact) (string, error) {
if s.failOperation == failDistributorNotification {
return "", errors.New("injected notification persistence failure")
}
return s.Store.SaveDistributorNotification(ctx, resolved, artifact)
}
func (s *failingPersistenceStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) { func (s *failingPersistenceStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
s.metadataCalls++ s.metadataCalls++
if s.failOperation == failMetadata && s.metadataCalls == s.failMetadataCall { if s.failOperation == failMetadata && s.metadataCalls == s.failMetadataCall {
@@ -151,9 +143,8 @@ func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
{name: "execution then metadata", failOperation: failMetadata, failMetadataCall: 2, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true}}, {name: "execution then metadata", failOperation: failMetadata, failMetadataCall: 2, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true}},
{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: "rendered 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 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: "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}},
} }
for _, test := range tests { for _, test := range tests {
@@ -250,7 +241,7 @@ func TestCompletedExecutionArtifactTracksDownstreamLifecycle(t *testing.T) {
want := state.PromptExecutionPaths{ want := state.PromptExecutionPaths{
RawOutputPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText, RawOutputPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText,
RenderContextPath: paths.RenderContext, RenderedReportPath: paths.RenderedReport, RenderContextPath: paths.RenderContext, RenderedReportPath: paths.RenderedReport,
OutputPath: paths.output, NotificationPath: paths.Notification, OutputPath: paths.output,
} }
assertPersistedExecutionPaths(t, req.Store, result.ExecutionPath, want) assertPersistedExecutionPaths(t, req.Store, result.ExecutionPath, want)
@@ -314,12 +305,12 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T)
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}, wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
}, },
{ {
name: "managed report write", failOperation: failRenderedReportPath, name: "rendered report write", failOperation: failRenderedReportPath,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true}, wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}, wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
}, },
{ {
name: "managed report checkpoint", failOperation: failPromptExecution, failExecutionCall: 4, name: "rendered report checkpoint", failOperation: failPromptExecution, failExecutionCall: 4,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true}, wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: 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},
}, },
@@ -339,25 +330,10 @@ func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T)
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: "notification artifact write", failOperation: failDistributorNotification, requestOutput: true, notify: true, name: "notification operation", requestOutput: true, notify: true, notificationFailure: 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},
}, },
{
name: "notification checkpoint", failOperation: failPromptExecution, failExecutionCall: 6, requestOutput: true, notify: 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, notification: true},
},
{
name: "notification metadata", failOperation: failMetadata, failMetadataCall: 6, requestOutput: true, notify: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
},
{
name: "notification operation", requestOutput: true, notify: true, notificationFailure: true,
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
},
} }
for _, test := range tests { for _, test := range tests {
@@ -409,7 +385,6 @@ type reachedExecutionArtifacts struct {
renderContext bool renderContext bool
report bool report bool
output bool output bool
notification bool
} }
func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifacts) state.PromptExecutionPaths { func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifacts) state.PromptExecutionPaths {
@@ -429,9 +404,6 @@ func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifa
if reached.output { if reached.output {
result.OutputPath = paths.output result.OutputPath = paths.output
} }
if reached.notification {
result.NotificationPath = paths.Notification
}
return result return result
} }
@@ -466,7 +438,6 @@ type reachedPromptArtifacts struct {
renderContext bool renderContext bool
report bool report bool
output bool output bool
notification bool
} }
func promptArtifactRequest(t *testing.T, executor promptexec.Executor) (promptReportRequest, promptArtifactPaths) { func promptArtifactRequest(t *testing.T, executor promptexec.Executor) (promptReportRequest, promptArtifactPaths) {
@@ -534,7 +505,6 @@ func assertReachedPromptArtifacts(t *testing.T, result *ReportResult, paths prom
{"render context", result.RenderContextPath, result.Metadata.RenderContextPath, true, paths.RenderContext, want.renderContext}, {"render context", result.RenderContextPath, result.Metadata.RenderContextPath, true, paths.RenderContext, want.renderContext},
{"report", result.ReportPath, result.Metadata.RenderedReportPath, true, paths.RenderedReport, want.report}, {"report", result.ReportPath, result.Metadata.RenderedReportPath, true, paths.RenderedReport, want.report},
{"output", result.OutputPath, "", false, paths.output, want.output}, {"output", result.OutputPath, "", false, paths.output, want.output},
{"notification", result.NotificationPath, result.Metadata.NotificationPath, true, paths.Notification, want.notification},
} }
for _, check := range checks { for _, check := range checks {
if check.want && check.got != check.path { if check.want && check.got != check.path {

View File

@@ -344,10 +344,10 @@ func (w *promptReportWorkflow) finalizeReport(rendered []byte) (*ReportResult, e
} }
finalized, err := finalizeRenderedReport(w.ctx, finalizeRenderedReportRequest{ finalized, err := finalizeRenderedReport(w.ctx, finalizeRenderedReportRequest{
Config: w.req.Config, Store: w.store, Resolved: w.req.Resolved, Metadata: w.metadata, MetadataPath: w.result.MetadataPath, Config: w.req.Config, Store: w.store, Resolved: w.req.Resolved, Metadata: w.metadata, MetadataPath: w.result.MetadataPath,
ExecutionArtifact: &w.executionArtifact, ManagedReportPath: reportPath, OutputPath: w.req.OutputPath, ExecutionArtifact: &w.executionArtifact, RenderedReportPath: reportPath, OutputPath: w.req.OutputPath,
Notifier: w.req.Notifier, noNotify: w.req.noNotify, Notifier: w.req.Notifier, noNotify: w.req.noNotify,
}) })
w.result.OutputPath, w.result.NotificationPath = finalized.OutputPath, finalized.NotificationPath w.result.OutputPath = finalized.OutputPath
w.result.Metadata, w.result.MetadataPath, w.result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification w.result.Metadata, w.result.MetadataPath, w.result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
return w.result, err return w.result, err
} }

View File

@@ -197,12 +197,12 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
if !bytes.Equal(executor.request.DataPackage, persisted) { if !bytes.Equal(executor.request.DataPackage, persisted) {
t.Fatal("executor data package differs from exact persisted YAML bytes") t.Fatal("executor data package differs from exact persisted YAML bytes")
} }
managed, readErr := os.ReadFile(result.ReportPath) renderedReport, readErr := os.ReadFile(result.ReportPath)
if readErr != nil || !strings.Contains(string(managed), test.wantOutput) { if readErr != nil || !strings.Contains(string(renderedReport), test.wantOutput) {
t.Fatalf("managed report = %q, error %v, want generated template output %q", managed, readErr, test.wantOutput) t.Fatalf("rendered report = %q, error %v, want generated template output %q", renderedReport, readErr, test.wantOutput)
} }
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, renderedReport) || result.OutputPath != outputPath {
t.Fatalf("output 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 != outputPath { if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != outputPath {
@@ -217,9 +217,9 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
if strings.Join(notifier.requests[0].BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { if strings.Join(notifier.requests[0].BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("bundle paths = %#v, want %#v", notifier.requests[0].BundlePaths, wantBundlePaths) t.Fatalf("bundle paths = %#v, want %#v", notifier.requests[0].BundlePaths, wantBundlePaths)
} }
managedName := filepath.Base(result.ReportPath) renderedName := filepath.Base(result.ReportPath)
if !strings.HasPrefix(managedName, "report.") || !strings.Contains(managedName, "_"+test.name) || !strings.HasSuffix(managedName, ".md") || filepath.Base(result.OutputPath) != test.name+".md" { if !strings.HasPrefix(renderedName, "report.") || !strings.Contains(renderedName, "_"+test.name) || !strings.HasSuffix(renderedName, ".md") || filepath.Base(result.OutputPath) != test.name+".md" {
t.Fatalf("output names = managed %q copy %q", result.ReportPath, result.OutputPath) t.Fatalf("output names = rendered %q copy %q", result.ReportPath, result.OutputPath)
} }
}) })
} }
@@ -465,7 +465,6 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
wantContext bool wantContext bool
wantReport bool wantReport bool
wantOutput bool wantOutput bool
wantNotify bool
}{ }{
{name: "generated text decode", raw: `{`, wantRaw: true}, {name: "generated text decode", raw: `{`, wantRaw: true},
{name: "generated text domain", raw: `{}`, wantRaw: true}, {name: "generated text domain", raw: `{}`, wantRaw: true},
@@ -484,7 +483,7 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
}, wantRaw: true, wantNormalized: true, wantContext: true}, }, wantRaw: true, wantNormalized: true, wantContext: 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},
} }
for _, test := range tests { for _, test := range tests {
@@ -512,8 +511,8 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
} }
if (result.GeneratedTextRawPath != "") != test.wantRaw || (result.GeneratedTextPath != "") != test.wantNormalized || if (result.GeneratedTextRawPath != "") != test.wantRaw || (result.GeneratedTextPath != "") != test.wantNormalized ||
(result.RenderContextPath != "") != test.wantContext || (result.ReportPath != "") != test.wantReport || (result.RenderContextPath != "") != test.wantContext || (result.ReportPath != "") != test.wantReport ||
(result.OutputPath != "") != test.wantOutput || (result.NotificationPath != "") != test.wantNotify { (result.OutputPath != "") != test.wantOutput {
t.Fatalf("reached paths = raw %q normalized %q context %q report %q output %q notification %q", result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.OutputPath, result.NotificationPath) t.Fatalf("reached paths = raw %q normalized %q context %q report %q output %q", result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.OutputPath)
} }
if test.wantRaw { if test.wantRaw {
persisted, readErr := os.ReadFile(result.GeneratedTextRawPath) persisted, readErr := os.ReadFile(result.GeneratedTextRawPath)
@@ -524,6 +523,9 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.OutputPath) { 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)
} }
if test.name == "notification" && result.Metadata.NotificationPath != "" {
t.Fatalf("notification metadata retains a receipt path: %#v", result.Metadata)
}
}) })
} }
} }

View File

@@ -63,9 +63,6 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
if result.Notification.BundleID != "" { if result.Notification.BundleID != "" {
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID) _, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
} }
if result.Notification.Path != "" {
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
}
if result.Notification.Error != "" { if result.Notification.Error != "" {
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error) _, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
} }

View File

@@ -35,7 +35,6 @@ type generateSummary struct {
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"` GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"` RenderContextPath string `json:"renderContextPath,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
Notification *generateNotificationSummary `json:"notification,omitempty"` Notification *generateNotificationSummary `json:"notification,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -92,7 +91,6 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
summary.GeneratedTextRawPath = result.GeneratedTextRawPath summary.GeneratedTextRawPath = result.GeneratedTextRawPath
summary.GeneratedTextPath = result.GeneratedTextPath summary.GeneratedTextPath = result.GeneratedTextPath
summary.RenderContextPath = result.RenderContextPath summary.RenderContextPath = result.RenderContextPath
summary.NotificationPath = result.NotificationPath
summary.Notification = newGenerateNotificationSummary(result.Notification) summary.Notification = newGenerateNotificationSummary(result.Notification)
if err != nil { if err != nil {
summary.Status = summaryStatusFailed summary.Status = summaryStatusFailed

View File

@@ -29,7 +29,6 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json", GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
GeneratedTextPath: "/runs/hourly/generated_text.json", GeneratedTextPath: "/runs/hourly/generated_text.json",
RenderContextPath: "/runs/hourly/render_context.json", RenderContextPath: "/runs/hourly/render_context.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{ Metadata: state.Metadata{
ReportID: report.Hourly, ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
@@ -99,8 +98,8 @@ func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) {
if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" { if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" {
t.Fatalf("summary = %#v, want successful daily summary", summary) t.Fatalf("summary = %#v, want successful daily summary", summary)
} }
if summary.Notification != nil || summary.NotificationPath != "" { if summary.Notification != nil {
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath) t.Fatalf("notification summary = %#v, want omitted", summary.Notification)
} }
data, err := json.Marshal(summary) data, err := json.Marshal(summary)
if err != nil { if err != nil {
@@ -141,12 +140,11 @@ func TestNewGenerateSummaryOmitsUnreachedArtifactPaths(t *testing.T) {
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) { func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{ result := &app.ReportResult{
DataPackagePath: "/runs/hourly/data_package.yaml", DataPackagePath: "/runs/hourly/data_package.yaml",
PreparationPath: "/runs/hourly/preparation.json", PreparationPath: "/runs/hourly/preparation.json",
ReportPath: "/runs/hourly/report.md", ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md", OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json", MetadataPath: "/runs/hourly/metadata.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{ Metadata: state.Metadata{
ReportID: report.Hourly, ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
@@ -162,8 +160,15 @@ func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
if summary.Status != "failed" || summary.Error != err.Error() { if summary.Status != "failed" || summary.Error != err.Error() {
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error) t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
} }
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" { if summary.ReportPath == "" || summary.MetadataPath == "" {
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath) t.Fatalf("artifact paths = report %q metadata %q, want retained output provenance", summary.ReportPath, summary.MetadataPath)
}
data, marshalErr := json.Marshal(summary)
if marshalErr != nil {
t.Fatalf("Marshal() error = %v", marshalErr)
}
if strings.Contains(string(data), "notificationPath") {
t.Fatalf("summary JSON includes a notification receipt path:\n%s", data)
} }
} }