Report distributor notification outcomes in batches

This commit is contained in:
2026-06-07 23:42:27 +00:00
parent c2758d7a91
commit c573cd5b4d
6 changed files with 261 additions and 23 deletions

View File

@@ -3,6 +3,7 @@ package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
@@ -112,20 +113,23 @@ type BatchResult struct {
}
type BatchReportResult struct {
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
NotificationStatus string `json:"notificationStatus,omitempty"`
NotificationRunID string `json:"notificationRunId,omitempty"`
NotificationError string `json:"notificationError,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
}
type BatchError struct {
@@ -164,6 +168,25 @@ type NotificationResult struct {
Status string
}
type NotificationError struct {
Request NotificationRequest
Err error
}
func (e *NotificationError) Error() string {
if e == nil || e.Err == nil {
return "notification failed"
}
return e.Err.Error()
}
func (e *NotificationError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func Generate(ctx context.Context, req GenerateRequest) error {
now := req.Now
if now.IsZero() {
@@ -242,6 +265,11 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if err != nil {
item.Status = "failed"
item.Error = err.Error()
var notificationErr *NotificationError
if errors.As(err, &notificationErr) {
item.NotificationStatus = "failed"
item.NotificationError = notificationErr.Error()
}
result.Failed++
} else {
item.Status = "succeeded"
@@ -251,6 +279,10 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
item.ReportPath = reportResult.ReportPath
item.OutputPath = reportResult.OutputPath
item.MetadataPath = reportResult.MetadataPath
if reportResult.Notification != nil {
item.NotificationStatus = reportResult.Notification.Status
item.NotificationRunID = reportResult.Notification.RunID
}
result.Succeeded++
}
result.Reports = append(result.Reports, item)
@@ -542,7 +574,10 @@ func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolv
}
result, err := notifier.Notify(ctx, notificationRequest)
if err != nil {
return nil, fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err)
return nil, &NotificationError{
Request: notificationRequest,
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
}
}
return result, nil
}

View File

@@ -1247,11 +1247,20 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
failedThreeDay = true
}
if item.NotificationStatus != "failed" {
t.Fatalf("3-day notification status = %q, want failed", item.NotificationStatus)
}
if !strings.Contains(item.NotificationError, "distributor unavailable") {
t.Fatalf("3-day notification error = %q, want distributor unavailable", item.NotificationError)
}
continue
}
if item.Status != "succeeded" {
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
}
if item.NotificationStatus != "accepted" {
t.Fatalf("report %s notification status = %q, want accepted", item.ReportID, item.NotificationStatus)
}
}
if !failedThreeDay {
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)

View File

@@ -352,11 +352,21 @@ func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
return
}
for _, item := range result.Reports {
notificationFields := ""
if item.NotificationStatus != "" {
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
}
if item.NotificationRunID != "" {
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
}
if item.NotificationError != "" {
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
}
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}

View File

@@ -346,6 +346,88 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
}
}
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning,
Total: 2,
Succeeded: 1,
Failed: 1,
Reports: []app.BatchReportResult{
{
ReportID: "daily_today",
Status: "succeeded",
OutputPath: "/tmp/daily.md",
NotificationStatus: "accepted",
NotificationRunID: "distributor-run-1",
},
{
ReportID: "three_day",
Status: "failed",
Error: "notify report three_day: upload failed",
NotificationStatus: "failed",
NotificationError: "notify report three_day: upload failed",
},
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
writeRunLogs(&stderr, result)
var decoded app.BatchResult
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
}
if decoded.Reports[0].NotificationStatus != "accepted" || decoded.Reports[0].NotificationRunID != "distributor-run-1" {
t.Fatalf("success notification fields = %#v", decoded.Reports[0])
}
if decoded.Reports[1].NotificationStatus != "failed" || !strings.Contains(decoded.Reports[1].NotificationError, "upload failed") {
t.Fatalf("failure notification fields = %#v", decoded.Reports[1])
}
if !strings.Contains(stderr.String(), `notificationStatus="accepted"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
t.Fatalf("stderr missing success notification fields:\n%s", stderr.String())
}
if !strings.Contains(stderr.String(), `notificationStatus="failed"`) || !strings.Contains(stderr.String(), `notificationError="notify report three_day: upload failed"`) {
t.Fatalf("stderr missing failure notification fields:\n%s", stderr.String())
}
}
func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning,
Total: 1,
Failed: 1,
Reports: []app.BatchReportResult{
{
ReportID: "daily_today",
Status: "failed",
Error: "notify report daily_today: upload failed: [redacted]",
NotificationStatus: "failed",
NotificationError: "notify report daily_today: upload failed: [redacted]",
},
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
writeRunLogs(&stderr, result)
for _, output := range []string{stdout.String(), stderr.String()} {
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
t.Fatalf("output contains token value:\n%s", output)
}
if !strings.Contains(output, "[redacted]") {
t.Fatalf("output missing redacted marker:\n%s", output)
}
}
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -384,6 +466,103 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
}
}
func TestRunEveningReportsNotificationSuccess(t *testing.T) {
server := dailyServer(t)
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/upload" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","status":"accepted"}`))
}))
t.Cleanup(distributorServer.Close)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"run", "evening",
"--config", configPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if len(summary.Reports) != 1 {
t.Fatalf("reports = %#v, want one report", summary.Reports)
}
if summary.Reports[0].NotificationStatus != "accepted" || summary.Reports[0].NotificationRunID != "distributor-run-1" {
t.Fatalf("notification fields = %#v", summary.Reports[0])
}
if !strings.Contains(stderr.String(), `notificationStatus="accepted"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
t.Fatalf("stderr missing notification fields:\n%s", stderr.String())
}
if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") {
t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String())
}
}
func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
server := dailyServer(t)
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"rejected cli-secret-token","retryable":false}`))
}))
t.Cleanup(distributorServer.Close)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"run", "evening",
"--config", configPath,
}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want notification failure")
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if len(summary.Reports) != 1 || summary.Reports[0].NotificationStatus != "failed" {
t.Fatalf("summary reports = %#v, want failed notification", summary.Reports)
}
for _, output := range []string{stdout.String(), stderr.String(), err.Error()} {
if strings.Contains(output, "cli-secret-token") {
t.Fatalf("output contains token value:\n%s", output)
}
}
for _, output := range []string{stdout.String(), stderr.String()} {
if !strings.Contains(output, "[redacted]") {
t.Fatalf("output missing redaction marker:\n%s", output)
}
}
}
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()