mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
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:
parent
fb293169f7
commit
5cc2f61be0
16 changed files with 329 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue