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, "