Surface will_fix_later remind-at on dismiss confirm and dismissed rows

Slice 18 made will_fix_later a real operational commitment server-side, but
the new RemindAt field stayed invisible to operators until the reminder fired
a week later. This wires it through the API surface and renders it where the
operator decides and where they later revisit.

UnifiedFinding (Go and TS) and the Patrol Finding TS shape now carry
RemindAt / remind_at; router.go and AddFromAI mirror it like the other
user-feedback fields. FindingsPanel previews "Pulse will stay quiet for 7
days, then surface again on <date>" on the dismiss confirmation panel before
the operator confirms, badges dismissed-as-will_fix_later rows with
"Reminding <date>" in amber, and adds explanatory copy for the other two
dismissal reasons so all three paths feel deliberate rather than
undifferentiated.
This commit is contained in:
rcourtman 2026-05-09 10:47:21 +01:00
parent fb293169f7
commit 5cc2f61be0
16 changed files with 329 additions and 1 deletions

View file

@ -917,6 +917,14 @@ profile and assignment columns, but embedded table framing must route through
## Current State
Patrol-finding to unified-finding mirroring in `internal/api/router.go`
also keeps the will_fix_later wake-up deadline (`Finding.RemindAt`)
intact across restarts. Both the live wire-up callback and the
persistence-recovery resync must copy `f.RemindAt` onto the unified
finding so the operator's commitment survives a reboot or process
restart instead of silently lapsing into the canonical findings store
without being mirrored on the API surface.
Linux agent privilege hardening is now part of the installer/runtime contract.
The supported full-telemetry systemd agent may still run as `root`, but
`cmd/pulse-agent/main.go` must bind health/metrics to loopback by default,

View file

@ -1545,3 +1545,12 @@ clearing the dismissal and emitting a `reminded` lifecycle event, and the
`dismiss_finding` LLM tool response must communicate the remind-at date so
Patrol's conversational explanations stay aligned with the persisted
behavior.
The unified-finding mirror in `internal/ai/unified/alerts.go` also carries
that same `RemindAt` field so the API surface preserves the will_fix_later
wake-up deadline across the canonical findings store and the read model.
The `AddFromAI` dedup-merge path must mirror `RemindAt` onto the existing
record (including clearing it when a remind-at wake or undismiss has
already cleared the dismissal in the canonical store), and the TS API
clients in `frontend-modern/src/api/patrol.ts` and
`frontend-modern/src/api/ai.ts` must round-trip the `remind_at` field
verbatim so the operator surface can preview and badge the deadline.

View file

@ -1013,7 +1013,22 @@ the canonical monitored-system blocked payload.
same overwrite pattern as `description`, `impact`, and
`recommendation`, and the Finding to UnifiedFinding conversion in
`internal/api/router.go` must copy `f.PreviousResolvedFixSummary`
alongside the other operator-facing strings
alongside the other operator-facing strings.
The same shape also carries an optional `remind_at` timestamp (ISO
8601) on both `UnifiedFindingRecord` and Patrol `Finding` shapes. It
is populated only when `dismissed_reason === 'will_fix_later'` and
represents the wake-up deadline at which the next re-detection clears
the dismissal — the operator-facing half of the canonical
`Finding.RemindAt` contract. The store normalizer promotes it to
camelCase `remindAt` on `UnifiedFinding`, the Finding to
UnifiedFinding conversion in `internal/api/router.go` must copy
`f.RemindAt`, and the AddFromAI update branch must mirror it
(including clearing on remind-at wake or undismiss) so the dedup
surface stays consistent with the canonical findings store. The
Findings panel must visibly preview the deadline at dismiss-confirm
time and badge dismissed-as-will_fix_later rows with the pending
remind-at, otherwise the new behavior is invisible until the
reminder fires
and the Assistant finding-context request contract, so `/api/ai/chat`
payloads carrying `finding_id` may hydrate a structured investigation
summary from the unified finding, but raw proposed-fix commands must stay

View file

@ -1087,3 +1087,15 @@ escalation still wakes any dismissed finding regardless of reason. The
`dismiss_finding` LLM tool response surfaces the remind-at date in plain
language so Patrol's own conversational explanations stay aligned with this
contract.
Patrol's findings panel must also surface that commitment to operators on
the canonical Patrol surface, not only inside the LLM tool response. The
inline dismiss confirmation must preview the will_fix_later remind-at
deadline before the operator confirms (and explain the
`expected_behavior` and `not_an_issue` paths so all three feel
deliberate), and dismissed-as-`will_fix_later` rows must show the pending
`Reminding <date>` badge in an amber tone so the operator can see their
own commitment without expanding the row. The store-level
`UnifiedFinding.remindAt` field is the canonical source for both
surfaces; render code reads it from the store, the store normalizer
promotes it from `remind_at` on both the unified and patrol-direct
fetch paths.

View file

@ -1245,3 +1245,9 @@ the canonical owners for desktop and mobile workload column sizing. Global CSS
must not reintroduce competing `.workload-table [data-workload-col=…]` width
rules or `min-width: max-content` fallbacks that can blow the table out
horizontally on Firefox or other desktop browsers.
The same `internal/api/router.go` payload boundary also keeps the
will_fix_later remind-at deadline scoped to a single optional pointer
(`*time.Time`) per finding on both API write paths, so adding the
operational-commitment field does not regress the unified-findings hot
path with a per-row allocation when the dismissal reason is anything
other than `will_fix_later`.

View file

@ -947,6 +947,13 @@ bypass the API fail-closed execution gate.
## Current State
The patrol findings-recovery sync in `internal/api/router.go` also keeps
the will_fix_later wake-up deadline alongside the rest of the finding's
durable state when re-hydrating findings from disk into the unified
store. Persisted `Finding.RemindAt` values must round-trip through that
recovery path so an operator commitment recorded before a process
restart is not silently dropped when findings reload.
`StorageSummary.tsx`, `StoragePageSummary.tsx`, and `useStoragePageSummary.ts`
now surface `poolsDegraded` and `disksFailing` health indicators alongside
pool/disk counts. `RecoverySummary.tsx` gains an aggregate health-state summary

View file

@ -11,6 +11,7 @@ import {
getPatrolRunHistory,
getPatrolRunHistoryWithToolCalls,
getPatrolRunWithToolCalls,
type Finding as PatrolFinding,
} from '@/api/patrol';
import { apiFetchJSON } from '@/utils/apiClient';
@ -310,4 +311,37 @@ describe('patrol api', () => {
},
});
});
it('round-trips remind_at on dismissed-as-will_fix_later patrol findings', async () => {
// The backend treats will_fix_later as an operator commitment with a
// wake-up deadline (Finding.RemindAt, default 7 days). The TS API client
// must mirror remind_at verbatim so the surface can preview the deadline
// at dismiss-confirm time and badge the dismissed row with the pending
// reminder. Without this round-trip, the deadline is invisible to the
// operator until the reminder fires a week later.
const willFixLater: PatrolFinding = {
id: 'finding-wfl',
severity: 'warning',
category: 'reliability',
resource_id: 'vm-101',
resource_name: 'db-01',
resource_type: 'vm',
title: 'Disk pressure',
description: 'Pulse will surface this again on the deadline',
detected_at: '2026-05-09T10:00:00Z',
last_seen_at: '2026-05-09T10:05:00Z',
auto_resolved: false,
times_raised: 1,
suppressed: false,
investigation_attempts: 0,
dismissed_reason: 'will_fix_later',
remind_at: '2026-05-16T10:00:00Z',
};
apiFetchJSONMock.mockResolvedValueOnce([willFixLater] as any);
const findings = await getPatrolFindings();
expect(findings).toHaveLength(1);
expect(findings[0]?.dismissed_reason).toBe('will_fix_later');
expect(findings[0]?.remind_at).toBe('2026-05-16T10:00:00Z');
});
});

View file

@ -463,6 +463,10 @@ export interface UnifiedFindingRecord {
user_note?: string;
suppressed?: boolean;
times_raised?: number;
// remind_at carries the will_fix_later wake-up deadline. When dismissed_reason
// === 'will_fix_later', the finding stays quiet until this timestamp passes;
// afterwards the next re-detection clears the dismissal.
remind_at?: string;
status?: string;
}

View file

