feat: acknowledge Compose Doctor preflight findings per stack (#1560)

* feat: acknowledge Compose Doctor preflight findings per stack

Add node-scoped preflight acknowledgements with read-time filtering.

Supports four expiry modes and activeStatus for banner, tab dot, and readiness.

* fix: align preflight acknowledge UI with design system

Use Combobox, modal chrome, mono fields, and non-destructive clear confirm.

* fix: update test mocks to match new preflight field names

The preflight-acknowledgements feature renamed status-\>activeStatus and
highestSeverity-\>activeHighestSeverity in the preflight report shape. The
corresponding test mocks in three files still used the old field names,
causing 6 test failures across backend and frontend.

- backend: update-guard-service mock now passes activeStatus
- frontend PreflightPanel: Report interface and report() helper now include
  activeStatus, activeHighestSeverity, activeCount, acknowledgedCount
- frontend StackAnatomyPanel doctor: mock API response now includes
  activeHighestSeverity and activeStatus
This commit is contained in:
Anso
2026-07-05 04:12:03 -04:00
committed by GitHub
parent 122c1b8073
commit 4077546492
16 changed files with 1002 additions and 54 deletions
+131
View File
@@ -0,0 +1,131 @@
/**
* Read-time Compose Doctor acknowledgement filter.
*
* Acknowledgements never modify stored finding rows. They are applied at read time
* so clearing an ack resurfaces findings without re-running preflight.
*/
import type { PreflightAckExpiryMode, PreflightAcknowledgement } from '../services/DatabaseService';
import type { PreflightFinding } from '../services/preflight/types';
export interface PreflightAcknowledgementDecision {
acknowledged: boolean;
acknowledgementId?: number;
acknowledgementReason?: string;
acknowledgementExpiry?: PreflightAckExpiryMode;
}
export interface PreflightAckFilterContext {
renderedHash: string | null;
/** Parsed service name to image ref from the latest stored run. */
serviceImages: Record<string, string>;
}
function matchesService(ackService: string | null, findingService: string | undefined): boolean {
if (ackService === null) return true;
return ackService === (findingService ?? null);
}
function isActive(
ack: PreflightAcknowledgement,
ctx: PreflightAckFilterContext,
findingService: string | undefined,
now: number,
): boolean {
switch (ack.expiry_mode) {
case 'forever':
return true;
case 'until_compose_change':
return ctx.renderedHash !== null
&& ack.anchor_rendered_hash !== null
&& ctx.renderedHash === ack.anchor_rendered_hash;
case 'days':
return ack.expires_at !== null && ack.expires_at > now;
case 'until_image_change': {
const svc = findingService ?? ack.service ?? null;
if (!svc) return false;
const current = ctx.serviceImages[svc] ?? null;
return current !== null
&& ack.anchor_image_ref !== null
&& current === ack.anchor_image_ref;
}
default:
return false;
}
}
function specificityScore(ack: PreflightAcknowledgement): number {
return ack.service ? 1 : 0;
}
function pickFromBucket(
bucket: PreflightAcknowledgement[],
findingService: string | undefined,
ctx: PreflightAckFilterContext,
now: number,
): PreflightAcknowledgement | null {
let best: PreflightAcknowledgement | null = null;
let bestScore = -1;
for (const ack of bucket) {
if (!matchesService(ack.service, findingService)) continue;
if (!isActive(ack, ctx, findingService, now)) continue;
const score = specificityScore(ack);
if (score > bestScore) {
best = ack;
bestScore = score;
}
}
return best;
}
export function parseServiceImages(json: string | null | undefined): Record<string, string> {
if (!json) return {};
try {
const parsed = JSON.parse(json) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string' && value.length > 0) out[key] = value;
}
return out;
} catch {
return {};
}
}
export function applyPreflightAcknowledgements<T extends PreflightFinding>(
findings: T[],
ctx: PreflightAckFilterContext,
acks: PreflightAcknowledgement[],
now: number = Date.now(),
): Array<T & PreflightAcknowledgementDecision> {
if (findings.length === 0) return [];
const buckets = new Map<string, PreflightAcknowledgement[]>();
for (const ack of acks) {
const existing = buckets.get(ack.rule_id);
if (existing) {
existing.push(ack);
} else {
buckets.set(ack.rule_id, [ack]);
}
}
return findings.map((f) => {
const bucket = buckets.get(f.ruleId);
const match = bucket ? pickFromBucket(bucket, f.service, ctx, now) : null;
if (!match) return { ...f, acknowledged: false };
return {
...f,
acknowledged: true,
acknowledgementId: match.id,
acknowledgementReason: match.reason,
acknowledgementExpiry: match.expiry_mode,
};
});
}
export function isPreflightAckActive(
ack: PreflightAcknowledgement,
ctx: PreflightAckFilterContext,
now: number = Date.now(),
): boolean {
return isActive(ack, ctx, ack.service ?? undefined, now);
}