diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs index 42fba8cc..4ae9bd24 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -745,23 +745,25 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { if (script == null || string.IsNullOrEmpty(script.PolicyId)) return; - // Outcome classification — phase-aware. The Microsoft IME Health-Script (Proactive - // Remediation) lifecycle treats the detection scripts as compliance reporters: - // - detection exit 0 = compliant - // - detection exit non-zero = non-compliant (script ran perfectly, just reporting state) - // - remediation exit 0 = remediation succeeded - // - remediation exit non-zero = remediation script failed (real script crash) - // - post-detection same as detection (exit non-zero = remediation didn't take, NOT a crash) - // So a non-compliant detection that gets remediated must NOT count as a script failure - // in metrics or render as red in the timeline. Only the remediation phase + platform - // scripts route non-zero exit to script_failed. The cycle-level outcome is conveyed - // separately via RemediationStatus (2=Remediated, 3=RemediationFailed) on each event. + // Outcome classification — three rules in priority order: + // 1. stderr present → script_failed. Per user UX preference (debrief 2026-05-11): + // consistent failure semantics across script types — any time a script writes + // to stderr the user wants visibility, even if exit was 0 and IME reported + // compliant. Eliminates the confusing case where a platform script with stderr + // shows red but a remediation detection with stderr shows green. + // 2. Phase-aware exit handling: detection / post-detection use exit code as a + // compliance verdict (not a crash signal), so non-zero exit alone is NOT + // failure for those phases. Only remediation phase + platform scripts route + // non-zero exit to script_failed. + // 3. Defensive: explicit Result=="Failed" → script_failed (V1 parity). var isHealthScript = IsRemediation(script.ScriptType); var isHealthComplianceReport = isHealthScript && (string.Equals(script.ScriptPart, "detection", StringComparison.OrdinalIgnoreCase) || string.Equals(script.ScriptPart, "post-detection", StringComparison.OrdinalIgnoreCase)); - var isFailure = string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase) + var hasStderr = !string.IsNullOrWhiteSpace(script.Stderr); + var isFailure = hasStderr + || string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase) || (!isHealthComplianceReport && script.ExitCode.HasValue && script.ExitCode.Value != 0); var severity = isFailure ? EventSeverity.Error : EventSeverity.Info; diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 9084fe1a..a3888f7a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -684 +687 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index d7725305..6fa4b873 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1300 +1301 diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx index 20d293f5..a8454450 100644 --- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx +++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx @@ -4,10 +4,12 @@ import { useEffect, useMemo, useState } from "react"; import { compareVersions } from "@/utils/bootstrapVersion"; import { buildScriptItemLabel, + getPhaseBadge, isDetectOnlyRow, isNonCompliantReport, mapRemediationStatus, reduceScriptEvents, + scriptItemKey, STALE_RUNNING_THRESHOLD_SECONDS, type ScriptInputEvent, type ScriptItem, @@ -49,7 +51,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots

Script Executions

{runningCount > 0 && ( - + {runningCount} running )} @@ -89,7 +91,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
{scripts.map((item) => (
{item.state === "Running" ? ( - + @@ -184,13 +189,18 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite ) : ( {shortId}… )} - - {item.scriptType} - + {/* Phase badge for remediation rows (detection / remediation / post-detection). + For platform scripts we keep showing the type tag. */} + {item.scriptType === "remediation" && getPhaseBadge(item) && ( + + {getPhaseBadge(item)} + + )} + {item.scriptType !== "remediation" && ( + + {item.scriptType} + + )} {isDetectOnly && ( diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts index a7b0f11d..7cd0c479 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from "vitest"; import { buildScriptItemLabel, + getPhaseBadge, isDetectOnlyRow, isNonCompliantReport, mapRemediationStatus, reduceScriptEvents, + scriptItemKey, toNumber, type ScriptInputEvent, type ScriptItem, @@ -62,24 +64,17 @@ describe("buildScriptItemLabel", () => { ...overrides, }); - it("Health Script (running) for live remediation placeholder", () => { - expect(buildScriptItemLabel(base({ state: "Running", scriptPart: undefined }))).toBe("Health Script (running)"); + it("uses Intune terminology: Remediation for all health-script phases", () => { + // The Intune Admin Center calls these policies "Remediations"; the phase (detection / + // remediation / post-detection) is conveyed via a separate badge, not the title. + expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 4 }))).toBe("Remediation"); + expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 2 }))).toBe("Remediation"); + expect(buildScriptItemLabel(base({ scriptPart: "remediation", remediationStatus: 2 }))).toBe("Remediation"); + expect(buildScriptItemLabel(base({ scriptPart: "post-detection", remediationStatus: 2 }))).toBe("Remediation"); }); - it("Health Detection for detect-only remediation phase", () => { - expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 4 }))).toBe("Health Detection"); - }); - - it("Health Detection for pre-detection phase of full cycle (no special label)", () => { - expect(buildScriptItemLabel(base({ scriptPart: "detection", remediationStatus: 2 }))).toBe("Health Detection"); - }); - - it("Remediation Run for the actual remediation phase", () => { - expect(buildScriptItemLabel(base({ scriptPart: "remediation", remediationStatus: 2 }))).toBe("Remediation Run"); - }); - - it("Detection (post) for post-detection verification phase", () => { - expect(buildScriptItemLabel(base({ scriptPart: "post-detection", remediationStatus: 2 }))).toBe("Detection (post)"); + it("Remediation (running) for live placeholder", () => { + expect(buildScriptItemLabel(base({ state: "Running", scriptPart: undefined }))).toBe("Remediation (running)"); }); it("Platform Script for completed platform scripts", () => { @@ -91,6 +86,38 @@ describe("buildScriptItemLabel", () => { }); }); +describe("getPhaseBadge", () => { + it("returns the scriptPart for completed remediation rows", () => { + expect(getPhaseBadge({ scriptType: "remediation", scriptPart: "detection", state: "Success" })).toBe("detection"); + expect(getPhaseBadge({ scriptType: "remediation", scriptPart: "remediation", state: "Success" })).toBe("remediation"); + expect(getPhaseBadge({ scriptType: "remediation", scriptPart: "post-detection", state: "Failed" })).toBe("post-detection"); + }); + it("returns null for platform scripts (no phase concept)", () => { + expect(getPhaseBadge({ scriptType: "platform", scriptPart: undefined, state: "Success" })).toBeNull(); + }); + it("returns null for Running placeholders (no phase known yet)", () => { + expect(getPhaseBadge({ scriptType: "remediation", scriptPart: undefined, state: "Running" })).toBeNull(); + }); +}); + +describe("scriptItemKey", () => { + it("is stable across re-renders for the same identity (no timestamp / index in key)", () => { + // Critical for preserving showDetails state across upstream prop changes — the React + // key MUST NOT include any index or timestamp that shifts on reducer re-runs. + const a = scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" }); + const b = scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" }); + expect(a).toBe(b); + }); + it("differs by phase so multi-phase rows get separate React identities", () => { + expect(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" })) + .not.toBe(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "remediation", state: "Success" })); + }); + it("uses a separate slot for Running placeholders so they don't collide with finals", () => { + expect(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: undefined, state: "Running" })) + .not.toBe(scriptItemKey({ policyId: "p1", scriptType: "remediation", scriptPart: "detection", state: "Success" })); + }); +}); + describe("isDetectOnlyRow", () => { const base = (overrides: Partial = {}): Pick => ({ scriptType: "remediation", @@ -189,26 +216,48 @@ describe("reduceScriptEvents", () => { expect(items).toHaveLength(1); }); - it("does NOT collapse repeated runs of the same policy with different timestamps (Codex finding 3)", () => { - // A health script policy on a daily schedule will run multiple times in a long-lived - // session. Each run has a distinct timestamp from IME and must surface as its own row, - // otherwise the second run silently disappears from the UI. + it("collapses repeated emissions of the same (policyId, scriptType, scriptPart) keeping the most-complete entry", () => { + // Trade-off vs. Codex finding 3: in an Autopilot enrollment session IME often re-emits + // the same script across ESP-phase transitions (DeviceSetup→AccountSetup), and the + // second emission frequently has degraded data (lost exit code etc) due to agent state + // being cleared after the first emit. The user found this confusing — same script + // appearing twice with different data quality. We now collapse and keep the entry with + // the most populated fields (exitCode, stdout, etc). For long-running scheduled-script + // monitoring outside Autopilot, this would lose distinct runs — out of scope for this UI. const items = reduceScriptEvents([ finalEvent({ eventType: "script_completed", ts: 0, - data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0, complianceResult: "True" }, + data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0, complianceResult: "True", stdout: "first run details" }, }), finalEvent({ eventType: "script_completed", ts: 86400, - data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 1, complianceResult: "False", remediationStatus: 4 }, + // Second emission has fewer populated fields → must lose the dedupe race. + data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection" }, }), ]); - expect(items).toHaveLength(2); - // Both rows are state=Success (script ran fine each time); the second carries a - // non-compliant compliance verdict surfaced via the amber non-compliant styling. - expect(items.map(i => i.state)).toEqual(["Success", "Success"]); + expect(items).toHaveLength(1); + expect(items[0].exitCode).toBe(0); expect(items[0].complianceResult).toBe("True"); - expect(items[1].complianceResult).toBe("False"); + expect(items[0].stdout).toBe("first run details"); + }); + + it("collapses platform-script re-emissions across ESP phases (real-world Autopilot pattern)", () => { + // Pattern observed in session e2929c97: same platform script ran in DeviceSetup + // (with full data) and again in AccountSetup (degraded data, no exit code). Earlier + // versions surfaced both rows side-by-side which was confusing. + const items = reduceScriptEvents([ + finalEvent({ + eventType: "script_completed", ts: 0, + data: { policyId: "847da54e", scriptType: "platform", exitCode: 0, result: "Success", runContext: "System" }, + }), + finalEvent({ + eventType: "script_completed", ts: 360, + data: { policyId: "847da54e", scriptType: "platform", result: "Success" }, + }), + ]); + expect(items).toHaveLength(1); + expect(items[0].exitCode).toBe(0); + expect(items[0].runContext).toBe("System"); }); it("two runs interleaved with started signals each show their own Running placeholder until matched", () => { @@ -416,6 +465,47 @@ describe("reduceScriptEvents", () => { ]); expect(items[0].state).toBe("Failed"); }); + + // ── stderr-as-failure rule (user UX preference, debrief 2026-05-11) ───────────── + it("PLATFORM script with stderr present renders as Failed regardless of exit code", () => { + const items = reduceScriptEvents([ + finalEvent({ + eventType: "script_completed", ts: 0, + data: { policyId: "p1", scriptType: "platform", exitCode: 0, result: "Success", stderr: "WARNING: deprecated path" }, + }), + ]); + expect(items[0].state).toBe("Failed"); + }); + + it("HEALTH-SCRIPT detection with stderr present renders as Failed (consistency over compliance verdict)", () => { + // Earlier behavior: stderr-on-detection was just amber non-compliant. User wanted + // consistency: stderr present → Failed everywhere. This is the case from session + // e2929c97 (Inventory script with Invoke-WebRequest DNS error). + const items = reduceScriptEvents([ + finalEvent({ + eventType: "script_completed", ts: 0, + data: { + policyId: "p1", + scriptType: "remediation", + scriptPart: "detection", + exitCode: 0, + complianceResult: "True", + stderr: "Invoke-WebRequest : The remote name could not be resolved", + }, + }), + ]); + expect(items[0].state).toBe("Failed"); + }); + + it("whitespace-only stderr does not flip state to Failed", () => { + const items = reduceScriptEvents([ + finalEvent({ + eventType: "script_completed", ts: 0, + data: { policyId: "p1", scriptType: "platform", exitCode: 0, result: "Success", stderr: " \n " }, + }), + ]); + expect(items[0].state).toBe("Success"); + }); }); describe("isNonCompliantReport", () => { diff --git a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts index b19699db..58082821 100644 --- a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts +++ b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts @@ -28,13 +28,41 @@ export interface ScriptItem { stderr?: string; state: "Running" | "Success" | "Failed"; timestamp: string; - firstSeenIndex: number; bootstrapVersion?: string | null; } +/** + * Stable React key for a ScriptItem. Based ONLY on identity-shaped fields (policyId, + * scriptType, scriptPart) so re-renders triggered by upstream prop changes (live event + * polling, SignalR) keep the same component instance mounted — preserving showDetails + * and other local state. Do NOT include timestamp or insertion index here, otherwise + * any reducer re-run will remount and collapse expanded detail panels. + */ +export function scriptItemKey(item: Pick): string { + const part = item.state === "Running" ? "_running" : (item.scriptPart ?? "_nopart"); + return `${item.policyId || "_noid"}-${item.scriptType}-${part}`; +} + /** Threshold above which a Running placeholder is rendered as "stuck?" rather than animated. */ export const STALE_RUNNING_THRESHOLD_SECONDS = 600; +/** + * Score how complete a script item's data is (higher = better). Used by the reducer to + * pick the best entry when re-emissions of the same script collapse into one row. + * Counts the presence of fields that meaningfully describe the script's outcome. + */ +function dataCompleteness(item: ScriptItem): number { + let score = 0; + if (item.exitCode != null) score += 4; // exit code is the most important signal + if (item.result) score += 2; + if (item.complianceResult) score += 2; + if (item.remediationStatus != null) score += 1; + if (item.stdout && item.stdout.length > 0) score += 1; + if (item.stderr && item.stderr.length > 0) score += 1; + if (item.runContext) score += 1; + return score; +} + /** * Coerce a wire value to a number. Backend serializes integer event-data fields as strings * (`Dictionary` payload format), but newer in-process emitters could pass @@ -62,19 +90,30 @@ export function mapRemediationStatus(status?: number): string | null { } } -/** Pick the headline label for a row. Five distinct shapes — see in-code comments. */ +/** + * Pick the headline label for a row. We use Intune Admin Center terminology — proactive + * remediation policies are labelled "Remediation" in the Intune UI, regardless of which + * phase (detection / remediation / post-detection) of the cycle this row represents. + * The phase is conveyed via the badge next to the title, not the title itself. + */ export function buildScriptItemLabel(item: Pick): string { if (item.scriptType === "remediation") { - if (item.state === "Running") return "Health Script (running)"; - if (item.scriptPart === "remediation") return "Remediation Run"; - if (item.scriptPart === "post-detection") return "Detection (post)"; - // Detection phase — same label whether detect-only (RemediationStatus=4) or pre-detection - // of a full cycle. The detect-only badge handles the visual distinction. - return "Health Detection"; + return item.state === "Running" ? "Remediation (running)" : "Remediation"; } return item.state === "Running" ? "Platform Script (running)" : "Platform Script"; } +/** + * Phase badge text for remediation rows (e.g. "detection", "remediation", "post-detection"). + * Returns null when no phase badge should render (platform scripts, Running placeholders). + */ +export function getPhaseBadge(item: Pick): string | null { + if (item.scriptType !== "remediation") return null; + if (item.state === "Running") return null; + if (!item.scriptPart) return null; + return item.scriptPart; +} + /** True when this row should display the "detect-only" badge. */ export function isDetectOnlyRow(item: Pick): boolean { return item.state !== "Running" @@ -114,15 +153,14 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); - const items: ScriptItem[] = []; - const seenFinal = new Set(); - // Keyed by `${policyId}-${scriptType}-${timestamp}` so repeated runs of the same health - // script policy in a long-lived session each get their own running placeholder slot. - // We can't dedupe a "started"-only emission against a "completed" without a shared - // run-id (IME doesn't expose one), so we fall back to nearest-timestamp matching: a - // running placeholder vanishes if a final event with a timestamp >= the started - // timestamp exists for the same (policyId, scriptType). - const finalTimestampsByPolicy = new Map(); + // Map of dedupe-key → ScriptItem. Same (policyId, scriptType, scriptPart) re-emissions + // collapse into one row keeping the entry with the most-complete data. This is the + // common case in IME logs where the same script appears multiple times across ESP-phase + // transitions, sometimes with degraded data on the second emission (lost exit code etc). + // For Autopilot enrollment sessions (time-bounded, < 1 h typical) this is the right + // trade-off — long-running scheduled-script monitoring is out of scope for this UI. + const finalsByKey = new Map(); + const policyIdsWithFinal = new Set(); for (let idx = 0; idx < sorted.length; idx++) { const evt = sorted[idx]; @@ -134,38 +172,35 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { const scriptType = d.scriptType ?? d.script_type ?? "platform"; const scriptPart = d.scriptPart ?? d.script_part; const dedupeId = policyId || `_noid_${idx}`; - // Including the timestamp distinguishes repeated runs of the same policy in a - // long-lived session (e.g. health scripts on a daily schedule). Re-fetched events - // carry the same timestamp and still dedupe correctly. - const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}-${evt.timestamp}`; - if (seenFinal.has(key)) continue; - seenFinal.add(key); - if (policyId) { - const policyKey = `${policyId}-${scriptType}`; - const timestamps = finalTimestampsByPolicy.get(policyKey); - const ts = new Date(evt.timestamp).getTime(); - if (timestamps) timestamps.push(ts); - else finalTimestampsByPolicy.set(policyKey, [ts]); - } + const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}`; + if (policyId) policyIdsWithFinal.add(`${policyId}-${scriptType}`); const exitCode = toNumber(d.exitCode ?? d.exit_code); const remediationStatus = toNumber(d.remediationStatus ?? d.remediation_status); const targetType = toNumber(d.targetType ?? d.target_type); const errorCode = toNumber(d.errorCode ?? d.error_code); + const stdout = typeof d.stdout === "string" ? d.stdout : undefined; + const stderr = typeof d.stderr === "string" ? d.stderr : undefined; + const hasStderr = !!stderr && stderr.trim().length > 0; - // State derivation — phase-aware so a non-compliant health-script detection (exit 1) - // does NOT show as a red Failed card. Detection / post-detection scripts use the exit - // code as a compliance verdict, not a crash signal: exit 0 = compliant, exit non-zero - // = non-compliant (the script ran fine, it's reporting state). Only the remediation - // phase + platform scripts treat non-zero exit as a real failure. The defensive check - // also handles legacy emitters that send script_completed for actual failures. + // State derivation — three rules in priority order: + // 1. stderr present → Failed. Consistent across script types: any time a script + // writes to stderr the user wants visibility, even if exit was 0. Per user + // preference (debrief 2026-05-11): "exit 0 bedeutet zwar success aber wenn auf + // stderr was gezeigt wird sollte es failed sein". + // 2. Phase-aware exit handling: detection / post-detection use exit code as a + // compliance verdict (not a crash signal), so non-zero exit alone is NOT + // failure for those phases. Only remediation phase + platform scripts route + // non-zero exit to Failed. + // 3. Defensive: explicit script_failed eventType OR result === "Failed" → Failed. const isHealthComplianceReport = scriptType === "remediation" && (scriptPart === "detection" || scriptPart === "post-detection"); const isFailureSignal = evt.eventType === "script_failed" + || hasStderr || (!isHealthComplianceReport && exitCode != null && exitCode !== 0) || d.result === "Failed"; - items.push({ + const candidate: ScriptItem = { policyId, scriptType, scriptPart, @@ -177,18 +212,44 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { targetType, errorCode, errorDetails: d.errorDetails ?? d.error_details, - stdout: d.stdout, - stderr: d.stderr, + stdout, + stderr, state: isFailureSignal ? "Failed" : "Success", timestamp: evt.timestamp, - firstSeenIndex: items.length, - bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(d.stdout) : null, - }); + bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(stdout) : null, + }; + + const existing = finalsByKey.get(key); + if (!existing || dataCompleteness(candidate) > dataCompleteness(existing)) { + finalsByKey.set(key, candidate); + } } - const seenRunning = new Set(); - for (let idx = 0; idx < sorted.length; idx++) { - const evt = sorted[idx]; + // Assemble the ordered list of finals. Sort by the timestamp of the kept entry so + // the timeline reflects actual chronology of the surviving events. + const items: ScriptItem[] = Array.from(finalsByKey.values()).sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + + // Suppression set for Running placeholders: track which (policyId, scriptType) pairs + // already have a final, so a started signal that arrives after its own completion + // doesn't surface a stale Running indicator. + const finalTimestampsByPolicy = new Map(); + for (const item of items) { + if (!item.policyId) continue; + const policyKey = `${item.policyId}-${item.scriptType}`; + const ts = new Date(item.timestamp).getTime(); + const arr = finalTimestampsByPolicy.get(policyKey); + if (arr) arr.push(ts); + else finalTimestampsByPolicy.set(policyKey, [ts]); + } + + // Running placeholders: emit one per (policyId, scriptType) when a started signal + // exists without any final at-or-after its timestamp. Collapsed by policyId+type so + // a single row shows "running" rather than one per started signal — matches the + // collapsed-final dedupe semantics above. + const runningEmitted = new Set(); + for (const evt of sorted) { if (evt.eventType !== "script_started") continue; const d = evt.data; if (!d) continue; @@ -197,28 +258,19 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { const scriptType = d.scriptType ?? d.script_type ?? "platform"; if (!policyId) continue; - // Suppress this placeholder if a final event for the same (policyId, scriptType) - // exists with a timestamp at-or-after this started timestamp. That covers two - // cases: (a) the normal flow where the final lands after the started event; - // (b) a re-run scenario where an *earlier* started signal already has a matching - // final, but a *later* started should still surface as Running until ITS final - // arrives. + const policyKey = `${policyId}-${scriptType}`; + if (runningEmitted.has(policyKey)) continue; + const startedTs = new Date(evt.timestamp).getTime(); - const finals = finalTimestampsByPolicy.get(`${policyId}-${scriptType}`); + const finals = finalTimestampsByPolicy.get(policyKey); if (finals && finals.some(ts => ts >= startedTs)) continue; - // Per-timestamp key so two started-only signals for the same policy in different - // runs both render placeholders. - const key = `${policyId}-${scriptType}-_running-${evt.timestamp}`; - if (seenRunning.has(key)) continue; - seenRunning.add(key); - + runningEmitted.add(policyKey); items.push({ policyId, scriptType, state: "Running", timestamp: evt.timestamp, - firstSeenIndex: items.length, bootstrapVersion: null, }); }