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
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
GeneratedTextRawPath string
@@ -121,7 +120,6 @@ type BatchNotificationResult struct {
PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Path string `json:"path,omitempty"`
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -144,7 +142,6 @@ type BatchReportResult struct {
NotificationRunID string `json:"notificationRunId,omitempty"`
NotificationPipelineID string `json:"notificationPipelineId,omitempty"`
NotificationError string `json:"notificationError,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
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.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 {
result.Notification = batchNotification
}
@@ -408,7 +405,6 @@ func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) {
item.ReportPath = result.ReportPath
item.OutputPath = result.OutputPath
item.MetadataPath = result.MetadataPath
item.NotificationPath = result.NotificationPath
if result.Notification != nil {
item.NotificationStatus = result.Notification.Status
item.NotificationRunID = result.Notification.RunID
@@ -617,33 +613,32 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
}
type finalizeRenderedReportRequest struct {
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
MetadataPath string
ExecutionArtifact *state.PromptExecutionArtifact
ManagedReportPath string
OutputPath string
Notifier Notifier
GenerationErr error
noNotify bool
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
MetadataPath string
ExecutionArtifact *state.PromptExecutionArtifact
RenderedReportPath string
OutputPath string
Notifier Notifier
GenerationErr error
noNotify bool
}
type finalizeRenderedReportResult struct {
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
Notification *NotificationResult
OutputPath string
Metadata state.Metadata
MetadataPath string
Notification *NotificationResult
}
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
if req.Store == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
}
if req.ManagedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
if req.RenderedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("rendered report path is required for report %q", req.Resolved.Definition.ID)
}
if req.ExecutionArtifact == nil {
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}
if req.OutputPath != "" && req.GenerationErr == nil {
if req.OutputPath != req.ManagedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
if req.OutputPath != req.RenderedReportPath {
if err := fileutil.CopyFileAtomic(req.RenderedReportPath, req.OutputPath); err != nil {
return result, err
}
result.OutputPath = req.OutputPath
@@ -667,7 +662,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
metadata.RenderedReportPath = req.RenderedReportPath
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil {
return result, err
@@ -681,24 +676,7 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
return result, nil
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata, req.Notifier, req.Store)
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
}
notification, err := notifyReport(ctx, req.Config, req.Resolved, req.OutputPath, metadata.RunID, metadata.GeneratedAt, req.Notifier)
result.Notification = notification
if err != nil {
return result, err
@@ -706,31 +684,23 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
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)
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 {
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, NotificationRequest{}, nil, err)
if saveErr != nil {
return nil, "", saveErr
}
return nil, notificationPath, err
return nil, err
}
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 {
return result, notificationPath, &NotificationError{
return result, &NotificationError{
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) {
@@ -745,8 +715,8 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
}, true
}
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, filepath.Base(reportPath))
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time) (NotificationRequest, error) {
values, err := distributorTemplateValuesForReport(cfg, resolved, runID, filepath.Base(outputPath))
if err != nil {
return NotificationRequest{}, err
}
@@ -763,19 +733,19 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
if err != nil {
return NotificationRequest{}, err
}
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values)
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, runID, outputPath, values)
if err != nil {
return NotificationRequest{}, err
}
return NotificationRequest{
ReportID: resolved.Definition.ID,
RunID: metadata.RunID,
RunID: runID,
PipelineID: pipelineID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
ReportPath: reportPath,
ReportPath: outputPath,
BundlePaths: bundlePaths,
CreatedAt: metadata.GeneratedAt,
CreatedAt: generatedAt,
}, nil
}
@@ -849,54 +819,6 @@ func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, p
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{}
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {

View File

@@ -9,7 +9,6 @@ import (
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"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)
}
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 {
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)
if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err)
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
return failedBatchNotificationResult(batchNotificationRequest{}, err), err
}
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
if err != nil {
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err)
if saveErr != nil {
return nil, saveErr
}
return failedBatchNotificationResult(req, path, err), err
return failedBatchNotificationResult(req, err), err
}
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
@@ -87,12 +78,7 @@ func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID
if notifyErr != nil {
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)
if saveErr != nil {
return nil, saveErr
}
batchResult := batchNotificationResult(req, notification, path)
batchResult := batchNotificationResult(req, notification)
if wrappedErr != nil {
batchResult.Status = "failed"
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{
Status: "unknown",
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Path: path,
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
}
if result != nil {
@@ -257,8 +242,8 @@ func batchNotificationResult(req batchNotificationRequest, result *NotificationR
return notification
}
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult {
notification := batchNotificationResult(req, nil, path)
func failedBatchNotificationResult(req batchNotificationRequest, err error) *BatchNotificationResult {
notification := batchNotificationResult(req, nil)
notification.Status = "failed"
if err != nil {
notification.Error = err.Error()
@@ -266,78 +251,6 @@ func failedBatchNotificationResult(req batchNotificationRequest, path string, er
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) {
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
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])
}
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 {
t.Fatalf("read managed report: %v", readErr)
t.Fatalf("read rendered report: %v", readErr)
}
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)
}
}
@@ -263,15 +263,12 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
if err != nil {
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))
}
outputPaths := make(map[string]struct{}, len(result.Reports))
for _, item := range result.Reports {
outputPaths[item.OutputPath] = struct{}{}
if item.NotificationPath != "" {
t.Fatalf("report item contains per-report notification path: %#v", item)
}
}
request := notifier.batchRequests[0]
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)
}
}
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) {
@@ -299,7 +292,7 @@ func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
if err != nil {
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)
}
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)
}
}
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") {
t.Fatalf("notification artifact = %#v", artifact)
if !strings.Contains(result.Notification.Error, "batch upload rejected") {
t.Fatalf("notification error = %#v", result.Notification)
}
})
@@ -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(),
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)
}
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !bytes.Contains(artifact.RunStatus.Report, []byte("replace_older")) {
t.Fatalf("notification artifact = %#v", artifact)
if result.Notification.Status != "accepted" || result.Notification.RunID != "batch-notification-run" {
t.Fatalf("notification = %#v", result.Notification)
}
})
}
@@ -439,25 +430,11 @@ func assertBatchItemMatchesMetadata(t *testing.T, item BatchReportResult) {
}
if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID ||
item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath ||
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath ||
item.NotificationPath != metadata.NotificationPath {
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath {
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 {
t.Helper()
data, err := os.ReadFile(path)

View File

@@ -20,12 +20,11 @@ import (
)
const (
failPromptExecution = "prompt execution"
failMetadata = "metadata"
failGeneratedText = "generated text"
failRenderContext = "render context"
failRenderedReportPath = "rendered report path"
failDistributorNotification = "distributor notification"
failPromptExecution = "prompt execution"
failMetadata = "metadata"
failGeneratedText = "generated text"
failRenderContext = "render context"
failRenderedReportPath = "rendered report path"
)
type failingPersistenceStore struct {
@@ -67,13 +66,6 @@ func (s *failingPersistenceStore) PrepareRenderedReport(ctx context.Context, res
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) {
s.metadataCalls++
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: "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: "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: "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 {
@@ -250,7 +241,7 @@ func TestCompletedExecutionArtifactTracksDownstreamLifecycle(t *testing.T) {
want := state.PromptExecutionPaths{
RawOutputPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText,
RenderContextPath: paths.RenderContext, RenderedReportPath: paths.RenderedReport,
OutputPath: paths.output, NotificationPath: paths.Notification,
OutputPath: paths.output,
}
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},
},
{
name: "managed report write", failOperation: failRenderedReportPath,
name: "rendered report write", failOperation: failRenderedReportPath,
wantExecution: reachedExecutionArtifacts{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},
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},
},
{
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},
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 {
@@ -409,7 +385,6 @@ type reachedExecutionArtifacts struct {
renderContext bool
report bool
output bool
notification bool
}
func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifacts) state.PromptExecutionPaths {
@@ -429,9 +404,6 @@ func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifa
if reached.output {
result.OutputPath = paths.output
}
if reached.notification {
result.NotificationPath = paths.Notification
}
return result
}
@@ -466,7 +438,6 @@ type reachedPromptArtifacts struct {
renderContext bool
report bool
output bool
notification bool
}
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},
{"report", result.ReportPath, result.Metadata.RenderedReportPath, true, paths.RenderedReport, want.report},
{"output", result.OutputPath, "", false, paths.output, want.output},
{"notification", result.NotificationPath, result.Metadata.NotificationPath, true, paths.Notification, want.notification},
}
for _, check := range checks {
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{
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,
})
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
return w.result, err
}

View File

@@ -197,12 +197,12 @@ func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
if !bytes.Equal(executor.request.DataPackage, persisted) {
t.Fatal("executor data package differs from exact persisted YAML bytes")
}
managed, readErr := os.ReadFile(result.ReportPath)
if readErr != nil || !strings.Contains(string(managed), test.wantOutput) {
t.Fatalf("managed report = %q, error %v, want generated template output %q", managed, readErr, test.wantOutput)
renderedReport, readErr := os.ReadFile(result.ReportPath)
if readErr != nil || !strings.Contains(string(renderedReport), test.wantOutput) {
t.Fatalf("rendered report = %q, error %v, want generated template output %q", renderedReport, readErr, test.wantOutput)
}
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)
}
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") {
t.Fatalf("bundle paths = %#v, want %#v", notifier.requests[0].BundlePaths, wantBundlePaths)
}
managedName := 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" {
t.Fatalf("output names = managed %q copy %q", result.ReportPath, result.OutputPath)
renderedName := filepath.Base(result.ReportPath)
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 = rendered %q copy %q", result.ReportPath, result.OutputPath)
}
})
}
@@ -465,7 +465,6 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
wantContext bool
wantReport bool
wantOutput bool
wantNotify bool
}{
{name: "generated text decode", raw: `{`, wantRaw: true},
{name: "generated text domain", raw: `{}`, wantRaw: true},
@@ -484,7 +483,7 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
}, wantRaw: true, wantNormalized: true, wantContext: true},
{name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) {
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 {
@@ -512,8 +511,8 @@ func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *
}
if (result.GeneratedTextRawPath != "") != test.wantRaw || (result.GeneratedTextPath != "") != test.wantNormalized ||
(result.RenderContextPath != "") != test.wantContext || (result.ReportPath != "") != test.wantReport ||
(result.OutputPath != "") != test.wantOutput || (result.NotificationPath != "") != test.wantNotify {
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)
(result.OutputPath != "") != test.wantOutput {
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 {
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) {
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 != "" {
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
}
if result.Notification.Path != "" {
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
}
if 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"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
Notification *generateNotificationSummary `json:"notification,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -92,7 +91,6 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
summary.GeneratedTextPath = result.GeneratedTextPath
summary.RenderContextPath = result.RenderContextPath
summary.NotificationPath = result.NotificationPath
summary.Notification = newGenerateNotificationSummary(result.Notification)
if err != nil {
summary.Status = summaryStatusFailed

View File

@@ -29,7 +29,6 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
GeneratedTextPath: "/runs/hourly/generated_text.json",
RenderContextPath: "/runs/hourly/render_context.json",
NotificationPath: "/runs/hourly/notification.json",
Metadata: state.Metadata{
ReportID: report.Hourly,
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" {
t.Fatalf("summary = %#v, want successful daily summary", summary)
}
if summary.Notification != nil || summary.NotificationPath != "" {
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
if summary.Notification != nil {
t.Fatalf("notification summary = %#v, want omitted", summary.Notification)
}
data, err := json.Marshal(summary)
if err != nil {
@@ -141,12 +140,11 @@ func TestNewGenerateSummaryOmitsUnreachedArtifactPaths(t *testing.T) {
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
result := &app.ReportResult{
DataPackagePath: "/runs/hourly/data_package.yaml",
PreparationPath: "/runs/hourly/preparation.json",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
NotificationPath: "/runs/hourly/notification.json",
DataPackagePath: "/runs/hourly/data_package.yaml",
PreparationPath: "/runs/hourly/preparation.json",
ReportPath: "/runs/hourly/report.md",
OutputPath: "/copies/hourly.md",
MetadataPath: "/runs/hourly/metadata.json",
Metadata: state.Metadata{
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
@@ -162,8 +160,15 @@ func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
if summary.Status != "failed" || summary.Error != err.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 == "" {
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
if summary.ReportPath == "" || summary.MetadataPath == "" {
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)
}
}