Add the Patrol weekly digest endpoint

Nothing in Pulse showed a paying customer what Patrol had done for them:
about 164 runs a month per install, findings raised, investigations and
fixes, and none of it summarised. GET /api/ai/patrol/digest rolls the last
N days (default 7, max 30) up from records Pulse already keeps: run
history, the findings store, Patrol-origin action audits, and the usage
cost store. It adds no telemetry and persists nothing. The payload
reports when the bounded run history no longer covers the window and when
model pricing is unknown, rather than quietly under-counting.

This is the first slice of the "Patrol weekly digest" named bet in the
pulse-pro demand ledger; the in-app "This week" card follows once its
browser pass is recorded. docs/PATROL_WEEKLY_DIGEST.md holds the design
note and the honest limits of each line.

status.json registers the patrol-value-visibility coverage gap, the
candidate lane, and its work claim. It also drops the second, identical
copy of the ai-provider-guided-setup coverage gap that landed with #1853;
the duplicate id fails the status audit on main for every pull request.
This commit is contained in:
rcourtman
2026-09-02 08:53:50 +01:00
parent b1d1a03643
commit 8ea94d792b
17 changed files with 1223 additions and 46 deletions
+14
View File
@@ -1138,6 +1138,20 @@ Kubernetes-specific analysis as a standalone plan pillar.
Runs a focused investigation for an alert payload (used by the UI).
### Patrol
- `GET /api/ai/patrol/digest`
- Returns the "what Patrol did for you" rollup for the last `days` days
(query `days`, 130, default 7): the window and whether retained run
history covers it, the effective Patrol mode, runs (total, scheduled,
event-triggered, manual, failed, checks, resources covered, last run),
findings (new, still open by severity, resolved, auto-resolved,
dismissed, suppressed), investigations by outcome, Patrol-origin actions
(proposed, approved, rejected, executed, verified, failed, pending),
alerts Patrol reviewed, and estimated model spend with a pricing-known
flag.
- Computed from records Pulse already retains (run history, the findings
store, canonical action audits, and usage cost events); nothing new is
persisted. Requires the `ai:execute` scope. Backs the Patrol page's
"This week" card; see `docs/PATROL_WEEKLY_DIGEST.md`.
- `GET /api/ai/patrol/attention`
- Returns the typed Patrol attention queue projected from canonical
operational lifecycle records. This is the active-count and queue source
+88
View File
@@ -0,0 +1,88 @@
# 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
(what Patrol did for you)", a named bet under the Patrol operations loop.
## The job, in the customer's words
"Show me what I am paying for." A Pro customer turns Patrol on, it runs about
five times a day, and nothing in Pulse ever adds that up. They see individual
findings when they open the page and individual emails when a finding fires,
but never the week: how often Patrol looked, what it caught, what it fixed,
what it cost. The 2026-09-01 assessment found 33 of 175 Pro subscriptions past
due and 80 percent of paying installs never seeing the paid loop fire. A
customer who cannot see the work stops paying for it.
The least-expert plausible reader is a homelab operator who set Patrol up once
and opens Pulse when an alert email arrives. They do not know what an
"investigation", a "verdict", or an "evidence class" is. The digest must read
as a plain weekly account, and every number must lead to something they can
click or a decision they can make.
## What the digest reports
Everything below is computed from data Pulse already records. No new
telemetry fields; the readout task grades this bet from support and issue
mentions.
| Line | Source | What the reader does with it |
| --- | --- | --- |
| Runs: how many times Patrol checked, how many checks in total, how many resources it covered, when it last ran | `PatrolRunRecord` history (`internal/ai/patrol.go`, capped at 100 runs) | Confirms Patrol is alive. If runs are zero or failed, the card says so and points at Patrol setup. |
| Mode: monitor, approval, assisted, or full | `Service.GetEffectivePatrolAutonomyLevel` | Explains why actions were or were not taken. Monitor mode with fixable findings is the upgrade prompt, stated plainly. |
| New issues this week, and how many are still open by severity | run `new_findings` totals plus the findings store for still-tracked findings detected in the window | Open critical or warning issues are the thing to look at; the count links to the findings list. |
| Issues resolved and how many Patrol resolved on its own | run `resolved_findings` totals plus manual resolutions still in the findings store | This is the "it did something" line. |
| Issues you dismissed or muted | finding lifecycle events (`dismissed`, `suppressed`) in the window | Reminds the reader what they told Patrol to ignore. |
| Investigations and their outcomes | finding lifecycle `investigation_outcome` events in the window | Shows the paid loop firing; outcomes that need attention are named. |
| Actions proposed, approved, executed, verified, failed, and still waiting | canonical action audit records with `origin.surface = patrol` (`internal/unifiedresources`) | "Waiting" links straight to Actions and approvals. |
| Alerts Patrol looked into | alert-triggered runs (`alert_fired`, `alert_flapping`) and their distinct alert identifiers | Shows Patrol responding to the reader's own alerts, not just its schedule. |
| Estimated model spend, tokens, and calls | `internal/ai/cost` usage events with `use_case = patrol` | Answers "what does this cost me" without opening the AI cost dashboard. Marked as an estimate; unknown pricing is stated, never zeroed. |
Deliberately left out: per-run tool call traces, evidence classes, verdict
strings, model names, and anything the reader cannot act on. Those stay in run
history and the Actions audit, one click away.
## Honest limits of the data
- Run history keeps the last 100 runs. At five or six runs a day a week fits,
but a busy install with event-triggered runs can overflow. The payload
carries `window.history_complete` and `window.history_since`; the card says
"since <date>" instead of "this week" when the window is cut short.
- Resolved findings are purged from the findings store 24 hours after
resolution, and dismissed findings after 30 days. Resolution totals therefore
come from run records, and severity is only reported for findings still
tracked. Investigation outcomes are read from lifecycle events on tracked
findings, so a finding that was investigated, fixed, and purged more than a
day ago no longer contributes. Executed and verified actions do not have this
gap: action audit records are durable.
- Spend is `cost.EstimateUSD` over recorded usage events. When the model has
no known price the digest says pricing is unknown rather than reporting a
smaller number.
## Surface
**In-app card first.** A "This week" card at the top of the Patrol page's
Activity tab, above Verified outcomes
(`frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx`, backed by
`GET /api/ai/patrol/digest?days=7`). It costs nothing to deliver, every paying
install can see it, and the Activity tab is already where "what happened"
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.
## API
`GET /api/ai/patrol/digest` — requires `ai:execute` scope like the other
Patrol read endpoints. Query `days` (1 to 30, default 7). Returns the window,
current mode, and the run, finding, investigation, action, alert, and spend
rollups described above. Empty history returns zero counts with
`window.history_complete = true`; a missing AI or Patrol service returns the
same zero shape so the card can render its "Patrol has not run" state.
+76 -46
View File
@@ -10022,52 +10022,6 @@
}
]
},
{
"id": "ai-provider-guided-setup",
"summary": "Paying installs choose a Patrol model and schedule with no guidance on which models work with Patrol or what a schedule costs: 38 of 120 paid installs had no AI provider and 12 more had AI without Patrol in the 2026-09-01 assessment, a Pro customer picked Gemini Flash-Lite to save money and Patrol could not file verdicts, and a mispriced Opus row tripped the 20 USD budget and silently disabled Patrol (issue 1789). The provider settings need recommended, suggested, and caution markers drawn from the qualified set and known failures, a projected monthly Patrol cost from the price table and the install's run history, a cost-model schedule default for per-token providers, and a visible Patrol pause with a budget action when the budget is used up.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-09-02",
"lane_ids": [
"L6",
"L8"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"frontend-primitives",
"patrol-intelligence"
],
"proposed_resolution": "lane-expansion",
"coverage_impact": 3,
"evidence": [
{
"repo": "pulse",
"path": "docs/AI_PATROL_QUALIFICATION.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/aiPatrolCostPresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_cost_projection.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_model_guidance.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_patrol_cost_preview.go",
"kind": "file"
}
]
},
{
"id": "patrol-findings-hygiene-attention-noise",
"summary": "Two users in a row (discussions #1623 and #1699) could not find Patrol's durable outcomes because Remember as expected, Dismiss: Not an issue, Dismiss: Later, and Create rule sat two levels below the Needs attention detail, which offered only Acknowledge and Suppress. The same screenshot showed an alert with eleven open/resolved transitions in a day rendered as eleven timeline rows, and Patrol findings that restate an active alert (same resource, same condition) listed as separate items. Telemetry on 2026-09-01 showed 4,709 findings across 736 installs but only 255 investigations across 27, so most findings are seen and ignored. Attention needs: alert-mirroring findings folded under the alert, flapping collapsed to one labelled item with a count, and the durable decisions on the detail with one-line explanations.",
@@ -10101,6 +10055,45 @@
"kind": "file"
}
]
},
{
"id": "patrol-value-visibility",
"summary": "Nothing in Pulse shows a paying customer what Patrol has done for them. Patrol runs roughly 164 times per install per month, raises findings, investigates, proposes and executes fixes, and records model spend, but no surface adds that up: run history is per run, findings are per finding, action audits live under Actions, and spend sits on the AI cost dashboard. The 2026-09-01 assessment found 33 of 175 Pro subscriptions past due and 80 percent of paying installs never seeing the paid loop fire; a customer who cannot see the work stops paying for it. The gap is a read-only rollup over records Pulse already keeps, first as an in-app card on the Patrol page and second as a weekly email through existing notification channels.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-09-02",
"lane_ids": [
"L6"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"patrol-intelligence"
],
"proposed_resolution": "lane-expansion",
"coverage_impact": 4,
"evidence": [
{
"repo": "pulse",
"path": "docs/PATROL_WEEKLY_DIGEST.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/cost/store.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol.go",
"kind": "file"
}
]
}
],
"candidate_lanes": [
@@ -10194,6 +10187,30 @@
"telemetry: 2026-09-01 clean basis, 38 of 120 paid installs without an AI provider and 12 more without Patrol",
"named-bet: cloud starting points are price-driven and labelled unqualified until the install's own readiness pass verifies them"
]
},
{
"id": "patrol-weekly-digest",
"name": "Patrol Weekly Digest",
"summary": "Show paying customers what Patrol did for them: a read-only weekly rollup of runs, findings raised and resolved, investigations, Patrol-origin actions, alerts reviewed, estimated model spend, and the current mode, computed only from records Pulse already retains. The first slice is the aggregation endpoint and an in-app This week card on the Patrol page; the weekly email through existing notification channels is the unscheduled second slice.",
"status": "proposed",
"recorded_at": "2026-09-02",
"target_id": "v6-product-lane-expansion",
"current_lane_ids": [
"L6"
],
"coverage_gap_ids": [
"patrol-value-visibility"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"patrol-intelligence"
],
"demand_evidence": [
"named-bet: the 2026-09-01 assessment found nothing in Pulse shows a customer what Patrol did for them while 33 of 175 Pro subscriptions are past due and 80 percent of paying installs never saw the paid loop fire",
"product-demand: pulse-pro/FEATURE_REQUESTS.md#patrol-weekly-digest-what-patrol-did-for-you",
"design-note: docs/PATROL_WEEKLY_DIGEST.md"
]
}
],
"work_claims": [
@@ -10209,6 +10226,19 @@
"kind": "candidate-lane",
"id": "ai-provider-guided-setup"
}
},
{
"id": "claude-patrol-digest-candidate-lane-patrol-weekly-digest",
"agent_id": "claude-patrol-digest",
"summary": "Patrol weekly digest: aggregation endpoint and in-app This week card",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-09-02T05:37:43Z",
"heartbeat_at": "2026-09-02T05:37:43Z",
"expires_at": "2026-09-02T11:37:43Z",
"work_item": {
"kind": "candidate-lane",
"id": "patrol-weekly-digest"
}
}
],
"open_decisions": [],
@@ -7684,3 +7684,11 @@ install's Patrol run history, and the cached readiness pass. They read no
agent identity, enrolment, or fleet state, issue no agent commands, and do
not change the shared agent-install or setup-script boundaries; the
agent-lifecycle contract keeps its authority over every agent surface.
### Patrol digest does not touch agent authority
The Patrol digest route in `internal/api/router_routes_ai_relay.go` and its
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.
@@ -7905,3 +7905,21 @@ success, because a healthy provider says nothing about spend.
`patrol_cost_projection_test.go` and `patrol_model_guidance_test.go` pin the
arithmetic, the history/default switch, the recommendation ladder, and the
guidance matching.
### Patrol digest is a pure rollup of retained runtime records
`internal/ai/patrol_digest.go` builds the weekly "what Patrol did" digest from
run history (`PatrolRunRecord`), the findings store (lifecycle events for
dismissals, suppressions, and investigation outcomes, plus still-open findings
by severity), Patrol-origin action audits, and `internal/ai/cost` usage events
with `use_case = patrol`. It is a pure function over those inputs: it starts no
run, calls no provider, spends no tokens, and mutates no store. New-finding and
resolution totals come from run records because resolved findings are purged
from the store after 24 hours, and the payload reports
`history_complete=false` with `history_since` when the bounded run history no
longer reaches the start of the window. Unknown model pricing is reported as
`pricing_known=false`, never as zero known spend. The effective mode comes from
`Service.GetEffectivePatrolAutonomyLevel`, so an unlicensed install reads as
`monitor`. `frontend-modern/src/api/patrol.ts` mirrors the payload as
`PatrolDigest`. Proofs: `internal/ai/patrol_digest_test.go` and
`frontend-modern/src/api/__tests__/patrol.test.ts`.
@@ -10522,3 +10522,23 @@ and registered in `router_routes_ai_relay.go`, listed in
`route_inventory_test.go`, and documented in `docs/API.md` and its public
mirror. The frontend client lives in `frontend-modern/src/api/aiPatrolCost.ts`
with `aiPatrolCost.test.ts` pinning the query encoding.
### Patrol weekly digest is an additive read endpoint
`GET /api/ai/patrol/digest?days=N` (130, default 7) is a new `ai:execute`
scoped read. It returns `generated_at`, `window` (`start`, `end`, `days`,
`history_complete`, optional `history_since`), `mode`, and the `runs`,
`findings`, `investigations`, `actions`, `alerts`, and `spend` rollups defined
in `internal/ai/patrol_digest.go` and mirrored by `PatrolDigest` in
`frontend-modern/src/api/patrol.ts`. Every count is derived from records Pulse
already retains; the endpoint persists nothing and adds no telemetry fields.
A missing AI or Patrol service returns the same zero-valued shape rather than
an error so clients can render an honest "Patrol has not run" state, and
`investigations.by_outcome` always serialises as an object. An out-of-range
`days` returns `400 invalid_patrol_digest_days`. The endpoint is documented in
`docs/API.md` and its shipped mirror. Proofs:
`internal/api/ai_patrol_digest_handler_test.go`,
`internal/api/route_inventory_test.go`,
`internal/api/security_regression_test.go`,
`frontend-modern/src/api/__tests__/patrol.test.ts`, and
`frontend-modern/src/utils/__tests__/docsLinks.test.ts`.
@@ -5902,3 +5902,12 @@ interpreted as authoritative emptiness, deletion, backup completion, recovery
state, or permission to clean up storage. Propagating the marker through the
shared resource projection creates no snapshot, restore, retention, or
container-action authority.
### Patrol digest reads are not storage or recovery authority
`GET /api/ai/patrol/digest` reads retained Patrol run history, the in-memory
findings store, canonical action audit records, and usage cost events, and
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.
+14
View File
@@ -1138,6 +1138,20 @@ Kubernetes-specific analysis as a standalone plan pillar.
Runs a focused investigation for an alert payload (used by the UI).
### Patrol
- `GET /api/ai/patrol/digest`
- Returns the "what Patrol did for you" rollup for the last `days` days
(query `days`, 130, default 7): the window and whether retained run
history covers it, the effective Patrol mode, runs (total, scheduled,
event-triggered, manual, failed, checks, resources covered, last run),
findings (new, still open by severity, resolved, auto-resolved,
dismissed, suppressed), investigations by outcome, Patrol-origin actions
(proposed, approved, rejected, executed, verified, failed, pending),
alerts Patrol reviewed, and estimated model spend with a pricing-known
flag.
- Computed from records Pulse already retains (run history, the findings
store, canonical action audits, and usage cost events); nothing new is
persisted. Requires the `ai:execute` scope. Backs the Patrol page's
"This week" card; see `docs/PATROL_WEEKLY_DIGEST.md`.
- `GET /api/ai/patrol/attention`
- Returns the typed Patrol attention queue projected from canonical
operational lifecycle records. This is the active-count and queue source
@@ -147,6 +147,20 @@ describe('docsLinks', () => {
expect(apiReference).toContain('resumes normal policy without resolving the incident');
});
it('ships the Patrol weekly digest API contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
path.join(frontendRoot, 'public', 'docs', 'API.md'),
'utf8',
);
expect(shippedAPIReference).toBe(apiReference);
expect(apiReference).toContain('`GET /api/ai/patrol/digest`');
expect(apiReference).toContain('"what Patrol did for you" rollup');
expect(apiReference).toMatch(/nothing new is\s+persisted/);
expect(apiReference).toContain('docs/PATROL_WEEKLY_DIGEST.md');
});
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');
+450
View File
@@ -0,0 +1,450 @@
package ai
import (
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// The Patrol digest answers one customer question: "what did Patrol do for me
// this week?" It is a pure rollup over records Pulse already keeps (run
// history, the findings store, canonical action audits, and the usage cost
// store). It adds no telemetry and no new persistence; see
// docs/PATROL_WEEKLY_DIGEST.md for the source of each line and its limits.
const (
// PatrolDigestDefaultDays is the digest window when the caller does not
// ask for one.
PatrolDigestDefaultDays = 7
// PatrolDigestMaxDays bounds the window so the rollup stays a weekly
// account rather than a history export.
PatrolDigestMaxDays = 30
// patrolDigestActionOriginSurface mirrors the broker-owned origin surface
// stamped on actions Patrol proposes (internal/api patrolActionOriginSurface).
patrolDigestActionOriginSurface = "patrol"
patrolDigestUsageUseCase = "patrol"
)
// PatrolDigestWindow describes the period the digest covers and whether the
// retained run history actually reaches back that far.
type PatrolDigestWindow struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Days int `json:"days"`
// HistoryComplete is false when the bounded run history store had
// already dropped runs from inside the window, in which case HistorySince
// is the oldest retained run and the digest only speaks from that point.
HistoryComplete bool `json:"history_complete"`
HistorySince *time.Time `json:"history_since,omitempty"`
}
// PatrolDigestRuns summarises how often Patrol looked.
type PatrolDigestRuns struct {
Total int `json:"total"`
Scheduled int `json:"scheduled"`
EventTriggered int `json:"event_triggered"`
Manual int `json:"manual"`
Failed int `json:"failed"`
// Checks is the total number of resource checks across all runs;
// ResourcesCovered is the largest single-run resource count, which is the
// best available proxy for the size of the estate Patrol watches.
Checks int `json:"checks"`
ResourcesCovered int `json:"resources_covered"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
}
// PatrolDigestSeverityCounts splits findings by severity.
type PatrolDigestSeverityCounts struct {
Critical int `json:"critical"`
Warning int `json:"warning"`
Watch int `json:"watch"`
Info int `json:"info"`
}
// PatrolDigestFindings summarises what Patrol raised and what happened to it.
type PatrolDigestFindings struct {
// New counts findings first raised in the window (from run records, so it
// survives finding cleanup). OpenBySeverity covers only findings raised in
// the window that are still open now.
New int `json:"new"`
OpenBySeverity PatrolDigestSeverityCounts `json:"open_by_severity"`
// Resolved is every resolution in the window; AutoResolved is the subset
// Patrol cleared itself because the condition was no longer detected.
Resolved int `json:"resolved"`
AutoResolved int `json:"auto_resolved"`
Dismissed int `json:"dismissed"`
Suppressed int `json:"suppressed"`
}
// PatrolDigestInvestigations summarises completed investigations by outcome.
type PatrolDigestInvestigations struct {
Total int `json:"total"`
ByOutcome map[string]int `json:"by_outcome"`
}
// PatrolDigestActions summarises the canonical action lifecycle for actions
// Patrol proposed. Pending is the current queue, not a window count, because
// it is the one number the reader can still act on.
type PatrolDigestActions struct {
Proposed int `json:"proposed"`
Approved int `json:"approved"`
Rejected int `json:"rejected"`
Executed int `json:"executed"`
Verified int `json:"verified"`
Failed int `json:"failed"`
Pending int `json:"pending"`
}
// PatrolDigestAlerts counts the reader's own alerts that Patrol responded to.
type PatrolDigestAlerts struct {
Reviewed int `json:"reviewed"`
}
// PatrolDigestSpend is the estimated model spend attributed to Patrol.
type PatrolDigestSpend struct {
EstimatedUSD float64 `json:"estimated_usd"`
// PricingKnown is false when at least one call used a model with no known
// price; EstimatedUSD then covers only the priced calls.
PricingKnown bool `json:"pricing_known"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
Calls int `json:"calls"`
}
// PatrolDigest is the wire payload for GET /api/ai/patrol/digest.
type PatrolDigest struct {
GeneratedAt time.Time `json:"generated_at"`
Window PatrolDigestWindow `json:"window"`
Mode string `json:"mode"`
Runs PatrolDigestRuns `json:"runs"`
Findings PatrolDigestFindings `json:"findings"`
Investigations PatrolDigestInvestigations `json:"investigations"`
Actions PatrolDigestActions `json:"actions"`
Alerts PatrolDigestAlerts `json:"alerts"`
Spend PatrolDigestSpend `json:"spend"`
}
// PatrolDigestInput carries the raw records the digest is built from. Every
// slice may be nil; the digest then reports zero for that line.
type PatrolDigestInput struct {
Now time.Time
Days int
Mode string
// Runs is the full retained run history, newest first or in any order.
Runs []PatrolRunRecord
// RunHistoryCapacity is the store's retention cap; when Runs is at
// capacity and the oldest run falls inside the window the digest marks the
// window as incomplete. Zero disables the check.
RunHistoryCapacity int
Findings []*Finding
Actions []unifiedresources.ActionAuditRecord
Usage []cost.UsageEvent
}
// NormalizePatrolDigestDays clamps a requested window into the supported range.
func NormalizePatrolDigestDays(days int) int {
if days <= 0 {
return PatrolDigestDefaultDays
}
if days > PatrolDigestMaxDays {
return PatrolDigestMaxDays
}
return days
}
// BuildPatrolDigest computes the digest for the window ending at input.Now.
func BuildPatrolDigest(in PatrolDigestInput) PatrolDigest {
now := in.Now
if now.IsZero() {
now = time.Now()
}
now = now.UTC()
days := NormalizePatrolDigestDays(in.Days)
start := now.Add(-time.Duration(days) * 24 * time.Hour)
inWindow := func(t time.Time) bool {
return !t.IsZero() && !t.Before(start) && !t.After(now)
}
digest := PatrolDigest{
GeneratedAt: now,
Window: PatrolDigestWindow{
Start: start,
End: now,
Days: days,
HistoryComplete: true,
},
Mode: normalizePatrolDigestMode(in.Mode),
Investigations: PatrolDigestInvestigations{
ByOutcome: map[string]int{},
},
}
digest.Runs, digest.Alerts.Reviewed, digest.Findings.New, digest.Findings.AutoResolved = summarizeDigestRuns(in.Runs, inWindow)
digest.Window.HistoryComplete, digest.Window.HistorySince = digestHistoryCoverage(in.Runs, in.RunHistoryCapacity, start)
summarizeDigestFindings(&digest, in.Findings, inWindow)
digest.Actions = summarizeDigestActions(in.Actions, inWindow)
digest.Spend = summarizeDigestSpend(in.Usage, inWindow)
return digest
}
func normalizePatrolDigestMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case config.PatrolAutonomyApproval:
return config.PatrolAutonomyApproval
case config.PatrolAutonomyAssisted:
return config.PatrolAutonomyAssisted
case config.PatrolAutonomyFull:
return config.PatrolAutonomyFull
default:
return config.PatrolAutonomyMonitor
}
}
func digestRunTime(run PatrolRunRecord) time.Time {
if !run.StartedAt.IsZero() {
return run.StartedAt
}
return run.CompletedAt
}
func isAlertTriggeredRun(reason string) bool {
switch TriggerReason(strings.TrimSpace(reason)) {
case TriggerReasonAlertFired, TriggerReasonAlertCleared, TriggerReasonAlertFlapping:
return true
}
return false
}
func summarizeDigestRuns(runs []PatrolRunRecord, inWindow func(time.Time) bool) (PatrolDigestRuns, int, int, int) {
var summary PatrolDigestRuns
alerts := map[string]struct{}{}
alertRunsWithoutID := 0
newFindings := 0
autoResolved := 0
for _, run := range runs {
at := digestRunTime(run)
if !inWindow(at) {
continue
}
summary.Total++
switch reason := TriggerReason(strings.TrimSpace(run.TriggerReason)); {
case reason == TriggerReasonManual:
summary.Manual++
case reason == "" || reason == TriggerReasonScheduled:
summary.Scheduled++
default:
summary.EventTriggered++
}
if strings.EqualFold(strings.TrimSpace(run.Status), "error") {
summary.Failed++
}
summary.Checks += run.ResourcesChecked
if run.ResourcesChecked > summary.ResourcesCovered {
summary.ResourcesCovered = run.ResourcesChecked
}
completed := run.CompletedAt
if completed.IsZero() {
completed = at
}
if summary.LastRunAt == nil || completed.After(*summary.LastRunAt) {
last := completed.UTC()
summary.LastRunAt = &last
}
if isAlertTriggeredRun(run.TriggerReason) {
if id := strings.TrimSpace(run.AlertIdentifier); id != "" {
alerts[id] = struct{}{}
} else {
alertRunsWithoutID++
}
}
newFindings += run.NewFindings
autoResolved += run.ResolvedFindings
}
return summary, len(alerts) + alertRunsWithoutID, newFindings, autoResolved
}
func digestHistoryCoverage(runs []PatrolRunRecord, capacity int, start time.Time) (bool, *time.Time) {
if capacity <= 0 || len(runs) < capacity {
return true, nil
}
var oldest time.Time
for _, run := range runs {
at := digestRunTime(run)
if at.IsZero() {
continue
}
if oldest.IsZero() || at.Before(oldest) {
oldest = at
}
}
if oldest.IsZero() || !oldest.After(start) {
return true, nil
}
since := oldest.UTC()
return false, &since
}
func summarizeDigestFindings(digest *PatrolDigest, findings []*Finding, inWindow func(time.Time) bool) {
manualResolved := 0
for _, f := range findings {
if f == nil {
continue
}
if inWindow(f.DetectedAt) && f.ResolvedAt == nil && strings.TrimSpace(f.DismissedReason) == "" {
switch f.Severity {
case FindingSeverityCritical:
digest.Findings.OpenBySeverity.Critical++
case FindingSeverityWarning:
digest.Findings.OpenBySeverity.Warning++
case FindingSeverityWatch:
digest.Findings.OpenBySeverity.Watch++
default:
digest.Findings.OpenBySeverity.Info++
}
}
if f.ResolvedAt != nil && inWindow(*f.ResolvedAt) && !f.AutoResolved {
manualResolved++
}
investigationEvents := 0
for _, event := range f.Lifecycle {
if !inWindow(event.At) {
continue
}
switch event.Type {
case "dismissed":
digest.Findings.Dismissed++
case "suppressed":
digest.Findings.Suppressed++
case "investigation_outcome":
investigationEvents++
digest.Investigations.Total++
digest.Investigations.ByOutcome[digestOutcomeLabel("", event.Metadata)]++
}
}
// Findings that predate lifecycle logging, or were investigated through a
// path that only stamps the summary fields, still count once.
if investigationEvents == 0 && f.LastInvestigatedAt != nil && inWindow(*f.LastInvestigatedAt) {
digest.Investigations.Total++
digest.Investigations.ByOutcome[digestOutcomeLabel(f.InvestigationOutcome, nil)]++
}
}
digest.Findings.Resolved = digest.Findings.AutoResolved + manualResolved
}
// digestOutcomeLabel reads the investigation outcome from a lifecycle event.
// The "investigation_outcome" event stores the outcome in metadata and keeps
// From/To for the loop state, so metadata wins and To is only a fallback.
func digestOutcomeLabel(fallback string, metadata map[string]string) string {
outcome := ""
if metadata != nil {
outcome = strings.TrimSpace(metadata["outcome"])
}
if outcome == "" {
outcome = strings.TrimSpace(fallback)
}
if outcome == "" {
return "unknown"
}
return strings.ToLower(outcome)
}
func summarizeDigestActions(records []unifiedresources.ActionAuditRecord, inWindow func(time.Time) bool) PatrolDigestActions {
var summary PatrolDigestActions
for _, record := range records {
if record.Origin == nil || strings.TrimSpace(record.Origin.Surface) != patrolDigestActionOriginSurface {
continue
}
if record.State == unifiedresources.ActionStatePending {
summary.Pending++
}
if inWindow(record.CreatedAt) {
summary.Proposed++
}
for _, approval := range record.Approvals {
if !inWindow(approval.Timestamp) {
continue
}
switch approval.Outcome {
case unifiedresources.OutcomeApproved:
summary.Approved++
case unifiedresources.OutcomeRejected:
summary.Rejected++
}
}
if !inWindow(record.UpdatedAt) {
continue
}
truth := unifiedresources.CanonicalActionResultV2(record)
switch truth.Execution.Status {
case unifiedresources.ActionExecutionSucceeded:
summary.Executed++
if truth.Verification.Status == unifiedresources.ActionVerificationConfirmed {
summary.Verified++
}
case unifiedresources.ActionExecutionFailed:
summary.Executed++
summary.Failed++
}
}
return summary
}
func summarizeDigestSpend(events []cost.UsageEvent, inWindow func(time.Time) bool) PatrolDigestSpend {
summary := PatrolDigestSpend{PricingKnown: true}
for _, event := range events {
if !inWindow(event.Timestamp) || !strings.EqualFold(strings.TrimSpace(event.UseCase), patrolDigestUsageUseCase) {
continue
}
summary.Calls++
summary.InputTokens += int64(event.InputTokens)
summary.OutputTokens += int64(event.OutputTokens)
provider, model := cost.ResolveProviderAndModel(event.Provider, event.RequestModel, event.ResponseModel)
usd, known, _ := cost.EstimateUSD(provider, model, int64(event.InputTokens), int64(event.OutputTokens))
if !known {
summary.PricingKnown = false
continue
}
summary.EstimatedUSD += usd
}
return summary
}
// PatrolDigestOutcomeOrder returns the by-outcome keys in a stable, most
// actionable-first order so presentation layers do not have to know the
// vocabulary.
func PatrolDigestOutcomeOrder(byOutcome map[string]int) []string {
priority := map[string]int{
"needs_attention": 0,
"fix_failed": 1,
"fix_verification_failed": 2,
"cannot_fix": 3,
"timed_out": 4,
"fix_queued": 5,
"fix_executed": 6,
"fix_verification_unknown": 7,
"fix_verified": 8,
"resolved": 9,
"fix_rejected": 10,
}
keys := make([]string, 0, len(byOutcome))
for key := range byOutcome {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
pi, okI := priority[keys[i]]
pj, okJ := priority[keys[j]]
if okI != okJ {
return okI
}
if pi != pj {
return pi < pj
}
return keys[i] < keys[j]
})
return keys
}
+198
View File
@@ -0,0 +1,198 @@
package ai
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func digestTestAction(id string, createdAt time.Time, state unifiedresources.ActionState, verified bool, surface string) unifiedresources.ActionAuditRecord {
record := unifiedresources.ActionAuditRecord{
ID: id,
CreatedAt: createdAt,
UpdatedAt: createdAt.Add(time.Minute),
State: state,
Request: unifiedresources.ActionRequest{
RequestID: id + "-request", ResourceID: "app-container:test", CapabilityName: "restart", RequestedBy: "pulse_patrol",
},
Plan: unifiedresources.ActionPlan{ActionID: id, RequestID: id + "-request", Allowed: true},
Origin: &unifiedresources.ActionOrigin{Surface: surface, FindingID: id + "-finding"},
}
switch state {
case unifiedresources.ActionStateCompleted:
record.Approvals = []unifiedresources.ActionApprovalRecord{{
Actor: "operator", Outcome: unifiedresources.OutcomeApproved, Timestamp: createdAt.Add(30 * time.Second),
}}
record.Result = &unifiedresources.ExecutionResult{
Success: true,
Verification: &unifiedresources.ActionVerificationResult{Ran: true, Success: verified, RanAt: record.UpdatedAt},
}
record.VerificationOutcome = unifiedresources.VerificationOutcome{Status: unifiedresources.VerificationFailed}
if verified {
record.VerificationOutcome = unifiedresources.VerificationOutcome{
Status: unifiedresources.VerificationVerified, EvidenceSummary: "Service healthy after restart.",
}
}
case unifiedresources.ActionStateFailed:
record.Approvals = []unifiedresources.ActionApprovalRecord{{
Actor: "operator", Outcome: unifiedresources.OutcomeApproved, Timestamp: createdAt.Add(30 * time.Second),
}}
record.Result = &unifiedresources.ExecutionResult{Success: false, ErrorMessage: "restart failed"}
case unifiedresources.ActionStateRejected:
record.Approvals = []unifiedresources.ActionApprovalRecord{{
Actor: "operator", Outcome: unifiedresources.OutcomeRejected, Timestamp: createdAt.Add(30 * time.Second),
}}
}
return record
}
func TestBuildPatrolDigestRollsUpTheWeek(t *testing.T) {
t.Parallel()
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
day := 24 * time.Hour
resolvedAt := now.Add(-2 * day)
investigatedAt := now.Add(-3 * day)
dismissedAt := now.Add(-4 * day)
runs := []PatrolRunRecord{
{ID: "run-scheduled", StartedAt: now.Add(-1 * day), CompletedAt: now.Add(-1*day + time.Minute), TriggerReason: string(TriggerReasonScheduled), ResourcesChecked: 40, NewFindings: 3, ResolvedFindings: 2, Status: "issues_found"},
{ID: "run-alert", StartedAt: now.Add(-2 * day), CompletedAt: now.Add(-2*day + time.Minute), TriggerReason: string(TriggerReasonAlertFired), AlertIdentifier: "alert-a", ResourcesChecked: 1, NewFindings: 1, Status: "issues_found"},
{ID: "run-alert-again", StartedAt: now.Add(-3 * day), CompletedAt: now.Add(-3*day + time.Minute), TriggerReason: string(TriggerReasonAlertFlapping), AlertIdentifier: "alert-a", ResourcesChecked: 1, Status: "healthy"},
{ID: "run-manual-error", StartedAt: now.Add(-5 * day), CompletedAt: now.Add(-5*day + time.Minute), TriggerReason: string(TriggerReasonManual), ResourcesChecked: 38, Status: "error", ErrorCount: 1},
{ID: "run-old", StartedAt: now.Add(-10 * day), CompletedAt: now.Add(-10*day + time.Minute), TriggerReason: string(TriggerReasonScheduled), ResourcesChecked: 99, NewFindings: 9, ResolvedFindings: 9, Status: "issues_found"},
}
findings := []*Finding{
{ID: "open-critical", Severity: FindingSeverityCritical, DetectedAt: now.Add(-1 * day)},
{ID: "open-warning", Severity: FindingSeverityWarning, DetectedAt: now.Add(-2 * day), Lifecycle: []FindingLifecycleEvent{
{At: investigatedAt, Type: "investigation_outcome", From: "investigating", To: "investigating", Metadata: map[string]string{"outcome": "needs_attention"}},
}},
{ID: "old-open", Severity: FindingSeverityCritical, DetectedAt: now.Add(-20 * day)},
{ID: "manually-resolved", Severity: FindingSeverityWarning, DetectedAt: now.Add(-6 * day), ResolvedAt: &resolvedAt, AutoResolved: false},
{ID: "auto-resolved", Severity: FindingSeverityWarning, DetectedAt: now.Add(-6 * day), ResolvedAt: &resolvedAt, AutoResolved: true},
{ID: "dismissed", Severity: FindingSeverityWatch, DetectedAt: now.Add(-5 * day), DismissedReason: "expected_behavior", Lifecycle: []FindingLifecycleEvent{
{At: dismissedAt, Type: "dismissed", Metadata: map[string]string{"reason": "expected_behavior"}},
{At: dismissedAt, Type: "suppressed"},
}},
{ID: "legacy-investigated", Severity: FindingSeverityWarning, DetectedAt: now.Add(-6 * day), LastInvestigatedAt: &investigatedAt, InvestigationOutcome: "fix_verified"},
nil,
}
actions := []unifiedresources.ActionAuditRecord{
digestTestAction("verified", now.Add(-1*day), unifiedresources.ActionStateCompleted, true, "patrol"),
digestTestAction("unverified", now.Add(-2*day), unifiedresources.ActionStateCompleted, false, "patrol"),
digestTestAction("failed", now.Add(-2*day), unifiedresources.ActionStateFailed, false, "patrol"),
digestTestAction("rejected", now.Add(-3*day), unifiedresources.ActionStateRejected, false, "patrol"),
digestTestAction("pending-old", now.Add(-12*day), unifiedresources.ActionStatePending, false, "patrol"),
digestTestAction("pending-new", now.Add(-time.Hour), unifiedresources.ActionStatePending, false, "patrol"),
digestTestAction("assistant", now.Add(-1*day), unifiedresources.ActionStateCompleted, true, "assistant"),
digestTestAction("old-verified", now.Add(-15*day), unifiedresources.ActionStateCompleted, true, "patrol"),
}
usage := []cost.UsageEvent{
{Timestamp: now.Add(-1 * day), Provider: "openai", RequestModel: "gpt-4o-mini", UseCase: "patrol", InputTokens: 1_000_000, OutputTokens: 100_000},
{Timestamp: now.Add(-2 * day), Provider: "openai", RequestModel: "gpt-4o-mini", UseCase: "PATROL", InputTokens: 1_000_000, OutputTokens: 0},
{Timestamp: now.Add(-2 * day), Provider: "openai", RequestModel: "gpt-4o-mini", UseCase: "chat", InputTokens: 5_000_000},
{Timestamp: now.Add(-9 * day), Provider: "openai", RequestModel: "gpt-4o-mini", UseCase: "patrol", InputTokens: 5_000_000},
}
digest := BuildPatrolDigest(PatrolDigestInput{
Now: now, Days: 7, Mode: "Approval",
Runs: runs, RunHistoryCapacity: MaxPatrolRunHistory,
Findings: findings, Actions: actions, Usage: usage,
})
if digest.Mode != config.PatrolAutonomyApproval {
t.Fatalf("mode = %q", digest.Mode)
}
if !digest.Window.HistoryComplete || digest.Window.HistorySince != nil || digest.Window.Days != 7 {
t.Fatalf("window = %+v", digest.Window)
}
if got := digest.Runs; got.Total != 4 || got.Scheduled != 1 || got.EventTriggered != 2 || got.Manual != 1 || got.Failed != 1 || got.Checks != 80 || got.ResourcesCovered != 40 {
t.Fatalf("runs = %+v", got)
}
if digest.Runs.LastRunAt == nil || !digest.Runs.LastRunAt.Equal(now.Add(-1*day+time.Minute)) {
t.Fatalf("last run = %v", digest.Runs.LastRunAt)
}
if digest.Alerts.Reviewed != 1 {
t.Fatalf("alerts reviewed = %d, want the one distinct alert", digest.Alerts.Reviewed)
}
if got := digest.Findings; got.New != 4 || got.AutoResolved != 2 || got.Resolved != 3 || got.Dismissed != 1 || got.Suppressed != 1 {
t.Fatalf("findings = %+v", got)
}
// open-critical and open-warning were raised in the window and are still open;
// legacy-investigated is open too. old-open predates the window and the
// dismissed finding is not open.
if got := digest.Findings.OpenBySeverity; got.Critical != 1 || got.Warning != 2 || got.Watch != 0 || got.Info != 0 {
t.Fatalf("open by severity = %+v", got)
}
if digest.Investigations.Total != 2 || digest.Investigations.ByOutcome["needs_attention"] != 1 || digest.Investigations.ByOutcome["fix_verified"] != 1 {
t.Fatalf("investigations = %+v", digest.Investigations)
}
if got := digest.Actions; got.Proposed != 5 || got.Approved != 3 || got.Rejected != 1 || got.Executed != 3 || got.Verified != 1 || got.Failed != 1 || got.Pending != 2 {
t.Fatalf("actions = %+v", got)
}
if got := digest.Spend; got.Calls != 2 || got.InputTokens != 2_000_000 || got.OutputTokens != 100_000 || !got.PricingKnown {
t.Fatalf("spend = %+v", got)
}
// gpt-4o-mini: 0.15 USD per million input, 0.60 per million output.
if want := 0.15*2 + 0.60*0.1; digest.Spend.EstimatedUSD < want-0.0001 || digest.Spend.EstimatedUSD > want+0.0001 {
t.Fatalf("estimated usd = %f, want %f", digest.Spend.EstimatedUSD, want)
}
}
func TestBuildPatrolDigestFlagsTruncatedHistoryAndUnknownPricing(t *testing.T) {
t.Parallel()
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
runs := make([]PatrolRunRecord, 0, 5)
for i := 0; i < 5; i++ {
runs = append(runs, PatrolRunRecord{
ID: "run-" + string(rune('a'+i)),
StartedAt: now.Add(-time.Duration(i+1) * time.Hour),
Status: "healthy",
})
}
digest := BuildPatrolDigest(PatrolDigestInput{
Now: now, Days: 7, Runs: runs, RunHistoryCapacity: 5,
Usage: []cost.UsageEvent{{Timestamp: now.Add(-time.Hour), Provider: "custom", RequestModel: "mystery-model", UseCase: "patrol", InputTokens: 10}},
})
if digest.Window.HistoryComplete || digest.Window.HistorySince == nil || !digest.Window.HistorySince.Equal(now.Add(-5*time.Hour)) {
t.Fatalf("window = %+v, want truncated history since the oldest retained run", digest.Window)
}
if digest.Mode != config.PatrolAutonomyMonitor {
t.Fatalf("mode = %q, want monitor default", digest.Mode)
}
if digest.Spend.Calls != 1 || digest.Spend.PricingKnown || digest.Spend.EstimatedUSD != 0 {
t.Fatalf("spend = %+v, want unknown pricing reported, not zeroed as known", digest.Spend)
}
// Below capacity the store still holds everything, so the window is complete.
digest = BuildPatrolDigest(PatrolDigestInput{Now: now, Days: 7, Runs: runs[:3], RunHistoryCapacity: 5})
if !digest.Window.HistoryComplete {
t.Fatalf("window = %+v, want complete history below capacity", digest.Window)
}
}
func TestBuildPatrolDigestEmptyInputIsZeroNotNil(t *testing.T) {
t.Parallel()
digest := BuildPatrolDigest(PatrolDigestInput{Days: 99})
if digest.Window.Days != PatrolDigestMaxDays {
t.Fatalf("days = %d, want clamp to %d", digest.Window.Days, PatrolDigestMaxDays)
}
if digest.Investigations.ByOutcome == nil {
t.Fatal("by_outcome must serialise as an object, not null")
}
if !digest.Spend.PricingKnown {
t.Fatal("no calls means nothing has unknown pricing")
}
if NormalizePatrolDigestDays(0) != PatrolDigestDefaultDays || NormalizePatrolDigestDays(3) != 3 {
t.Fatal("NormalizePatrolDigestDays defaults and passthrough")
}
order := PatrolDigestOutcomeOrder(map[string]int{"resolved": 1, "needs_attention": 2, "zzz_custom": 1})
if len(order) != 3 || order[0] != "needs_attention" || order[1] != "resolved" || order[2] != "zzz_custom" {
t.Fatalf("outcome order = %v", order)
}
}
+58
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
@@ -634,3 +635,60 @@ func TestAISettingsHandler_GetAIService_NonDefaultWithTenantMonitorWithoutPersis
t.Fatal("expected fail-closed tenant service to be disabled")
}
}
// TestHandleGetPatrolDigest_PayloadContract pins the wire shape the Patrol
// "This week" card and docs/API.md describe: snake_case rollup groups, an
// explicit window with coverage flags, and by_outcome serialised as an object.
func TestHandleGetPatrolDigest_PayloadContract(t *testing.T) {
t.Parallel()
handler := createTestAIHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/digest?days=14", nil)
rec := httptest.NewRecorder()
handler.HandleGetPatrolDigest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
for _, key := range []string{"generated_at", "window", "mode", "runs", "findings", "investigations", "actions", "alerts", "spend"} {
if _, ok := payload[key]; !ok {
t.Fatalf("digest payload missing %q: %s", key, rec.Body.String())
}
}
var window struct {
Days int `json:"days"`
HistoryComplete bool `json:"history_complete"`
}
if err := json.Unmarshal(payload["window"], &window); err != nil {
t.Fatal(err)
}
if window.Days != 14 || !window.HistoryComplete {
t.Fatalf("window = %+v", window)
}
var investigations map[string]json.RawMessage
if err := json.Unmarshal(payload["investigations"], &investigations); err != nil {
t.Fatal(err)
}
if string(investigations["by_outcome"]) != "{}" {
t.Fatalf("by_outcome must serialise as an empty object, got %s", investigations["by_outcome"])
}
var findings map[string]json.RawMessage
if err := json.Unmarshal(payload["findings"], &findings); err != nil {
t.Fatal(err)
}
for _, key := range []string{"new", "open_by_severity", "resolved", "auto_resolved", "dismissed", "suppressed"} {
if _, ok := findings[key]; !ok {
t.Fatalf("findings rollup missing %q: %s", key, payload["findings"])
}
}
if strings.TrimSpace(string(payload["mode"])) != `"monitor"` {
t.Fatalf("mode = %s, want monitor when no autonomy is configured", payload["mode"])
}
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// patrolDigestActionScanLimit bounds the action-audit read behind the digest.
// Patrol-origin actions are a small fraction of the audit table and a week
// of them fits comfortably; the store caps larger requests at 100 anyway.
const patrolDigestActionScanLimit = 500
var patrolDigestActionStates = []unifiedresources.ActionState{
unifiedresources.ActionStatePlanned,
unifiedresources.ActionStatePending,
unifiedresources.ActionStateApproved,
unifiedresources.ActionStateRejected,
unifiedresources.ActionStateExpired,
unifiedresources.ActionStateExecuting,
unifiedresources.ActionStateCompleted,
unifiedresources.ActionStateFailed,
}
// HandleGetPatrolDigest returns the "what Patrol did for you" rollup for the
// requested window (GET /api/ai/patrol/digest?days=7). It reads only records
// Pulse already retains; see docs/PATROL_WEEKLY_DIGEST.md.
func (h *AISettingsHandler) HandleGetPatrolDigest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
days := ai.PatrolDigestDefaultDays
if raw := strings.TrimSpace(r.URL.Query().Get("days")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > ai.PatrolDigestMaxDays {
writeErrorResponse(
w,
http.StatusBadRequest,
"invalid_patrol_digest_days",
"Digest window must be between 1 and 30 days.",
map[string]string{"days": strconv.Itoa(ai.PatrolDigestMaxDays)},
)
return
}
days = parsed
}
input := ai.PatrolDigestInput{
Now: time.Now().UTC(),
Days: days,
Mode: config.PatrolAutonomyMonitor,
RunHistoryCapacity: ai.MaxPatrolRunHistory,
}
if aiService := h.GetAIService(r.Context()); aiService != nil {
input.Mode = aiService.GetEffectivePatrolAutonomyLevel()
if patrol := aiService.GetPatrolService(); patrol != nil {
input.Runs = patrol.GetRunHistory(ai.MaxPatrolRunHistory)
if store := patrol.GetFindings(); store != nil {
input.Findings = store.GetAll(nil)
}
}
if costStore := aiService.CostStore(); costStore != nil {
// One extra day so a window that started mid-day is fully covered;
// BuildPatrolDigest trims to the exact window.
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")
}
}
// 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 {
if h == nil {
return nil
}
h.stateMu.RLock()
provider := h.resourceStoreProvider
h.stateMu.RUnlock()
if provider == nil {
return nil
}
store, err := provider(GetOrgID(r.Context()))
if err != nil || store == nil {
if err != nil {
log.Debug().Err(err).Msg("Failed to resolve resource store for Patrol digest actions")
}
return nil
}
records, err := store.GetActionAuditsByStates(patrolDigestActionStates, patrolDigestActionScanLimit)
if err != nil {
log.Debug().Err(err).Msg("Failed to read action audits for Patrol digest")
return nil
}
return records
}
@@ -0,0 +1,141 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"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/unifiedresources"
)
func TestHandleGetPatrolDigest_MethodNotAllowed(t *testing.T) {
t.Parallel()
handler := createTestAIHandler(t)
req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/digest", nil)
rec := httptest.NewRecorder()
handler.HandleGetPatrolDigest(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleGetPatrolDigest_RejectsInvalidWindow(t *testing.T) {
t.Parallel()
handler := createTestAIHandler(t)
for _, query := range []string{"?days=0", "?days=31", "?days=week"} {
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/digest"+query, nil)
rec := httptest.NewRecorder()
handler.HandleGetPatrolDigest(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s: expected %d, got %d: %s", query, http.StatusBadRequest, rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("%s: %v", query, err)
}
if payload["code"] != "invalid_patrol_digest_days" {
t.Fatalf("%s: error payload = %v", query, payload)
}
}
}
func TestHandleGetPatrolDigest_NoPatrolServiceReturnsZeroShape(t *testing.T) {
t.Parallel()
handler := createTestAIHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/digest", nil)
rec := httptest.NewRecorder()
handler.HandleGetPatrolDigest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var digest ai.PatrolDigest
if err := json.Unmarshal(rec.Body.Bytes(), &digest); err != nil {
t.Fatal(err)
}
if digest.Window.Days != ai.PatrolDigestDefaultDays || !digest.Window.HistoryComplete {
t.Fatalf("window = %+v", digest.Window)
}
if digest.Mode != config.PatrolAutonomyMonitor {
t.Fatalf("mode = %q, want monitor when no paid autonomy is configured", digest.Mode)
}
if digest.Runs.Total != 0 || digest.Findings.New != 0 || digest.Actions.Pending != 0 {
t.Fatalf("expected zero digest, got %s", rec.Body.String())
}
var wire map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &wire); err != nil {
t.Fatal(err)
}
investigations, ok := wire["investigations"].(map[string]any)
if !ok || investigations["by_outcome"] == nil {
t.Fatalf("by_outcome must serialise as an object: %v", wire["investigations"])
}
}
func TestHandleGetPatrolDigest_SummarisesRunsAndPatrolActions(t *testing.T) {
t.Parallel()
handler := createTestAIHandler(t)
now := time.Now().UTC()
patrol := &ai.PatrolService{}
runs := ai.NewPatrolRunHistoryStore(10)
runs.Add(ai.PatrolRunRecord{
ID: "run-1", StartedAt: now.Add(-2 * time.Hour), CompletedAt: now.Add(-2*time.Hour + time.Minute),
Type: "patrol", TriggerReason: "scheduled", ResourcesChecked: 12, NewFindings: 2, ResolvedFindings: 1,
FindingIDs: []string{}, Status: "issues_found",
})
runs.Add(ai.PatrolRunRecord{
ID: "run-2", StartedAt: now.Add(-9 * 24 * time.Hour), CompletedAt: now.Add(-9*24*time.Hour + time.Minute),
Type: "patrol", TriggerReason: "scheduled", ResourcesChecked: 12, NewFindings: 7, FindingIDs: []string{}, Status: "issues_found",
})
setUnexportedField(t, patrol, "runHistoryStore", runs)
setUnexportedField(t, handler.defaultAIService, "patrolService", patrol)
resources := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
t.Cleanup(func() { _ = resources.CloseStores() })
store, err := resources.getStore("default")
if err != nil {
t.Fatal(err)
}
for _, record := range []unifiedresources.ActionAuditRecord{
attentionReceiptTestRecord("patrol-verified", patrolActionOriginSurface, now.Add(-time.Hour), true),
attentionReceiptTestRecord("patrol-unverified", patrolActionOriginSurface, now.Add(-time.Hour), false),
attentionReceiptTestRecord("assistant", "assistant", now.Add(-time.Hour), true),
} {
if err := store.RecordActionAudit(record); err != nil {
t.Fatalf("RecordActionAudit(%s): %v", record.ID, err)
}
}
handler.SetResourceStoreProvider(resources.getStore)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/digest?days=7", nil)
rec := httptest.NewRecorder()
handler.HandleGetPatrolDigest(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var digest ai.PatrolDigest
if err := json.Unmarshal(rec.Body.Bytes(), &digest); err != nil {
t.Fatal(err)
}
if digest.Runs.Total != 1 || digest.Runs.Checks != 12 || digest.Runs.ResourcesCovered != 12 {
t.Fatalf("runs = %+v, want only the run inside the window", digest.Runs)
}
if digest.Findings.New != 2 || digest.Findings.Resolved != 1 || digest.Findings.AutoResolved != 1 {
t.Fatalf("findings = %+v", digest.Findings)
}
if digest.Actions.Proposed != 2 || digest.Actions.Executed != 2 || digest.Actions.Verified != 1 {
t.Fatalf("actions = %+v, want only Patrol-origin actions", digest.Actions)
}
if digest.Runs.LastRunAt == nil {
t.Fatal("expected last_run_at from the run inside the window")
}
}
+1
View File
@@ -654,6 +654,7 @@ var allRouteAllowlist = []string{
"/api/ai/patrol/resolve",
"/api/ai/patrol/runs",
"/api/ai/patrol/runs/",
"/api/ai/patrol/digest",
"/api/ai/patrol/suppressions",
"/api/ai/patrol/suppressions/",
"/api/ai/patrol/dismissed",
+1
View File
@@ -165,6 +165,7 @@ func (r *Router) registerAIRelayRoutesGroup() {
))))
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRunHistory)))
r.mux.HandleFunc("/api/ai/patrol/runs/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRun)))
r.mux.HandleFunc("/api/ai/patrol/digest", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolDigest)))
// Suppression rules management - require scope to prevent low-privilege tokens from creating suppression rules
r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
+1
View File
@@ -1794,6 +1794,7 @@ func TestAIExecuteReadEndpointsRequireAIExecuteScope(t *testing.T) {
"/api/ai/patrol/history",
"/api/ai/patrol/runs",
"/api/ai/patrol/runs/run-1",
"/api/ai/patrol/digest",
"/api/ai/patrol/dismissed",
"/api/ai/patrol/suppressions",
"/api/ai/approvals",