fix(agent/v2,web/scripts): UX polish + dedupe duplicates + stderr-as-failure rule

UX fallout from session e2929c97 debrief:

- Dedupe: same (policyId, scriptType, scriptPart) re-emissions collapse into one row
  keeping the most-complete entry via dataCompleteness() score. Real-world Autopilot
  pattern is IME re-emitting the same script across ESP-phase transitions, sometimes
  with degraded data on the second emission. Trade-off vs. Codex finding 3 (preserving
  distinct runs of scheduled scripts in long sessions) — out of scope for this UI.

- Labels: use Intune Admin Center terminology — all health-script rows now read
  "Remediation" with phase shown via separate badge ("detection" / "remediation" /
  "post-detection"). The customer-facing word for these policies in Intune is
  "Remediation", not "Health Detection".

- Flicker fix: React key included firstSeenIndex which was reassigned on every
  reducer run, causing ScriptItemRow to remount on upstream prop changes (live
  polling / SignalR) and collapse expanded detail panels. New scriptItemKey() uses
  only identity-shaped fields (policyId, scriptType, scriptPart). firstSeenIndex
  field removed from ScriptItem entirely.

- Animation polish: removed animate-pulse from Running container — only the inline
  spinner SVG conveys motion now. Container is neutral gray. With multiple concurrent
  Running cards the full-box pulse made the page feel unstable.

- stderr-as-failure rule (consistent across script types): stderr present →
  script_failed regardless of exit code or compliance verdict. Eliminates the
  inconsistency where a platform script with stderr showed red but a remediation
  detection with stderr (and exit 0 + compliant) showed green for the same data
  shape. Implemented in BOTH the Agent adapter and the Web reducer.

Agent V2 only — V1 phasing out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-11 23:26:44 +02:00
parent 527e36b957
commit 33023ecfc8
6 changed files with 266 additions and 112 deletions
@@ -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;
@@ -1 +1 @@
684
687
@@ -1 +1 @@
1300
1301
@@ -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
<h2 className="text-lg font-semibold text-gray-900">Script Executions</h2>
<div className="flex items-center space-x-2 text-xs">
{runningCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium animate-pulse">
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 font-medium">
{runningCount} running
</span>
)}
@@ -89,7 +91,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
<div className="space-y-3 mt-4">
{scripts.map((item) => (
<ScriptItemRow
key={`${item.policyId}-${item.scriptPart ?? "_running"}-${item.firstSeenIndex}`}
key={scriptItemKey(item)}
item={item}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
@@ -130,7 +132,10 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
const containerClass = item.state === "Failed"
? "bg-red-50 border border-red-200"
: item.state === "Running"
? `bg-blue-50 border border-blue-200 ${isStale ? "" : "animate-pulse"}`
// Neutral gray for the box itself — only the inline spinner conveys motion.
// The full-box pulse was visually distracting with multiple concurrent Running cards
// and caused a "flickering" feeling that made the page feel unstable.
? "bg-gray-50 border border-gray-200"
: isNonCompliant
? "bg-amber-50 border border-amber-200"
: "bg-green-50 border border-green-200";
@@ -160,7 +165,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 min-w-0">
{item.state === "Running" ? (
<svg className={`w-4 h-4 text-blue-500 flex-shrink-0 ${isStale ? "" : "animate-spin"}`} fill="none" viewBox="0 0 24 24">
<svg className={`w-4 h-4 text-gray-500 flex-shrink-0 ${isStale ? "" : "animate-spin"}`} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@@ -184,13 +189,18 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
) : (
<span className="text-xs font-mono text-gray-500">{shortId}…</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
item.scriptType === "remediation"
? "bg-amber-100 text-amber-700"
: "bg-blue-100 text-blue-700"
}`}>
{item.scriptType}
</span>
{/* Phase badge for remediation rows (detection / remediation / post-detection).
For platform scripts we keep showing the type tag. */}
{item.scriptType === "remediation" && getPhaseBadge(item) && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700">
{getPhaseBadge(item)}
</span>
)}
{item.scriptType !== "remediation" && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
{item.scriptType}
</span>
)}
{isDetectOnly && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600"
@@ -222,7 +232,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
<span className={`font-medium ${
item.state === "Failed" ? "text-red-600"
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-blue-600")
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-gray-600")
: isNonCompliant ? "text-amber-700"
: "text-green-600"
}`}>
@@ -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<ScriptItem> = {}): Pick<ScriptItem, "scriptType" | "scriptPart" | "remediationStatus" | "state"> => ({
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", () => {
@@ -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<ScriptItem, "policyId" | "scriptType" | "scriptPart" | "state">): 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<string, string>` 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<ScriptItem, "scriptType" | "scriptPart" | "state" | "remediationStatus">): 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<ScriptItem, "scriptType" | "scriptPart" | "state">): 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<ScriptItem, "scriptType" | "scriptPart" | "remediationStatus" | "state">): 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<string>();
// 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<string, number[]>();
// 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<string, ScriptItem>();
const policyIdsWithFinal = new Set<string>();
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<string>();
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<string, number[]>();
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<string>();
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,
});
}