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(`
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(`
` + 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(`