From eecf45fafdb1da8bc3151568f33cd61089689266 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:15:22 +0100 Subject: [PATCH] Email the Patrol weekly summary as a report schedule kind The customers most likely to lapse are the ones who stopped opening Pulse, so the in-app "This week" card cannot reach them. Report schedules gain a kind: the default resources kind is the existing PDF or CSV performance report, and patrol_digest emails the same weekly "what Patrol did for you" rollup the Patrol page shows, in plain language, for the whole workspace. It reuses the existing scheduler, cadence, recipients, tenant email config, and Pro advanced-reporting entitlement rather than adding a second scheduler. Digest schedules are weekly and email-only; the server fixes scope, format, and attachments, and a run with no AI service, no Patrol, or no email destination records a last_error that names the missing piece instead of sending an empty week. Nothing is written to disk. The digest assembly moves from the HTTP handler into AISettingsHandler.BuildPatrolDigest so the schedule runner and the endpoint share one code path. docs/MSP.md documents the kind for providers; the Settings > Reporting "Report type" selector follows in a separate change once it has a Pro-licensed browser pass. --- docs/MSP.md | 5 + docs/PATROL_WEEKLY_DIGEST.md | 24 +- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/api-contracts.md | 15 ++ .../subsystems/deployment-installability.md | 8 + .../subsystems/performance-and-scalability.md | 8 + .../internal/subsystems/security-privacy.md | 8 + .../internal/subsystems/storage-recovery.md | 7 + frontend-modern/public/docs/MSP.md | 5 + .../src/utils/__tests__/docsLinks.test.ts | 6 + internal/api/ai_handlers_more_test.go | 27 +++ internal/api/ai_patrol_digest_handler.go | 30 ++- internal/api/metrics_reporting_handlers.go | 14 ++ internal/api/patrol_digest_email.go | 216 ++++++++++++++++++ internal/api/patrol_digest_email_test.go | 89 ++++++++ internal/api/report_schedules.go | 103 ++++++++- internal/api/report_schedules_test.go | 101 ++++++++ internal/api/router.go | 6 + internal/config/report_schedules.go | 14 ++ 19 files changed, 667 insertions(+), 25 deletions(-) create mode 100644 internal/api/patrol_digest_email.go create mode 100644 internal/api/patrol_digest_email_test.go diff --git a/docs/MSP.md b/docs/MSP.md index 1fc2cb69b..855a76365 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -228,6 +228,11 @@ that client's resources: `POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit resources and/or comma-separated resource tags, choose weekly or monthly cadence, and deliver PDF or CSV output by email or to disk. + A schedule's `kind` is `resources` (the default, a PDF or CSV performance + report) or `patrol_digest`, which emails the weekly "what Patrol did for you" + summary for the whole workspace: runs, new and resolved issues, + investigations, fixes, and estimated spend. Digest schedules are weekly and + email-only; scope, format, and attachments are fixed by the server. Report branding (logo + display name) supports a provider-wide default via environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`, diff --git a/docs/PATROL_WEEKLY_DIGEST.md b/docs/PATROL_WEEKLY_DIGEST.md index da77555bc..584538e2b 100644 --- a/docs/PATROL_WEEKLY_DIGEST.md +++ b/docs/PATROL_WEEKLY_DIGEST.md @@ -1,8 +1,8 @@ # Patrol Weekly Digest -Status: building — first slice (aggregation endpoint and in-app "This week" -card) landing on `main` 2026-09-01; weekly email is the unscheduled second -slice. Demand ledger: `pulse-pro/FEATURE_REQUESTS.md`, "Patrol weekly digest +Status: building — endpoint and in-app "This week" card on `main` (PRs #1856 +and #1860, 2026-09-02); the weekly email lands as a `patrol_digest` report +schedule kind. Demand ledger: `pulse-pro/FEATURE_REQUESTS.md`, "Patrol weekly digest (what Patrol did for you)", a named bet under the Patrol operations loop. ## The job, in the customer's words @@ -71,12 +71,18 @@ questions are answered. The Inbox stays a decision surface and does not gain a summary card. **Weekly email second.** The past-due population is the population that has -stopped opening Pulse, so the email is the slice that reaches them. It should -render the same digest through the existing enhanced email manager -(`internal/notifications/email_enhanced.go`, `SendEmailWithRetry`) on a weekly -schedule, respecting the existing notification enable state and email -destination. Webhook and Apprise channels are not in scope for the first -email slice. Not scheduled; the ledger entry's decline condition governs it. +stopped opening Pulse, so the email is the slice that reaches them. It is a +report schedule kind, `patrol_digest`, on the report schedule API +(`POST /api/admin/reports/schedules` with `"kind": "patrol_digest"`): the +existing scheduler, cadence, recipients, and Pro reporting entitlement are +reused, and the run renders the same digest as plain-language HTML and text +(`internal/api/patrol_digest_email.go`) through the tenant email config via +`SendEmailWithRetry`. Digest schedules are weekly and email-only; nothing is +written to disk. A run without an email destination fails with a message that +names the missing setting instead of silently doing nothing. Webhook and +Apprise channels are out of scope. The Settings > Reporting form gains a +"Report type" selector for the kind in a follow-up; its browser proof needs a +Pro-licensed instance, which the isolated verification stack does not have. ## API diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 90139165f..9ab5cf8cb 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -7692,3 +7692,9 @@ handler `internal/api/ai_patrol_digest_handler.go` are read-only. They issue no agent commands, create no action plans, and do not alter capability, token, or binding state. Agent-executed Patrol actions appear in the digest only as counts projected from existing action audit records. + +### Patrol digest schedules do not touch agent authority + +The `patrol_digest` report schedule kind in `internal/api/report_schedules.go` +only reads the digest and sends an email. It issues no agent commands, plans +no actions, and changes no capability, token, or binding state. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 15f92ce6c..e7360c56f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10551,3 +10551,18 @@ payload field for field (snake_case, `by_outcome` as an object, optional `history_since` and `last_run_at`). New payload fields are additive and the client must tolerate their absence. Proof: `frontend-modern/src/api/__tests__/patrol.test.ts`. + +### Report schedules carry a kind; patrol_digest is weekly and email-only + +`ReportSchedule` gains `kind`: `resources` (the default, applied to every +stored schedule without one) or `patrol_digest`. A `patrol_digest` schedule is +the weekly "what Patrol did for you" email for the whole workspace: the server +fixes `format` to `email`, empties `scope`, forces email delivery with no +attachment or disk copy, and rejects a non-weekly cadence +(`invalid_cadence`), disk delivery (`invalid_delivery`), or an unknown kind +(`invalid_kind`). Runs render the digest from `AISettingsHandler.BuildPatrolDigest` +through the tenant email config and record a clear `last_error` when the AI +service, Patrol, or an email destination is missing; nothing is written to +disk. The advanced-reporting entitlement gates both kinds. Proofs: +`internal/api/report_schedules_test.go`, `internal/api/patrol_digest_email_test.go`, +`internal/api/ai_handlers_more_test.go`. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d7e38a7c4..2da852a2a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5015,3 +5015,11 @@ the first release train so the workflow refuses a v6.5 dispatch from any other branch. `scripts/release_control/resolve_release_promotion_test.py` pins the allowlist, the drift refusal, the hotfix path, and the minor soak; `release_promotion_policy_test.py` pins the policy's Release Train section. + +### Provider docs name the Patrol weekly summary schedule kind + +`docs/MSP.md` documents that a report schedule's `kind` is `resources` or +`patrol_digest`, and that digest schedules are weekly, email-only, fleet-wide +summaries whose scope, format, and attachments the server fixes. The delivery +surface (`/api/admin/reports/schedules`) and tenant-local storage are +unchanged. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index af6e5a8ea..cee02dde6 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3009,3 +3009,11 @@ poll, query, websocket subscription, history series, cache, per-row scan, or responsive signal. The Docker drawer evaluates the one bounded string only for the selected host, and summary-only helper collection omits the existing stats-dependent and secondary-inventory work at the agent. + +### Patrol digest schedule runs are bounded reads + +A `patrol_digest` run performs the same bounded reads as +`GET /api/ai/patrol/digest` (retained run history, in-memory findings, a +capped action-audit page, and cost events for the window) once per weekly +occurrence, under the existing schedule run mutex. It adds no polling, no +per-resource fan-out, and no new persistence. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 93bebb1ed..646fb9eba 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2737,3 +2737,11 @@ unprefixed record, preserving their existing payload and access boundaries. `internal/logging/logging_test.go` pins both the level-to-priority mapping and that sink isolation. Deployments that do not explicitly opt in—including containers and interactive terminals—must retain their unprefixed output. + +### Patrol digest emails carry counts, not infrastructure detail + +The weekly Patrol summary email contains aggregate counts, mode copy, and an +optional link to the configured public URL. It never includes resource names, +finding text, commands, evidence, or model names, and it uses the tenant's +existing email configuration and recipients under the admin-only report +schedule routes. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0184dbb96..81d97d342 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5911,3 +5911,10 @@ writes nothing. It creates no backup, snapshot, retention, cleanup, or recovery state. Its action counts are a projection of the existing action audit table (`GetActionAuditsByStates`, filtered to Patrol origin); no new persistence, table, or migration is introduced by the digest. + +### Patrol digest schedules write nothing to disk + +Unlike resource reports, a `patrol_digest` schedule run produces no generated +file under the tenant `reports` directory and never calls the retention prune; +the email body is the only artifact. Recovery and retention state are +unaffected. diff --git a/frontend-modern/public/docs/MSP.md b/frontend-modern/public/docs/MSP.md index 1fc2cb69b..855a76365 100644 --- a/frontend-modern/public/docs/MSP.md +++ b/frontend-modern/public/docs/MSP.md @@ -228,6 +228,11 @@ that client's resources: `POST /api/admin/reports/schedules/{id}/run`. Schedules can target explicit resources and/or comma-separated resource tags, choose weekly or monthly cadence, and deliver PDF or CSV output by email or to disk. + A schedule's `kind` is `resources` (the default, a PDF or CSV performance + report) or `patrol_digest`, which emails the weekly "what Patrol did for you" + summary for the whole workspace: runs, new and resolved issues, + investigations, fixes, and estimated spend. Digest schedules are weekly and + email-only; scope, format, and attachments are fixed by the server. Report branding (logo + display name) supports a provider-wide default via environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`, diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index ce86fbcfe..f501a20f7 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -161,6 +161,12 @@ describe('docsLinks', () => { expect(apiReference).toContain('docs/PATROL_WEEKLY_DIGEST.md'); }); + it('documents the Patrol weekly summary report schedule kind for providers', () => { + const mspGuide = readFileSync(path.join(repoRoot, 'docs', 'MSP.md'), 'utf8'); + expect(mspGuide).toContain('`patrol_digest`'); + expect(mspGuide).toMatch(/Digest schedules are weekly and\s+email-only/); + }); + it('ships the readable SSO identity and user deprovisioning contract', () => { const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8'); const rbacGuide = readFileSync(path.join(repoRoot, 'docs', 'RBAC.md'), 'utf8'); diff --git a/internal/api/ai_handlers_more_test.go b/internal/api/ai_handlers_more_test.go index 32160e5c4..12bc7899f 100644 --- a/internal/api/ai_handlers_more_test.go +++ b/internal/api/ai_handlers_more_test.go @@ -692,3 +692,30 @@ func TestHandleGetPatrolDigest_PayloadContract(t *testing.T) { t.Fatalf("mode = %s, want monitor when no autonomy is configured", payload["mode"]) } } + +// TestBuildPatrolDigestReportsAvailability pins the contract the scheduled +// Patrol summary relies on: without a Patrol service the digest is the zero +// shape and reported as unavailable, so a schedule fails clearly instead of +// emailing an empty week. +func TestBuildPatrolDigestReportsAvailability(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + digest, available := handler.BuildPatrolDigest(context.Background(), 0) + if available { + t.Fatal("digest must report unavailable without a Patrol service") + } + if digest.Window.Days != ai.PatrolDigestDefaultDays || digest.Runs.Total != 0 || digest.Mode != config.PatrolAutonomyMonitor { + t.Fatalf("zero digest = %+v", digest) + } + if digest.Investigations.ByOutcome == nil { + t.Fatal("by_outcome must stay an object for clients and email rendering") + } + + patrol := &ai.PatrolService{} + setUnexportedField(t, patrol, "runHistoryStore", ai.NewPatrolRunHistoryStore(10)) + setUnexportedField(t, handler.defaultAIService, "patrolService", patrol) + if _, available := handler.BuildPatrolDigest(context.Background(), 45); !available { + t.Fatal("digest must report available once a Patrol service exists") + } +} diff --git a/internal/api/ai_patrol_digest_handler.go b/internal/api/ai_patrol_digest_handler.go index c76f1920d..72fa9ba1a 100644 --- a/internal/api/ai_patrol_digest_handler.go +++ b/internal/api/ai_patrol_digest_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "strconv" "strings" @@ -54,16 +55,29 @@ func (h *AISettingsHandler) HandleGetPatrolDigest(w http.ResponseWriter, r *http days = parsed } + digest, _ := h.BuildPatrolDigest(r.Context(), days) + if err := utils.WriteJSONResponse(w, digest); err != nil { + log.Error().Err(err).Msg("Failed to write patrol digest response") + } +} + +// BuildPatrolDigest assembles the digest inputs for the tenant in ctx and +// returns the rollup. The boolean reports whether a Patrol service backed the +// numbers; a false result is the zero-valued shape a client can still render as +// "Patrol has not run", while scheduled emails treat it as "not available". +func (h *AISettingsHandler) BuildPatrolDigest(ctx context.Context, days int) (ai.PatrolDigest, bool) { + days = ai.NormalizePatrolDigestDays(days) input := ai.PatrolDigestInput{ Now: time.Now().UTC(), Days: days, Mode: config.PatrolAutonomyMonitor, RunHistoryCapacity: ai.MaxPatrolRunHistory, } - - if aiService := h.GetAIService(r.Context()); aiService != nil { + available := false + if aiService := h.GetAIService(ctx); aiService != nil { input.Mode = aiService.GetEffectivePatrolAutonomyLevel() if patrol := aiService.GetPatrolService(); patrol != nil { + available = true input.Runs = patrol.GetRunHistory(ai.MaxPatrolRunHistory) if store := patrol.GetFindings(); store != nil { input.Findings = store.GetAll(nil) @@ -75,18 +89,14 @@ func (h *AISettingsHandler) HandleGetPatrolDigest(w http.ResponseWriter, r *http input.Usage = costStore.ListEvents(days + 1) } } - - input.Actions = h.patrolDigestActions(r) - - if err := utils.WriteJSONResponse(w, ai.BuildPatrolDigest(input)); err != nil { - log.Error().Err(err).Msg("Failed to write patrol digest response") - } + input.Actions = h.patrolDigestActions(ctx) + return ai.BuildPatrolDigest(input), available } // patrolDigestActions reads the canonical action audits the digest summarises. // A missing store degrades to an empty action line rather than failing the // whole digest: runs, findings, and spend are still worth showing. -func (h *AISettingsHandler) patrolDigestActions(r *http.Request) []unifiedresources.ActionAuditRecord { +func (h *AISettingsHandler) patrolDigestActions(ctx context.Context) []unifiedresources.ActionAuditRecord { if h == nil { return nil } @@ -96,7 +106,7 @@ func (h *AISettingsHandler) patrolDigestActions(r *http.Request) []unifiedresour if provider == nil { return nil } - store, err := provider(GetOrgID(r.Context())) + store, err := provider(GetOrgID(ctx)) if err != nil || store == nil { if err != nil { log.Debug().Err(err).Msg("Failed to resolve resource store for Patrol digest actions") diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index eed80fc38..ebf7c9168 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" @@ -99,6 +100,19 @@ type ReportingHandlers struct { settingsStore reportingSystemSettingsStore scheduleRunMu sync.Mutex commercialLicenseResolver func(ctx context.Context) *licenseService + // patrolDigestResolver builds the Patrol digest for the request's tenant. + // Nil means the runtime has no AI service and digest schedules fail with a + // clear error instead of sending an empty email. + patrolDigestResolver func(ctx context.Context, days int) (ai.PatrolDigest, bool) +} + +// SetPatrolDigestResolver wires the per-tenant Patrol digest used by +// patrol_digest report schedules. +func (h *ReportingHandlers) SetPatrolDigestResolver(resolver func(ctx context.Context, days int) (ai.PatrolDigest, bool)) { + if h == nil { + return + } + h.patrolDigestResolver = resolver } type reportingSystemSettingsStore interface { diff --git a/internal/api/patrol_digest_email.go b/internal/api/patrol_digest_email.go new file mode 100644 index 000000000..3479acc37 --- /dev/null +++ b/internal/api/patrol_digest_email.go @@ -0,0 +1,216 @@ +package api + +import ( + "fmt" + "html" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// renderPatrolDigestEmail turns the digest into the weekly "what Patrol did for +// you" email. It mirrors the Patrol page's This week card line for line and +// uses the same plain vocabulary: no evidence classes, verdicts, or model names. +// The mode sentence is included because, unlike the page, the email has no +// header that already states it. +func renderPatrolDigestEmail(digest ai.PatrolDigest, publicURL string) (subject, htmlBody, textBody string) { + publicURL = strings.TrimRight(strings.TrimSpace(publicURL), "/") + lines := patrolDigestEmailLines(digest) + subject = patrolDigestEmailSubject(digest) + + var text strings.Builder + text.WriteString("What Patrol did for you. " + patrolDigestWindowLabel(digest) + ".\n\n") + for _, line := range lines { + text.WriteString(line.label + ": " + line.value + "\n") + for _, detail := range line.details { + text.WriteString(" " + detail + "\n") + } + text.WriteString("\n") + } + text.WriteString(patrolDigestModeSentence(digest.Mode) + "\n") + if publicURL != "" { + text.WriteString("\nOpen Patrol: " + publicURL + "/patrol\n") + if digest.Actions.Pending > 0 { + text.WriteString("Review waiting fixes: " + publicURL + "/actions\n") + } + } + textBody = text.String() + + var b strings.Builder + b.WriteString(`
`) + b.WriteString(`

This week

`) + b.WriteString(`

What Patrol did for you. ` + html.EscapeString(patrolDigestWindowLabel(digest)) + `.

`) + for _, line := range lines { + b.WriteString(`

` + html.EscapeString(line.label) + `: ` + html.EscapeString(line.value)) + for _, detail := range line.details { + b.WriteString(`
` + html.EscapeString(detail) + ``) + } + b.WriteString(`

`) + } + b.WriteString(`

` + html.EscapeString(patrolDigestModeSentence(digest.Mode)) + `

`) + if publicURL != "" { + safe := html.EscapeString(publicURL) + b.WriteString(`

Open Patrol`) + if digest.Actions.Pending > 0 { + b.WriteString(` · Review waiting fixes`) + } + b.WriteString(`

`) + } + b.WriteString(`
`) + htmlBody = b.String() + return subject, htmlBody, textBody +} + +type patrolDigestEmailLine struct { + label string + value string + details []string +} + +func patrolDigestEmailSubject(digest ai.PatrolDigest) string { + if digest.Runs.Total == 0 { + return fmt.Sprintf("Pulse: Patrol has not run in the last %d days", digest.Window.Days) + } + return fmt.Sprintf("Pulse: what Patrol did this week (%s, %s)", + pluralCount(digest.Runs.Total, "run", "runs"), + pluralCount(digest.Findings.New, "new issue", "new issues")) +} + +func patrolDigestWindowLabel(digest ai.PatrolDigest) string { + if !digest.Window.HistoryComplete && digest.Window.HistorySince != nil { + return "Since " + digest.Window.HistorySince.UTC().Format("2 Jan") + " (older runs are no longer kept)" + } + return fmt.Sprintf("Last %d days", digest.Window.Days) +} + +func patrolDigestEmailLines(digest ai.PatrolDigest) []patrolDigestEmailLine { + runs := patrolDigestEmailLine{label: "Patrol runs", value: fmt.Sprintf("%d", digest.Runs.Total)} + runs.details = append(runs.details, fmt.Sprintf("%s across %s.", pluralCount(digest.Runs.Checks, "check", "checks"), pluralCount(digest.Runs.ResourcesCovered, "resource", "resources"))) + if digest.Alerts.Reviewed > 0 { + runs.details = append(runs.details, pluralCount(digest.Alerts.Reviewed, "alert", "alerts")+" looked into.") + } + if digest.Runs.Failed > 0 { + runs.details = append(runs.details, pluralCount(digest.Runs.Failed, "run", "runs")+" failed.") + } + if digest.Runs.LastRunAt != nil { + runs.details = append(runs.details, "Last run "+digest.Runs.LastRunAt.UTC().Format("2 Jan 15:04 UTC")+".") + } + + open := digest.Findings.OpenBySeverity + openTotal := open.Critical + open.Warning + open.Watch + open.Info + newLine := patrolDigestEmailLine{label: "New issues", value: fmt.Sprintf("%d", digest.Findings.New)} + switch { + case digest.Findings.New == 0: + newLine.details = []string{"Nothing new was raised."} + case openTotal == 0: + newLine.details = []string{"All of them have since cleared."} + default: + parts := []string{} + if open.Critical > 0 { + parts = append(parts, fmt.Sprintf("%d critical", open.Critical)) + } + if open.Warning > 0 { + parts = append(parts, fmt.Sprintf("%d warning", open.Warning)) + } + detail := fmt.Sprintf("%d still open", openTotal) + if len(parts) > 0 { + detail += " (" + strings.Join(parts, ", ") + ")" + } + newLine.details = []string{detail + "."} + } + + resolved := patrolDigestEmailLine{label: "Issues resolved", value: fmt.Sprintf("%d", digest.Findings.Resolved)} + if digest.Findings.AutoResolved > 0 { + resolved.details = append(resolved.details, fmt.Sprintf("%d cleared by Patrol on its own.", digest.Findings.AutoResolved)) + } + if digest.Findings.Dismissed > 0 { + resolved.details = append(resolved.details, fmt.Sprintf("%d dismissed by you.", digest.Findings.Dismissed)) + } + if digest.Findings.Suppressed > 0 { + resolved.details = append(resolved.details, fmt.Sprintf("%d muted for good.", digest.Findings.Suppressed)) + } + if len(resolved.details) == 0 { + if digest.Findings.Resolved > 0 { + resolved.details = []string{"Resolved by you."} + } else { + resolved.details = []string{"No issues were resolved this period."} + } + } + + investigated := patrolDigestEmailLine{label: "Investigated", value: fmt.Sprintf("%d", digest.Investigations.Total)} + outcomeCopy := map[string]string{ + "needs_attention": "need you", "fix_failed": "fix failed", "fix_verification_failed": "fix not confirmed", + "cannot_fix": "could not fix", "timed_out": "timed out", "fix_queued": "fix waiting for approval", + "fix_executed": "fix run", "fix_verification_unknown": "fix run, result unknown", "fix_verified": "fixed and verified", + "resolved": "resolved", "fix_rejected": "fix declined", + } + outcomeDetails := []string{} + for _, key := range ai.PatrolDigestOutcomeOrder(digest.Investigations.ByOutcome) { + label, ok := outcomeCopy[key] + if !ok { + continue + } + outcomeDetails = append(outcomeDetails, fmt.Sprintf("%d %s", digest.Investigations.ByOutcome[key], label)) + if len(outcomeDetails) == 2 { + break + } + } + switch { + case len(outcomeDetails) > 0: + investigated.details = []string{strings.Join(outcomeDetails, ", ") + "."} + case digest.Mode == config.PatrolAutonomyMonitor: + investigated.details = []string{"Patrol is watch only, so it reports issues without investigating them."} + default: + investigated.details = []string{"No issues needed a closer look."} + } + + fixes := patrolDigestEmailLine{label: "Fixes run", value: fmt.Sprintf("%d", digest.Actions.Executed)} + if digest.Actions.Executed > 0 { + fixes.details = append(fixes.details, fmt.Sprintf("%d of %d verified afterwards.", digest.Actions.Verified, digest.Actions.Executed)) + } + if digest.Actions.Failed > 0 { + fixes.details = append(fixes.details, pluralCount(digest.Actions.Failed, "action", "actions")+" failed.") + } + if digest.Actions.Rejected > 0 { + fixes.details = append(fixes.details, fmt.Sprintf("%d declined by you.", digest.Actions.Rejected)) + } + if digest.Actions.Pending > 0 { + fixes.details = append(fixes.details, pluralCount(digest.Actions.Pending, "fix", "fixes")+" waiting for your approval.") + } + if len(fixes.details) == 0 { + if digest.Mode == config.PatrolAutonomyMonitor { + fixes.details = []string{"Patrol is watch only, so no fixes were proposed."} + } else { + fixes.details = []string{"No fixes were needed."} + } + } + + spend := patrolDigestEmailLine{label: "Estimated spend", value: fmt.Sprintf("$%.2f", digest.Spend.EstimatedUSD)} + spend.details = append(spend.details, pluralCount(digest.Spend.Calls, "model call", "model calls")+".") + if digest.Spend.Calls > 0 && !digest.Spend.PricingKnown { + spend.details = append(spend.details, "Some calls used a model with no known price.") + } + + return []patrolDigestEmailLine{runs, newLine, resolved, investigated, fixes, spend} +} + +func patrolDigestModeSentence(mode string) string { + switch mode { + case config.PatrolAutonomyApproval: + return "Ask first. Patrol investigates and prepares fixes, but every change waits for your approval." + case config.PatrolAutonomyAssisted: + return "Safe auto-fix. Patrol can run low- or medium-risk fixes allowed by policy. Higher-risk work still asks first." + case config.PatrolAutonomyFull: + return "Autopilot. Patrol can act automatically within policy and still asks when approval is required." + default: + return "Watch only. Patrol checks infrastructure and reports issues only. It does not start fixes." + } +} + +func pluralCount(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %s", count, plural) +} diff --git a/internal/api/patrol_digest_email_test.go b/internal/api/patrol_digest_email_test.go new file mode 100644 index 000000000..7e0afb4e9 --- /dev/null +++ b/internal/api/patrol_digest_email_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestRenderPatrolDigestEmailMirrorsTheCardInPlainLanguage(t *testing.T) { + last := time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC) + digest := ai.PatrolDigest{ + Window: ai.PatrolDigestWindow{Days: 7, HistoryComplete: true}, + Mode: config.PatrolAutonomyApproval, + Runs: ai.PatrolDigestRuns{Total: 38, Checks: 1520, ResourcesCovered: 40, Failed: 1, LastRunAt: &last}, + Findings: ai.PatrolDigestFindings{ + New: 12, OpenBySeverity: ai.PatrolDigestSeverityCounts{Critical: 1, Warning: 3}, Resolved: 9, AutoResolved: 7, Dismissed: 2, + }, + Investigations: ai.PatrolDigestInvestigations{Total: 4, ByOutcome: map[string]int{"fix_verified": 2, "needs_attention": 1, "resolved": 1}}, + Actions: ai.PatrolDigestActions{Executed: 2, Verified: 1, Pending: 1}, + Alerts: ai.PatrolDigestAlerts{Reviewed: 5}, + Spend: ai.PatrolDigestSpend{EstimatedUSD: 1.2345, PricingKnown: true, Calls: 40}, + } + + subject, htmlBody, textBody := renderPatrolDigestEmail(digest, "https://pulse.example.com/") + + if subject != "Pulse: what Patrol did this week (38 runs, 12 new issues)" { + t.Fatalf("subject = %q", subject) + } + for _, want := range []string{ + "Patrol runs: 38", "1520 checks across 40 resources.", "5 alerts looked into.", "1 run failed.", + "New issues: 12", "4 still open (1 critical, 3 warning).", + "Issues resolved: 9", "7 cleared by Patrol on its own.", "2 dismissed by you.", + "Investigated: 4", "1 need you, 2 fixed and verified.", + "Fixes run: 2", "1 of 2 verified afterwards.", "1 fix waiting for your approval.", + "Estimated spend: $1.23", "40 model calls.", + "Ask first. Patrol investigates and prepares fixes, but every change waits for your approval.", + "Open Patrol: https://pulse.example.com/patrol", "Review waiting fixes: https://pulse.example.com/actions", + } { + if !strings.Contains(textBody, want) { + t.Fatalf("text body missing %q:\n%s", want, textBody) + } + } + for _, want := range []string{`href="https://pulse.example.com/patrol"`, `href="https://pulse.example.com/actions"`, "New issues: 12"} { + if !strings.Contains(htmlBody, want) { + t.Fatalf("html body missing %q:\n%s", want, htmlBody) + } + } + for _, forbidden := range []string{"evidence class", "verdict", "agent_attested", "fix_verified"} { + if strings.Contains(strings.ToLower(textBody), forbidden) || strings.Contains(strings.ToLower(htmlBody), forbidden) { + t.Fatalf("email leaks forensic vocabulary %q", forbidden) + } + } +} + +func TestRenderPatrolDigestEmailHandlesQuietWatchOnlyWeeks(t *testing.T) { + since := time.Date(2026, 8, 29, 8, 0, 0, 0, time.UTC) + digest := ai.PatrolDigest{ + Window: ai.PatrolDigestWindow{Days: 7, HistoryComplete: false, HistorySince: &since}, + Mode: config.PatrolAutonomyMonitor, + Runs: ai.PatrolDigestRuns{Total: 3, Checks: 30, ResourcesCovered: 10}, + Investigations: ai.PatrolDigestInvestigations{ByOutcome: map[string]int{}}, + Spend: ai.PatrolDigestSpend{PricingKnown: false, Calls: 3, EstimatedUSD: 0.5}, + } + subject, htmlBody, textBody := renderPatrolDigestEmail(digest, "