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.
This commit is contained in:
rcourtman
2026-09-02 11:15:22 +01:00
parent b5f694b216
commit eecf45fafd
19 changed files with 667 additions and 25 deletions
+5
View File
@@ -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`,
+15 -9
View File
@@ -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
@@ -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.
@@ -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`.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+5
View File
@@ -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`,
@@ -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');
+27
View File
@@ -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")
}
}
+20 -10
View File
@@ -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")
@@ -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 {
+216
View File
@@ -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(`<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;color:#111827">`)
b.WriteString(`<h2 style="margin:0 0 4px">This week</h2>`)
b.WriteString(`<p style="margin:0 0 16px;color:#6b7280">What Patrol did for you. ` + html.EscapeString(patrolDigestWindowLabel(digest)) + `.</p>`)
for _, line := range lines {
b.WriteString(`<p style="margin:0 0 12px"><strong>` + html.EscapeString(line.label) + `:</strong> ` + html.EscapeString(line.value))
for _, detail := range line.details {
b.WriteString(`<br><span style="color:#6b7280">` + html.EscapeString(detail) + `</span>`)
}
b.WriteString(`</p>`)
}
b.WriteString(`<p style="margin:16px 0 0;color:#6b7280">` + html.EscapeString(patrolDigestModeSentence(digest.Mode)) + `</p>`)
if publicURL != "" {
safe := html.EscapeString(publicURL)
b.WriteString(`<p style="margin:16px 0 0"><a href="` + safe + `/patrol">Open Patrol</a>`)
if digest.Actions.Pending > 0 {
b.WriteString(` &middot; <a href="` + safe + `/actions">Review waiting fixes</a>`)
}
b.WriteString(`</p>`)
}
b.WriteString(`</div>`)
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)
}
+89
View File
@@ -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"`, "<strong>New issues:</strong> 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, "<script>")
if subject != "Pulse: what Patrol did this week (3 runs, 0 new issues)" {
t.Fatalf("subject = %q", subject)
}
for _, want := range []string{
"Since 29 Aug (older runs are no longer kept)", "Nothing new was raised.", "No issues were resolved this period.",
"Patrol is watch only, so it reports issues without investigating them.", "Patrol is watch only, so no fixes were proposed.",
"Some calls used a model with no known price.", "Watch only. Patrol checks infrastructure and reports issues only.",
} {
if !strings.Contains(textBody, want) {
t.Fatalf("text body missing %q:\n%s", want, textBody)
}
}
if strings.Contains(htmlBody, "<script>") {
t.Fatal("public URL must be HTML-escaped")
}
empty := ai.PatrolDigest{Window: ai.PatrolDigestWindow{Days: 7, HistoryComplete: true}, Investigations: ai.PatrolDigestInvestigations{ByOutcome: map[string]int{}}, Spend: ai.PatrolDigestSpend{PricingKnown: true}}
subject, _, _ = renderPatrolDigestEmail(empty, "")
if subject != "Pulse: Patrol has not run in the last 7 days" {
t.Fatalf("empty subject = %q", subject)
}
}
+97 -6
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
@@ -297,6 +298,13 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule
if schedule.Name == "" || len(schedule.Name) > 80 {
return schedule, reportScheduleValidationError{code: "invalid_name", message: "Schedule name is required and must be 80 characters or fewer"}
}
switch schedule.Kind {
case config.ReportScheduleKindResources:
case config.ReportScheduleKindPatrolDigest:
return preparePatrolDigestSchedule(schedule, now)
default:
return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources or patrol_digest"}
}
if schedule.Format == "" {
schedule.Format = config.ReportScheduleFormatPDF
}
@@ -462,6 +470,9 @@ func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence *
if err := h.requireCommercialFeature(ctx, featureAdvancedReportingValue); err != nil {
return result, markReportScheduleFailed(schedule, now, fmt.Errorf("advanced reporting entitlement required: %w", err))
}
if schedule.Kind == config.ReportScheduleKindPatrolDigest {
return h.runPatrolDigestSchedule(ctx, persistence, schedule, now)
}
orgID := GetOrgID(ctx)
resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope)
@@ -601,13 +612,16 @@ func pruneGeneratedReports(dir string, retentionCount int) {
}
}
func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport, path string) (string, error) {
// scheduledReportEmailManager builds the email manager for a schedule from the
// tenant email config. It returns nil, nil when email is not configured or no
// recipient can be resolved, so callers decide how to report that.
func scheduledReportEmailManager(persistence *config.ConfigPersistence, schedule config.ReportSchedule) (*notifications.EnhancedEmailManager, error) {
emailCfg, err := persistence.LoadEmailConfig()
if err != nil {
return "", fmt.Errorf("load email config: %w", err)
return nil, fmt.Errorf("load email config: %w", err)
}
if emailCfg == nil || !emailCfg.Enabled {
return "saved_to_disk_email_unconfigured", nil
return nil, nil
}
recipients := schedule.Delivery.To
if len(recipients) == 0 {
@@ -617,9 +631,8 @@ func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule co
recipients = []string{emailCfg.From}
}
if len(recipients) == 0 {
return "saved_to_disk_email_unconfigured", nil
return nil, nil
}
providerConfig := notifications.EmailProviderConfig{
EmailConfig: notifications.EmailConfig{
Provider: emailCfg.Provider,
@@ -640,7 +653,17 @@ func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule co
StartTLS: emailCfg.StartTLS,
AuthRequired: emailCfg.Username != "" && emailCfg.Password != "",
}
manager := notifications.NewEnhancedEmailManager(providerConfig)
return notifications.NewEnhancedEmailManager(providerConfig), nil
}
func sendScheduledReportEmail(persistence *config.ConfigPersistence, schedule config.ReportSchedule, report generatedMultiReport, path string) (string, error) {
manager, err := scheduledReportEmailManager(persistence, schedule)
if err != nil {
return "", err
}
if manager == nil {
return "saved_to_disk_email_unconfigured", nil
}
subject := "Pulse report: " + schedule.Name
htmlBody := "<p>Your scheduled Pulse report is ready.</p>"
textBody := "Your scheduled Pulse report is ready."
@@ -938,3 +961,71 @@ func (h *ReportingHandlers) runDueReportSchedulesForOrg(ctx context.Context, org
}
}
}
// preparePatrolDigestSchedule validates the Patrol weekly summary kind. The
// summary is fleet-wide and only makes sense as a weekly email, so scope,
// format, attachments, and disk delivery are fixed rather than user choices.
func preparePatrolDigestSchedule(schedule config.ReportSchedule, now time.Time) (config.ReportSchedule, error) {
schedule.Format = config.ReportScheduleFormatEmail
schedule.Scope = config.ReportScheduleScope{Resources: []config.ReportScheduleResource{}, Tags: []string{}}
if schedule.Delivery.Method == "" {
schedule.Delivery.Method = config.ReportScheduleDeliveryEmail
}
if schedule.Delivery.Method != config.ReportScheduleDeliveryEmail {
return schedule, reportScheduleValidationError{code: "invalid_delivery", message: "Patrol weekly summaries are delivered by email"}
}
schedule.Delivery.Attach = false
schedule.Delivery.SaveToDisk = false
if schedule.Cadence.Type != config.ReportScheduleCadenceWeekly {
return schedule, reportScheduleValidationError{code: "invalid_cadence", message: "Patrol weekly summaries use a weekly cadence"}
}
if err := validateReportScheduleCadence(schedule.Cadence); err != nil {
return schedule, err
}
next, err := nextReportScheduleRunAt(schedule, now)
if err != nil {
return schedule, err
}
schedule.NextRunAt = &next
return schedule, nil
}
// runPatrolDigestSchedule emails the last seven days of Patrol work. Nothing is
// written to disk: the digest is a rollup of records Pulse already keeps, so
// the email is the only artifact.
func (h *ReportingHandlers) runPatrolDigestSchedule(ctx context.Context, persistence *config.ConfigPersistence, schedule config.ReportSchedule, now time.Time) (reportScheduleRunResult, config.ReportSchedule) {
result := reportScheduleRunResult{}
if h == nil || h.patrolDigestResolver == nil {
return result, markReportScheduleFailed(schedule, now, errors.New("Patrol weekly summary is unavailable on this server"))
}
digest, ok := h.patrolDigestResolver(ctx, ai.PatrolDigestDefaultDays)
if !ok {
return result, markReportScheduleFailed(schedule, now, errors.New("Patrol is not available for this workspace"))
}
manager, err := scheduledReportEmailManager(persistence, schedule)
if err != nil {
return result, markReportScheduleFailed(schedule, now, err)
}
if manager == nil {
return result, markReportScheduleFailed(schedule, now, errors.New("email notifications are not configured; add an email destination under Settings > Notifications or list recipients on the schedule"))
}
subject, htmlBody, textBody := renderPatrolDigestEmail(digest, h.patrolDigestPublicURL())
if err := manager.SendEmailWithRetry(subject, htmlBody, textBody); err != nil {
return result, markReportScheduleFailed(schedule, now, fmt.Errorf("send Patrol weekly summary: %w", err))
}
result.email = "sent"
schedule.LastRunStatus = config.ReportScheduleLastRunOK
schedule.LastError = ""
return result, schedule
}
func (h *ReportingHandlers) patrolDigestPublicURL() string {
if h == nil || h.settingsStore == nil {
return ""
}
settings, err := h.settingsStore.LoadSystemSettings()
if err != nil || settings == nil {
return ""
}
return strings.TrimRight(strings.TrimSpace(settings.PublicURL), "/")
}
+101
View File
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
@@ -254,3 +255,103 @@ func TestSaveGeneratedReportUsesHashedScheduleDirectory(t *testing.T) {
t.Fatalf("report filename still contains a path separator: %q", filepath.Base(path))
}
}
func TestPreparePatrolDigestScheduleFixesFormatScopeAndDelivery(t *testing.T) {
handlers, _ := newTestReportingScheduleHandlers(t)
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
now := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
base := config.ReportSchedule{
ID: "digest-1",
Name: "Patrol weekly summary",
Enabled: true,
Kind: config.ReportScheduleKindPatrolDigest,
Cadence: config.ReportScheduleCadence{Type: config.ReportScheduleCadenceWeekly, Weekday: "monday", Time: "08:00", Timezone: "UTC"},
// Clients may still send resource-report fields; the digest kind ignores them.
Scope: config.ReportScheduleScope{Tags: []string{"production"}},
Format: config.ReportScheduleFormatPDF,
Delivery: config.ReportScheduleDelivery{Method: config.ReportScheduleDeliveryEmail, To: []string{"ops@example.com"}, Attach: true, SaveToDisk: true},
}
prepared, err := handlers.prepareReportSchedule(ctx, base, now, false)
if err != nil {
t.Fatalf("prepare digest schedule: %v", err)
}
if prepared.Format != config.ReportScheduleFormatEmail || prepared.Delivery.Attach || prepared.Delivery.SaveToDisk {
t.Fatalf("digest schedule must be an email-only summary, got %+v", prepared)
}
if len(prepared.Scope.Resources) != 0 || len(prepared.Scope.Tags) != 0 {
t.Fatalf("digest schedule must not carry a resource scope, got %+v", prepared.Scope)
}
if prepared.NextRunAt == nil || !prepared.NextRunAt.After(now) {
t.Fatalf("digest schedule needs a next run, got %v", prepared.NextRunAt)
}
monthly := base
monthly.Cadence = config.ReportScheduleCadence{Type: config.ReportScheduleCadenceMonthly, DayOfMonth: 1, Time: "08:00", Timezone: "UTC"}
if _, err := handlers.prepareReportSchedule(ctx, monthly, now, false); err == nil || !strings.Contains(err.Error(), "weekly") {
t.Fatalf("monthly digest must be rejected, got %v", err)
}
disk := base
disk.Delivery.Method = config.ReportScheduleDeliveryDisk
if _, err := handlers.prepareReportSchedule(ctx, disk, now, false); err == nil || !strings.Contains(err.Error(), "email") {
t.Fatalf("disk digest must be rejected, got %v", err)
}
unknown := base
unknown.Kind = "spreadsheet"
if _, err := handlers.prepareReportSchedule(ctx, unknown, now, false); err == nil || !strings.Contains(err.Error(), "kind") {
t.Fatalf("unknown kind must be rejected, got %v", err)
}
// Legacy schedules without a kind stay resource reports.
legacy := validReportSchedulePayload()
legacy.ID = "legacy-1"
preparedLegacy, err := handlers.prepareReportSchedule(ctx, legacy, now, false)
if err != nil {
t.Fatalf("prepare legacy schedule: %v", err)
}
if preparedLegacy.Kind != config.ReportScheduleKindResources || preparedLegacy.Format != config.ReportScheduleFormatPDF {
t.Fatalf("legacy schedule = %+v, want resources kind", preparedLegacy)
}
}
func TestRunPatrolDigestScheduleFailsClearlyWithoutDigestOrEmail(t *testing.T) {
handlers, persistence := newTestReportingScheduleHandlers(t)
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
now := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
schedule := config.NormalizeReportSchedule(config.ReportSchedule{
ID: "digest-run", Name: "Patrol weekly summary", Enabled: true, Kind: config.ReportScheduleKindPatrolDigest,
Cadence: config.ReportScheduleCadence{Type: config.ReportScheduleCadenceWeekly, Weekday: "monday", Time: "08:00", Timezone: "UTC"},
Format: config.ReportScheduleFormatEmail,
Delivery: config.ReportScheduleDelivery{Method: config.ReportScheduleDeliveryEmail},
})
_, withoutResolver := handlers.runReportSchedule(ctx, persistence, schedule, now, true, "")
if withoutResolver.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(withoutResolver.LastError, "unavailable") {
t.Fatalf("run without resolver = %+v, want a clear unavailable failure", withoutResolver)
}
handlers.SetPatrolDigestResolver(func(context.Context, int) (ai.PatrolDigest, bool) { return ai.PatrolDigest{}, false })
_, notAvailable := handlers.runReportSchedule(ctx, persistence, schedule, now, true, "")
if notAvailable.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(notAvailable.LastError, "not available") {
t.Fatalf("run without Patrol = %+v", notAvailable)
}
calls := 0
handlers.SetPatrolDigestResolver(func(_ context.Context, days int) (ai.PatrolDigest, bool) {
calls++
if days != ai.PatrolDigestDefaultDays {
t.Fatalf("digest days = %d, want %d", days, ai.PatrolDigestDefaultDays)
}
return ai.BuildPatrolDigest(ai.PatrolDigestInput{Now: now, Days: days}), true
})
_, noEmail := handlers.runReportSchedule(ctx, persistence, schedule, now, true, "")
if calls != 1 {
t.Fatalf("resolver calls = %d, want 1", calls)
}
if noEmail.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(noEmail.LastError, "email notifications are not configured") {
t.Fatalf("run without email config = %+v, want a failure that names the missing email destination", noEmail)
}
if noEmail.NextRunAt == nil {
t.Fatal("failed digest run must still schedule the next occurrence")
}
}
+6
View File
@@ -804,6 +804,12 @@ func (r *Router) setupRoutes() {
// falls back to the heuristic narrators with no findings section.
if r.reportingHandlers != nil {
settings := r.aiSettingsHandler
r.reportingHandlers.SetPatrolDigestResolver(func(ctx context.Context, days int) (ai.PatrolDigest, bool) {
if settings == nil {
return ai.PatrolDigest{}, false
}
return settings.BuildPatrolDigest(ctx, days)
})
r.reportingHandlers.SetNarratorResolver(func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) {
if settings == nil {
return nil, nil, nil
+14
View File
@@ -15,6 +15,15 @@ const (
ReportScheduleFormatPDF = "pdf"
ReportScheduleFormatCSV = "csv"
// ReportScheduleFormatEmail marks schedules whose output is the email body
// itself (no generated file); used by the Patrol digest kind.
ReportScheduleFormatEmail = "email"
// ReportScheduleKindResources is the original multi-resource performance
// report; ReportScheduleKindPatrolDigest emails the "what Patrol did"
// weekly rollup for the whole workspace.
ReportScheduleKindResources = "resources"
ReportScheduleKindPatrolDigest = "patrol_digest"
ReportScheduleDeliveryEmail = "email"
ReportScheduleDeliveryDisk = "disk"
@@ -33,6 +42,7 @@ type ReportSchedule struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Kind string `json:"kind,omitempty"`
Cadence ReportScheduleCadence `json:"cadence"`
Scope ReportScheduleScope `json:"scope"`
Window string `json:"window,omitempty"`
@@ -91,6 +101,10 @@ func NormalizeReportScheduleStore(store ReportScheduleStore) ReportScheduleStore
func NormalizeReportSchedule(schedule ReportSchedule) ReportSchedule {
schedule.ID = strings.TrimSpace(schedule.ID)
schedule.Name = strings.TrimSpace(schedule.Name)
schedule.Kind = strings.ToLower(strings.TrimSpace(schedule.Kind))
if schedule.Kind == "" {
schedule.Kind = ReportScheduleKindResources
}
schedule.Cadence.Type = strings.ToLower(strings.TrimSpace(schedule.Cadence.Type))
schedule.Cadence.Weekday = strings.ToLower(strings.TrimSpace(schedule.Cadence.Weekday))
schedule.Cadence.Time = strings.TrimSpace(schedule.Cadence.Time)