@ -43,6 +43,10 @@ export interface Finding {
user_note?: string;
times_raised: number;
suppressed: boolean;
// remind_at carries the will_fix_later wake-up deadline; the finding stays
// quiet on re-detection until this timestamp passes, then the next
// re-detection clears the dismissal and emits a "reminded" lifecycle event.
remind_at?: string;
// Investigation fields (Patrol Autonomy)
investigation_session_id?: string;
investigation_status?: InvestigationStatus;

View file

@ -569,6 +569,23 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const formatTime = (isoString: string) => formatRelativeTime(isoString, { compact: true });
// Mirror the backend's DefaultWillFixLaterRemindAfter (7 days) so the dismiss
// confirmation panel can preview the remind-at date before the operator
// confirms. Kept as a helper instead of a constant so the date stays current
// each time the panel re-renders.
const formatWillFixLaterRemindDate = (): string => {
const remindAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
try {
return remindAt.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
} catch {
return remindAt.toISOString().slice(0, 10);
}
};
// Get meaningful resolution reason based on finding type
const getResolutionReason = (finding: UnifiedFinding): string => {
const resolvedTime = finding.resolvedAt ? formatTime(finding.resolvedAt) : '';
@ -751,6 +768,11 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
{' · '}({formatIdentifierLabel(finding.dismissedReason)})
</span>
</Show>
<Show when={finding.dismissedReason === 'will_fix_later' && finding.remindAt}>
<span class="ml-2 text-amber-600 dark:text-amber-400" title="Pulse will surface this finding again on this date if it is still tripping.">
{' · '}Reminding {formatTime(finding.remindAt!)}
</span>
</Show>
<Show when={finding.status === 'snoozed' && finding.snoozedUntil}>
<span class="ml-2 text-blue-500 dark:text-blue-400">
{' · '}snoozed until {formatTime(finding.snoozedUntil!)}
@ -1151,6 +1173,25 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
Dismiss as: {formatIdentifierLabel(dismissReason())}
</span>
</div>
<Show when={dismissReason() === 'will_fix_later'}>
<p class="text-[11px] text-amber-700 dark:text-amber-300 mb-1.5">
Pulse will stay quiet on this for 7 days, then surface it again on{' '}
<span class="font-semibold">{formatWillFixLaterRemindDate()}</span>{' '}
if it is still happening.
</p>
</Show>
<Show when={dismissReason() === 'expected_behavior'}>
<p class="text-[11px] text-muted mb-1.5">
Pulse will keep this finding visible as acknowledged but won't re-notify
you for it. Severity escalation will still wake it.
</p>
</Show>
<Show when={dismissReason() === 'not_an_issue'}>
<p class="text-[11px] text-muted mb-1.5">
Pulse will permanently suppress this and similar findings on this resource.
Use "Expected" or "Later" if the detection itself is correct.
</p>
</Show>
<textarea
class="w-full text-xs px-2 py-1.5 rounded border border-border bg-surface text-base-content resize-none focus:outline-none focus:ring-1 focus:ring-red-400"
rows={2}

View file

@ -106,6 +106,36 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('confidence');
});
it('previews the will_fix_later remind-at deadline before the operator confirms dismiss', () => {
// The backend now treats will_fix_later as a real operational commitment
// (Finding.RemindAt, default 7 days), not a silent shut-up. The dismiss
// confirmation panel must communicate that to the operator BEFORE they
// confirm — otherwise the new behavior is invisible until the reminder
// fires a week later. See ai-runtime / patrol-intelligence subsystem
// contracts for the three-way dismissal semantics.
expect(findingsPanelSource).toContain('formatWillFixLaterRemindDate');
expect(findingsPanelSource).toContain("dismissReason() === 'will_fix_later'");
expect(findingsPanelSource).toContain('Pulse will stay quiet on this for 7 days');
// The other two reasons must also have explanatory copy so all three
// dismissal paths feel deliberate, not undifferentiated.
expect(findingsPanelSource).toContain("dismissReason() === 'expected_behavior'");
expect(findingsPanelSource).toContain("dismissReason() === 'not_an_issue'");
expect(findingsPanelSource).toContain('Pulse will keep this finding visible as acknowledged');
expect(findingsPanelSource).toContain('Pulse will permanently suppress');
});
it("badges dismissed-as-will_fix_later rows with their pending remind-at deadline", () => {
// Once a finding is dismissed as will_fix_later, the row must surface the
// pending reminder so the operator knows the commitment exists; otherwise
// the only place the deadline lives is the lifecycle log. The amber tone
// signals "pending operator action" rather than a generic muted note.
expect(findingsPanelSource).toContain(
"finding.dismissedReason === 'will_fix_later' && finding.remindAt",
);
expect(findingsPanelSource).toContain('Reminding {formatTime(finding.remindAt!)}');
expect(findingsPanelSource).toContain('text-amber-600 dark:text-amber-400');
});
it('renders the operator-facing Impact line between Description and Recommendation', () => {
// The expanded finding card must surface Finding.Impact directly so
// detection-time consequence-if-ignored copy reaches the operator on the

View file

@ -142,6 +142,69 @@ describe('aiIntelligenceStore', () => {
});
});
it('promotes remind_at on dismissed-as-will_fix_later patrol findings to camelCase remindAt', async () => {
// The store-level UnifiedFinding is what render code consumes; if the
// normalizer drops remind_at, the dismiss confirmation panel and the
// dismissed-row badge cannot show the operator their pending commitment.
// Pin the wiring so the field cannot silently regress in either source.
vi.mocked(getPatrolFindings).mockResolvedValueOnce([
{
id: 'patrol-finding-wfl',
severity: 'warning',
category: 'reliability',
resource_id: 'instance:node:100',
resource_name: 'vm-100',
resource_type: 'vm',
title: 'Disk pressure',
description: 'Will fix during the Q3 storage upgrade.',
detected_at: '2026-05-09T10:00:00Z',
last_seen_at: '2026-05-09T10:05:00Z',
auto_resolved: false,
times_raised: 2,
suppressed: false,
investigation_attempts: 0,
dismissed_reason: 'will_fix_later',
remind_at: '2026-05-16T10:00:00Z',
},
]);
await aiIntelligenceStore.loadPatrolFindings();
expect(aiIntelligenceStore.patrolFindings).toHaveLength(1);
expect(aiIntelligenceStore.patrolFindings[0]).toMatchObject({
id: 'patrol-finding-wfl',
dismissedReason: 'will_fix_later',
remindAt: '2026-05-16T10:00:00Z',
});
vi.mocked(AIAPI.getUnifiedFindings).mockResolvedValueOnce({
findings: [
{
id: 'unified-finding-wfl',
source: 'ai-patrol',
severity: 'warning',
category: 'reliability',
resource_id: 'instance:node:100',
resource_name: 'vm-100',
resource_type: 'vm',
title: 'Disk pressure',
description: 'Will fix during the Q3 storage upgrade.',
detected_at: '2026-05-09T10:00:00Z',
last_seen_at: '2026-05-09T10:05:00Z',
times_raised: 2,
dismissed_reason: 'will_fix_later',
remind_at: '2026-05-16T10:00:00Z',
},
],
} as never);
await aiIntelligenceStore.loadFindings();
expect(aiIntelligenceStore.findings).toHaveLength(1);
expect(aiIntelligenceStore.findings[0]).toMatchObject({
id: 'unified-finding-wfl',
dismissedReason: 'will_fix_later',
remindAt: '2026-05-16T10:00:00Z',
});
});
it('loads the canonical intelligence summary', async () => {
vi.mocked(AIAPI.getIntelligenceSummary).mockResolvedValueOnce({
timestamp: '2026-03-01T00:00:00Z',

View file

@ -151,6 +151,10 @@ export interface UnifiedFinding {
snoozedUntil?: string;
dismissedReason?: string;
userNote?: string;
// remindAt is the will_fix_later wake-up deadline. UI uses it to (a) preview
// the deadline at dismiss time and (b) badge dismissed rows with "Reminding
// <date>" so the operator sees their pending commitment.
remindAt?: string;
status: 'active' | 'resolved' | 'dismissed' | 'snoozed';
correlatedFindingIds?: string[];
remediationPlanId?: string;
@ -216,6 +220,7 @@ function normalizeUnifiedFindingRecord(item: UnifiedFindingRecord, now: number):
snoozedUntil: item.snoozed_until,
dismissedReason: item.dismissed_reason,
userNote: item.user_note,
remindAt: item.remind_at,
status: normalizeFindingStatus(item, now),
correlatedFindingIds: item.correlated_ids,
remediationPlanId: item.remediation_id,
@ -256,6 +261,7 @@ function normalizePatrolFindingRecord(item: PatrolFinding, now: number): Unified
snoozedUntil: item.snoozed_until,
dismissedReason: item.dismissed_reason,
userNote: item.user_note,
remindAt: item.remind_at,
status: normalizeFindingStatus(item, now),
investigationSessionId: item.investigation_session_id || '',
investigationStatus: validateInvestigationStatus(item.investigation_status),

View file

@ -123,6 +123,11 @@ type UnifiedFinding struct {
UserNote string `json:"user_note,omitempty"`
Suppressed bool `json:"suppressed"`
TimesRaised int `json:"times_raised"`
// RemindAt is the will_fix_later wake-up deadline. When set, Patrol stays
// quiet on re-detection until this time passes; once it passes, the next
// re-detection clears the dismissal and emits a "reminded" lifecycle event.
// See ai-runtime / patrol-intelligence subsystem contracts.
RemindAt *time.Time `json:"remind_at,omitempty"`
}
type unifiedFindingJSON struct {
@ -171,6 +176,7 @@ type unifiedFindingJSON struct {
UserNote string `json:"user_note,omitempty"`
Suppressed bool `json:"suppressed"`
TimesRaised int `json:"times_raised"`
RemindAt *time.Time `json:"remind_at,omitempty"`
}
func (f UnifiedFinding) MarshalJSON() ([]byte, error) {
@ -221,6 +227,7 @@ func (f UnifiedFinding) MarshalJSON() ([]byte, error) {
UserNote: f.UserNote,
Suppressed: f.Suppressed,
TimesRaised: f.TimesRaised,
RemindAt: f.RemindAt,
})
}
@ -276,6 +283,7 @@ func (f *UnifiedFinding) UnmarshalJSON(data []byte) error {
UserNote: payload.UserNote,
Suppressed: payload.Suppressed,
TimesRaised: payload.TimesRaised,
RemindAt: payload.RemindAt,
}
return nil
}
@ -667,6 +675,9 @@ func (s *UnifiedStore) AddFromAI(finding *UnifiedFinding) (*UnifiedFinding, bool
existing.DismissedReason = finding.DismissedReason
existing.UserNote = finding.UserNote
existing.Suppressed = finding.Suppressed
// RemindAt mirrors the will_fix_later commitment from the canonical
// findings store; allow clearing on remind-at wake or undismiss.
existing.RemindAt = finding.RemindAt
// AI enhancement fields (best-effort merge)
if finding.AIContext != "" {

View file

@ -556,3 +556,79 @@ func TestCategoryMapping(t *testing.T) {
})
}
}
func TestUnifiedStore_AddFromAI_MirrorsRemindAt(t *testing.T) {
// RemindAt is the will_fix_later wake-up deadline persisted on the
// canonical findings store. The unified store mirrors finding state for
// the API surface, so AddFromAI must propagate RemindAt on the update
// branch — including clearing it on remind-at wake or undismiss —
// otherwise dismissed-as-will_fix_later rows on the operator surface
// would either show stale reminders or no reminder at all.
store := NewUnifiedStore(DefaultAlertToFindingConfig())
store.AddFromAI(&UnifiedFinding{
ID: "ai-wfl",
Source: SourceAIPatrol,
Severity: SeverityWarning,
Category: CategoryReliability,
ResourceID: "vm-101",
ResourceName: "db-01",
ResourceType: "vm",
Title: "Disk pressure",
Description: "Will fix during the Q3 storage upgrade.",
})
// Re-add with the will_fix_later commitment populated; the dedup-merge
// path must mirror RemindAt onto the existing record.
when := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC)
store.AddFromAI(&UnifiedFinding{
ID: "ai-wfl",
Source: SourceAIPatrol,
Severity: SeverityWarning,
Category: CategoryReliability,
ResourceID: "vm-101",
ResourceName: "db-01",
ResourceType: "vm",
Title: "Disk pressure",
Description: "Will fix during the Q3 storage upgrade.",
DismissedReason: "will_fix_later",
RemindAt: &when,
})
got := store.Get("ai-wfl")
if got == nil {
t.Fatal("expected finding to exist after merge")
}
if got.DismissedReason != "will_fix_later" {
t.Errorf("expected DismissedReason mirrored, got %q", got.DismissedReason)
}
if got.RemindAt == nil {
t.Fatal("RemindAt must be mirrored onto the existing record")
}
if !got.RemindAt.Equal(when) {
t.Errorf("RemindAt mismatch: got %v want %v", got.RemindAt, when)
}
// Re-add without RemindAt (simulates remind-at wake clearing the
// dismissal in the canonical store) — the mirror must clear too,
// not preserve the stale deadline.
store.AddFromAI(&UnifiedFinding{
ID: "ai-wfl",
Source: SourceAIPatrol,
Severity: SeverityWarning,
Category: CategoryReliability,
ResourceID: "vm-101",
ResourceName: "db-01",
ResourceType: "vm",
Title: "Disk pressure",
Description: "Reawakened — operator commitment lapsed",
})
if got = store.Get("ai-wfl"); got == nil {
t.Fatal("expected finding to still exist after wake")
}
if got.RemindAt != nil {
t.Errorf("RemindAt must be cleared on wake, got %v", got.RemindAt)
}
if got.DismissedReason != "" {
t.Errorf("DismissedReason must be cleared on wake, got %q", got.DismissedReason)
}
}

View file

@ -1741,6 +1741,7 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool {
UserNote: f.UserNote,
Suppressed: f.Suppressed,
TimesRaised: f.TimesRaised,
RemindAt: f.RemindAt,
}
_, isNew := unifiedStore.AddFromAI(uf)
return isNew
@ -1805,6 +1806,7 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool {
UserNote: f.UserNote,
Suppressed: f.Suppressed,
TimesRaised: f.TimesRaised,
RemindAt: f.RemindAt,
}
// Copy resolution timestamp if resolved
if f.ResolvedAt != nil || f.AutoResolved {