feat(agent/v2,web): capture full health-script lifecycle with phase-aware semantics + live indicator

Replaces the line-by-line HS-COMPLIANCE / HS-EXITCODE / HS-STDOUT / HS-STDERR /
HS-RUN-CONTEXT pattern set (only ever captured the pre-detection phase, reset state
mid-cycle) with a single atomic source: HS-NEW-RESULT matches IME's "[HS] new result =
{JSON}" line and emits up to three script_completed events per policy run (pre-detection
/ remediation / post-detection). HS-SCRIPT-START now also fires a live script_started
event so the UI shows a "running" indicator immediately, before the consolidated final
result lands ~30s-3min later.

Phase-aware failure semantics: detection / post-detection scripts use exit code as a
compliance verdict (not a crash signal), so a non-compliant detection that gets
remediated no longer counts as a "failed" event in metrics — the row renders with
amber styling via isNonCompliantReport(). Only the remediation phase + platform
scripts route non-zero exit to script_failed.

Defensive against wire-format drift: regex uses [\s\S]* with end-anchor, JSON parsed
with try/catch + counter, frontend toNumber() accepts both numeric and string fields
(backend serializes ints as strings via Dictionary<string,string>). Reducer dedupes
with timestamp included so repeated runs of the same policy in long sessions don't
collapse into one row.

V2 only — V1 (Agent.Core) phasing out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-11 22:36:49 +02:00
parent eacef9c38a
commit 594b3906ce
29 changed files with 1519 additions and 287 deletions
+1
View File
@@ -130,3 +130,4 @@ tasks/
db-backups/
plans/REFACTOR_AGENT_V2.md
.claude/scheduled_tasks.lock
.claude/plans/health-script-remediation-fix.md
+5 -52
View File
@@ -2,37 +2,14 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"rules": [
{
"patternId": "HS-COMPLIANCE",
"patternId": "HS-NEW-RESULT",
"category": "always",
"pattern": "\\[HS\\] the pre-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "scriptCompleted",
"parameters": {
"scriptType": "remediation",
"part": "detection"
},
"description": "Captures the detection compliance result for a remediation script. True = compliant (no remediation needed), False = non-compliant (remediation will run).",
"enabled": true
},
{
"patternId": "HS-EXITCODE",
"category": "always",
"pattern": "\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
"action": "scriptExitCode",
"pattern": "\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
"action": "healthScriptResult",
"parameters": {
"scriptType": "remediation"
},
"description": "Captures the exit code of a remediation script.",
"enabled": true
},
{
"patternId": "HS-RUN-CONTEXT",
"category": "always",
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
"action": "scriptContext",
"parameters": {
"scriptType": "remediation"
},
"description": "Captures whether a remediation script runs as System or User context.",
"description": "Atomic final JSON of a health script run, covering all three phases (pre-detection / remediation / post-detection) emitted by IME after the policy completes. Single source of truth for health-script outcomes — replaces the line-by-line HS-COMPLIANCE/HS-EXITCODE/HS-STDOUT/HS-STDERR patterns. The end-anchor and [\\s\\S] character class are defensive against future format changes and multi-line CMTrace buffering.",
"enabled": true
},
{
@@ -43,31 +20,7 @@
"parameters": {
"scriptType": "remediation"
},
"description": "Detects when a Proactive Remediation (Health Script) starts processing. Captures PolicyId and PolicyType (6 = remediation).",
"enabled": true
},
{
"patternId": "HS-STDERR",
"category": "always",
"pattern": "\\[HS\\] err output = (?<output>.*)",
"action": "scriptOutput",
"parameters": {
"scriptType": "remediation",
"outputType": "stderr"
},
"description": "Captures standard error output (stderr) from a remediation script execution.",
"enabled": true
},
{
"patternId": "HS-STDOUT",
"category": "always",
"pattern": "\\[HS\\] std output = (?<output>.*)",
"action": "scriptOutput",
"parameters": {
"scriptType": "remediation",
"outputType": "stdout"
},
"description": "Captures standard output (stdout) from a remediation script execution.",
"description": "Detects when a Proactive Remediation (Health Script) starts processing. Captures PolicyId and PolicyType (6 = remediation). Drives the live script_started event for UI progress indication; the consolidated final result for the same PolicyId arrives later via HS-NEW-RESULT.",
"enabled": true
},
{
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-COMPLIANCE",
"category": "always",
"pattern": "\\[HS\\] the pre-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "scriptCompleted",
"parameters": { "scriptType": "remediation", "part": "detection" },
"description": "Captures the detection compliance result for a remediation script. True = compliant (no remediation needed), False = non-compliant (remediation will run).",
"enabled": true
}
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-EXITCODE",
"category": "always",
"pattern": "\\[HS\\] exit code of the script is (?<exitCode>-?\\d+)",
"action": "scriptExitCode",
"parameters": { "scriptType": "remediation" },
"description": "Captures the exit code of a remediation script.",
"enabled": true
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-NEW-RESULT",
"category": "always",
"pattern": "\\[HS\\] new result = (?<json>\\{[\\s\\S]*\\})\\s*$",
"action": "healthScriptResult",
"parameters": { "scriptType": "remediation" },
"description": "Atomic final JSON of a health script run, covering all three phases (pre-detection / remediation / post-detection) emitted by IME after the policy completes. Single source of truth for health-script outcomes — replaces the line-by-line HS-COMPLIANCE/HS-EXITCODE/HS-STDOUT/HS-STDERR patterns. The end-anchor and [\\s\\S] character class are defensive against future format changes and multi-line CMTrace buffering.",
"enabled": true
}
@@ -1,10 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-RUN-CONTEXT",
"category": "always",
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
"action": "scriptContext",
"parameters": { "scriptType": "remediation" },
"description": "Captures whether a remediation script runs as System or User context.",
"enabled": true
}
+1 -1
View File
@@ -5,6 +5,6 @@
"pattern": "\\[HS\\] ProcessScript PolicyId: (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) PolicyType: (?<policyType>\\d+)",
"action": "scriptStarted",
"parameters": { "scriptType": "remediation" },
"description": "Detects when a Proactive Remediation (Health Script) starts processing. Captures PolicyId and PolicyType (6 = remediation).",
"description": "Detects when a Proactive Remediation (Health Script) starts processing. Captures PolicyId and PolicyType (6 = remediation). Drives the live script_started event for UI progress indication; the consolidated final result for the same PolicyId arrives later via HS-NEW-RESULT.",
"enabled": true
}
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-STDERR",
"category": "always",
"pattern": "\\[HS\\] err output = (?<output>.*)",
"action": "scriptOutput",
"parameters": { "scriptType": "remediation", "outputType": "stderr" },
"description": "Captures standard error output (stderr) from a remediation script execution.",
"enabled": true
}
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-STDOUT",
"category": "always",
"pattern": "\\[HS\\] std output = (?<output>.*)",
"action": "scriptOutput",
"parameters": { "scriptType": "remediation", "outputType": "stdout" },
"description": "Captures standard output (stdout) from a remediation script execution.",
"enabled": true
}
@@ -517,7 +517,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
}
[Fact]
public void ScriptCompleted_platform_failure_marks_severity_Error()
public void ScriptCompleted_platform_failure_emits_script_failed_eventType_with_severity_Error()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
@@ -534,7 +534,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
adapter.TriggerScriptCompletedFromTest(script);
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
// V1 parity: failures get a distinct eventType (script_failed) so the Web reducer
// — which keys on eventType, not severity — renders them as Failed rows. Without
// this, V2 failures would appear as green "Success" cards in the UI.
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptCompleted));
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptFailed));
Assert.Equal(EventSeverity.Error.ToString(), info.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("Failed", info.Payload["result"]);
Assert.Equal("1", info.Payload["exitCode"]);
@@ -704,5 +708,334 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Empty(f.InfoEvents(SharedEventTypes.DownloadProgress));
}
// ─────────────────────────────────────────────────────────────────────
// Health-script live-progress + atomic-result tests (PR-HS1/HS4)
//
// The HS-NEW-RESULT pattern delivers the full pre-detection / remediation /
// post-detection JSON in one shot. HS-SCRIPT-START fires earlier and drives
// a live `script_started` event so the UI can show a "running" indicator
// before the consolidated final result arrives ~30 s – 3 min later.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ScriptStarted_remediation_emits_live_script_started_event()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerScriptStartedFromTest(new ScriptStartedInfo
{
PolicyId = "75d14a95-d49f-473d-9d65-d4b006bc7468",
ScriptType = "remediation",
PolicyType = "6",
});
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptStarted));
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
Assert.Equal("75d14a95-d49f-473d-9d65-d4b006bc7468", info.Payload["policyId"]);
Assert.Equal("remediation", info.Payload["scriptType"]);
Assert.Equal("6", info.Payload["policyType"]);
Assert.Contains("Health script 75d14a95: started", info.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void ScriptStarted_null_or_empty_policyId_is_ignored()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerScriptStartedFromTest(null!);
adapter.TriggerScriptStartedFromTest(new ScriptStartedInfo { PolicyId = null });
adapter.TriggerScriptStartedFromTest(new ScriptStartedInfo { PolicyId = "" });
Assert.Empty(f.Ingress.Posted);
}
[Fact]
public void ScriptCompleted_remediation_post_detection_part_propagates_through_adapter()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var script = new ScriptExecutionState
{
PolicyId = "cafebabe-0000-0000-0000-000000000000",
ScriptType = "remediation",
ScriptPart = "post-detection",
ExitCode = 0,
RunContext = "System",
ComplianceResult = "True",
RemediationStatus = 2, // Remediated
TargetType = 2, // Device
ErrorCode = 0,
};
adapter.TriggerScriptCompletedFromTest(script);
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
Assert.Equal("post-detection", info.Payload!["scriptPart"]);
Assert.Equal("True", info.Payload["complianceResult"]);
// Wire-format check: the new RemediationStatus / TargetType / ErrorCode fields
// must reach the UI via the event payload, otherwise the detect-only badge,
// status pills, and Target column in the detail panel can never light up.
Assert.Equal("2", info.Payload["remediationStatus"]);
Assert.Equal("2", info.Payload["targetType"]);
Assert.Equal("0", info.Payload["errorCode"]);
}
[Fact]
public void HealthScriptResult_propagates_RemediationStatus_TargetType_ErrorCode_to_event_payload()
{
// End-to-end: HandleHealthScriptResultJson → adapter → event payload. Detection-only
// policy from c1e714e6 with TargetType=2 (Device) and RemediationStatus=4 (NoRemediation).
const string json = @"{
""PolicyId"": ""75d14a95-d49f-473d-9d65-d4b006bc7468"",
""PreRemediationDetectScriptOutput"": ""LocalAdminIsEnabled=False"",
""RemediationStatus"": 4,
""ErrorCode"": 0,
""Info"": { ""FirstDetectExitCode"": 0, ""ErrorDetails"": null },
""TargetType"": 2,
""RunAsAccount"": 1
}";
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleHealthScriptResultJson(json);
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
Assert.Equal("4", info.Payload!["remediationStatus"]);
Assert.Equal("2", info.Payload["targetType"]);
Assert.Equal("0", info.Payload["errorCode"]);
Assert.Equal("System", info.Payload["runContext"]);
}
[Fact]
public void HealthScriptResult_DetectionOnly_Compliant_emits_one_detection_event()
{
// Real JSON sample from session c1e714e6 (LocalAdmin compliance check).
// Detection-only policy: no remediation script attached → all
// RemediationScript* / PostRemediationDetectScript* fields are null
// and RemediationStatus = 4 (NoRemediation).
const string json = @"{
""PolicyId"": ""75d14a95-d49f-473d-9d65-d4b006bc7468"",
""UserId"": ""00000000-0000-0000-0000-000000000000"",
""Result"": 3,
""ResultType"": 1,
""ErrorCode"": 0,
""PreRemediationDetectScriptOutput"": ""LocalAdminIsEnabled=False, action list: Administrator|+Administrator,-"",
""PreRemediationDetectScriptError"": """",
""RemediationScriptOutputDetails"": null,
""RemediationScriptErrorDetails"": null,
""PostRemediationDetectScriptOutput"": null,
""PostRemediationDetectScriptError"": null,
""RemediationStatus"": 4,
""Info"": {
""RemediationExitCode"": null,
""FirstDetectExitCode"": 0,
""LastDetectExitCode"": null,
""ErrorDetails"": null
},
""TargetType"": 2,
""RunAsAccount"": 1
}";
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleHealthScriptResultJson(json);
var infos = f.InfoEvents(SharedEventTypes.ScriptCompleted);
var single = Assert.Single(infos);
Assert.Equal("75d14a95-d49f-473d-9d65-d4b006bc7468", single.Payload!["policyId"]);
Assert.Equal("remediation", single.Payload["scriptType"]);
Assert.Equal("detection", single.Payload["scriptPart"]);
Assert.Equal("True", single.Payload["complianceResult"]);
Assert.Equal("0", single.Payload["exitCode"]);
Assert.Equal("System", single.Payload["runContext"]);
}
[Fact]
public void HealthScriptResult_DetectionOnly_NonCompliant_emits_one_completed_event_with_complianceFalse()
{
// Real JSON shape from dev-machine HealthScripts.log (BuiltInAdmin policy 7980b14e):
// FirstDetectExitCode = 1, RemediationStatus = 4 (NoRemediation = no remediation
// script attached), all Remediation* / Post* fields null. Phase-aware semantics:
// detection exit != 0 is a compliance verdict, not a script failure → event type
// is script_completed with complianceResult="False". UI styles this row amber via
// isNonCompliantReport() and surfaces the detect-only badge from RemediationStatus=4.
const string json = @"{
""PolicyId"": ""7980b14e-0e5a-48c7-a8e5-d6018407ca22"",
""UserId"": ""0234b7f4-f66c-4f82-a4e3-67411f22ba40"",
""Result"": 4,
""PreRemediationDetectScriptOutput"": ""BuiltInAdminEnabled=False"",
""PreRemediationDetectScriptError"": """",
""RemediationScriptOutputDetails"": null,
""RemediationScriptErrorDetails"": null,
""PostRemediationDetectScriptOutput"": null,
""PostRemediationDetectScriptError"": null,
""RemediationStatus"": 4,
""Info"": {
""RemediationExitCode"": null,
""FirstDetectExitCode"": 1,
""LastDetectExitCode"": null,
""ErrorDetails"": null
},
""TargetType"": 1,
""RunAsAccount"": 1
}";
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleHealthScriptResultJson(json);
// Phase-aware routing: detection exit != 0 is a non-compliant compliance report,
// NOT a script crash. The script ran perfectly and reported the policy is in a
// non-compliant state. Cycle-level outcome lives in RemediationStatus (here = 4
// / NoRemediation = detect-only). Event type stays script_completed so metrics
// and the timeline don't render this as a failure.
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptFailed));
var single = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
Assert.Equal("7980b14e-0e5a-48c7-a8e5-d6018407ca22", single.Payload!["policyId"]);
Assert.Equal("detection", single.Payload["scriptPart"]);
Assert.Equal("False", single.Payload["complianceResult"]);
Assert.Equal("1", single.Payload["exitCode"]);
Assert.Equal("4", single.Payload["remediationStatus"]);
Assert.Equal(EventSeverity.Info.ToString(), single.Payload[SignalPayloadKeys.Severity]);
}
[Fact]
public void HealthScriptResult_FullCycle_emits_three_events()
{
// Hand-constructed JSON modelling a full health-script cycle: pre-detection False
// → remediation script ran (exit 0) → post-detection now True. The handler must
// emit three ScriptCompleted events with scriptPart = detection / remediation /
// post-detection respectively.
const string json = @"{
""PolicyId"": ""11111111-2222-3333-4444-555555555555"",
""UserId"": ""user-1"",
""Result"": 2,
""ErrorCode"": 0,
""PreRemediationDetectScriptOutput"": ""needs fix"",
""PreRemediationDetectScriptError"": """",
""RemediationScriptOutputDetails"": ""applied fix"",
""RemediationScriptErrorDetails"": """",
""PostRemediationDetectScriptOutput"": ""verified ok"",
""PostRemediationDetectScriptError"": """",
""RemediationStatus"": 2,
""Info"": {
""RemediationExitCode"": 0,
""FirstDetectExitCode"": 1,
""LastDetectExitCode"": 0,
""ErrorDetails"": null
},
""TargetType"": 2,
""RunAsAccount"": 1
}";
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleHealthScriptResultJson(json);
// Phase-aware routing: ALL three phases of a successfully-remediated cycle emit
// script_completed. The detection's exit 1 is a non-compliance report (script ran
// fine), the remediation succeeded (exit 0), and the post-detection confirms the
// fix took. None of these are "script failures" — the cycle is healthy.
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptFailed));
var completed = f.InfoEvents(SharedEventTypes.ScriptCompleted);
Assert.Equal(3, completed.Count);
var detection = completed.Single(e => e.Payload!["scriptPart"] == "detection");
Assert.Equal("False", detection.Payload!["complianceResult"]);
Assert.Equal("1", detection.Payload["exitCode"]);
Assert.Equal("needs fix", detection.Payload["stdout"]);
Assert.Equal(EventSeverity.Info.ToString(), detection.Payload[SignalPayloadKeys.Severity]);
var remediation = completed.Single(e => e.Payload!["scriptPart"] == "remediation");
Assert.Equal("0", remediation.Payload!["exitCode"]);
Assert.Equal("applied fix", remediation.Payload["stdout"]);
Assert.False(remediation.Payload.ContainsKey("complianceResult"),
"remediation phase must not carry a compliance verdict");
var post = completed.Single(e => e.Payload!["scriptPart"] == "post-detection");
Assert.Equal("True", post.Payload!["complianceResult"]);
Assert.Equal("0", post.Payload["exitCode"]);
Assert.Equal("verified ok", post.Payload["stdout"]);
}
[Fact]
public void HealthScriptResult_remediation_phase_with_non_zero_exit_emits_script_failed()
{
// Phase-aware routing flip side: when the actual remediation script crashes
// (RemediationExitCode != 0), THAT phase emits script_failed because it's a
// genuine script failure, not a compliance report.
const string json = @"{
""PolicyId"": ""22222222-2222-2222-2222-222222222222"",
""PreRemediationDetectScriptOutput"": ""needs fix"",
""RemediationScriptOutputDetails"": ""attempted fix"",
""RemediationScriptErrorDetails"": ""error: access denied"",
""RemediationStatus"": 3,
""Info"": {
""FirstDetectExitCode"": 1,
""RemediationExitCode"": 1,
""LastDetectExitCode"": null,
""ErrorDetails"": ""Remediation crashed""
},
""TargetType"": 2,
""RunAsAccount"": 1
}";
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleHealthScriptResultJson(json);
// Detection: non-compliant report → script_completed (phase-aware exemption)
var detection = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
Assert.Equal("detection", detection.Payload!["scriptPart"]);
Assert.Equal("False", detection.Payload["complianceResult"]);
// Remediation: actual script failure → script_failed (phase-aware exemption does NOT apply)
var remediation = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptFailed));
Assert.Equal("remediation", remediation.Payload!["scriptPart"]);
Assert.Equal("1", remediation.Payload["exitCode"]);
Assert.Equal(EventSeverity.Error.ToString(), remediation.Payload[SignalPayloadKeys.Severity]);
}
[Fact]
public void HealthScriptResult_InvalidJson_increments_failure_counter_and_emits_nothing()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var before = f.Tracker.HealthScriptResultParseFailures;
f.Tracker.HandleHealthScriptResultJson("{not even valid json");
Assert.Equal(before + 1, f.Tracker.HealthScriptResultParseFailures);
Assert.Empty(f.Ingress.Posted);
}
[Fact]
public void HealthScriptResult_MissingPolicyId_emits_nothing()
{
// Defensive: the regex captured something json-shaped but the payload is missing
// the required PolicyId. Skip cleanly without emitting half-empty events.
const string json = @"{
""PreRemediationDetectScriptOutput"": ""orphan output"",
""RemediationStatus"": 4,
""Info"": { ""FirstDetectExitCode"": 0 }
}";
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleHealthScriptResultJson(json);
Assert.Empty(f.Ingress.Posted);
}
}
}
@@ -157,14 +157,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// PowerShell script tracking handlers
// -----------------------------------------------------------------------
private ScriptExecutionState GetCurrentScript(Dictionary<string, string> parameters)
{
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
? _currentRemediationScript
: GetCurrentPlatformScript();
}
// Returns the current platform-script accumulator, or null when no platform script is active.
// Health-script (remediation) state is no longer tracked line-by-line — the HS-NEW-RESULT
// pattern delivers the full pre-detection / remediation / post-detection JSON in one shot
// via HandleHealthScriptResult. The platform branch keeps its line-by-line accumulator
// because PS-* patterns (PS-SCRIPT-CONTEXT / PS-SCRIPT-EXITCODE / PS-AGENT-OUTPUT / …) still
// arrive across multiple log lines.
private ScriptExecutionState GetCurrentPlatformScript()
{
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
@@ -187,20 +185,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
// Flush any pending remediation script (shouldn't happen normally, but defensive)
if (_currentRemediationScript != null)
{
_logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
EmitScriptEvent(_currentRemediationScript);
}
_currentRemediationScript = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "remediation",
ScriptPart = "detection" // detection runs first; updated if remediation runs
};
_logger.Info($"ImeLogTracker: remediation script started: {id}");
// Health script: emit a live "started" signal so the UI can show a running
// indicator immediately. The consolidated outcome arrives later via the
// HS-NEW-RESULT pattern → HandleHealthScriptResult, typically 30 s – 3 min later.
var policyType = match.Groups["policyType"]?.Value;
_logger.Info($"ImeLogTracker: health script started: {id}");
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "remediation", PolicyType = policyType }); }
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
}
else
{
@@ -227,7 +218,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (string.IsNullOrEmpty(context)) return;
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
var script = GetCurrentScript(parameters);
var script = GetCurrentPlatformScript();
if (script != null)
{
script.RunContext = runContext;
@@ -240,7 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var exitCodeStr = match.Groups["exitCode"]?.Value;
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
var script = GetCurrentScript(parameters);
var script = GetCurrentPlatformScript();
if (script != null)
{
script.ExitCode = exitCode;
@@ -251,7 +242,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
{
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
var script = GetCurrentScript(parameters);
var script = GetCurrentPlatformScript();
if (script == null) return;
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
@@ -278,55 +269,234 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private void HandleScriptCompleted(Match match, Dictionary<string, string> parameters)
{
// Platform script only — the remediation path is now handled by HandleHealthScriptResult
// (single-source via HS-NEW-RESULT JSON). Any caller passing scriptType=remediation
// here is a stale pattern from a downgraded ruleset; ignore defensively.
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
{
if (_currentRemediationScript == null) return;
var compliance = match.Groups["compliance"]?.Value;
var id = match.Groups["id"]?.Value;
_currentRemediationScript.ComplianceResult = compliance;
if (!string.IsNullOrEmpty(part))
_currentRemediationScript.ScriptPart = part;
if (!string.IsNullOrEmpty(id))
_currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
_logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
$"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
EmitScriptEvent(_currentRemediationScript);
_currentRemediationScript = null;
_logger.Debug("ImeLogTracker: ignoring remediation scriptCompleted from stale pattern (use HS-NEW-RESULT)");
return;
}
else
// Platform script: final result from IntuneManagementExtension.log
var id = match.Groups["id"]?.Value;
var result = match.Groups["result"]?.Value;
if (string.IsNullOrEmpty(id)) return;
// Merge with pending AgentExecutor data if available
if (!_pendingPlatformScripts.TryGetValue(id, out var script))
{
// Platform script: final result from IntuneManagementExtension.log
var id = match.Groups["id"]?.Value;
var result = match.Groups["result"]?.Value;
if (string.IsNullOrEmpty(id)) return;
// Merge with pending AgentExecutor data if available
if (!_pendingPlatformScripts.TryGetValue(id, out var script))
script = new ScriptExecutionState
{
script = new ScriptExecutionState
{
PolicyId = id,
ScriptType = "platform"
};
}
script.Result = result;
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
EmitScriptEvent(script);
_pendingPlatformScripts.Remove(id);
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
_lastPlatformScriptPolicyId = null;
PolicyId = id,
ScriptType = "platform"
};
}
script.Result = result;
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
EmitScriptEvent(script);
_pendingPlatformScripts.Remove(id);
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
_lastPlatformScriptPolicyId = null;
}
/// <summary>
/// Parses the IME <c>[HS] new result = {…}</c> JSON and emits one to three
/// <see cref="ScriptExecutionState"/> events covering the pre-detection,
/// remediation, and post-detection phases of a health script run.
/// All three field groups (<c>PreRemediationDetectScript*</c> /
/// <c>RemediationScript*</c> / <c>PostRemediationDetectScript*</c>) are nullable —
/// only the populated phases produce an event. Defensive against parse errors:
/// invalid JSON is logged once at warning level and counted via
/// <see cref="ImeLogTracker.HealthScriptResultParseFailures"/>.
/// </summary>
private void HandleHealthScriptResult(Match match, Dictionary<string, string> parameters)
{
var json = match.Groups["json"]?.Value;
if (string.IsNullOrEmpty(json))
{
_logger.Debug("ImeLogTracker: HS-NEW-RESULT match had empty json group, skipping");
return;
}
HandleHealthScriptResultJson(json);
}
/// <summary>
/// Test seam that bypasses the regex-match plumbing and feeds a raw <c>[HS] new result</c>
/// JSON payload directly into the parser. Production code path runs through
/// <see cref="HandleHealthScriptResult(Match, Dictionary{string, string})"/>; this method
/// exists so unit tests can exercise the JSON parser + 1-3 phase emit logic without
/// having to write CMTrace log files into a temp directory.
/// </summary>
internal void HandleHealthScriptResultJson(string json)
{
if (string.IsNullOrEmpty(json))
{
_logger.Debug("ImeLogTracker: HandleHealthScriptResultJson received empty json, skipping");
return;
}
JsonElement root;
try
{
using var doc = JsonDocument.Parse(json);
// Clone so the JsonElement outlives the using-scope (we read it after dispose).
root = doc.RootElement.Clone();
}
catch (Exception ex)
{
HealthScriptResultParseFailures++;
_logger.Warning($"ImeLogTracker: HS-NEW-RESULT JSON parse failed (failures so far: {HealthScriptResultParseFailures}): {ex.Message}");
return;
}
var policyId = TryGetString(root, "PolicyId");
if (string.IsNullOrEmpty(policyId))
{
_logger.Debug("ImeLogTracker: HS-NEW-RESULT JSON missing PolicyId, skipping");
return;
}
// Common metadata — applied to every emitted phase event.
var remediationStatus = TryGetInt(root, "RemediationStatus");
var targetType = TryGetInt(root, "TargetType");
var errorCode = TryGetInt(root, "ErrorCode");
var runAsAccount = TryGetInt(root, "RunAsAccount"); // 1 = System, 2 = User
var runContext = runAsAccount switch
{
1 => "System",
2 => "User",
_ => null
};
// Info sub-object holds the per-phase exit codes plus error details.
int? firstDetectExit = null;
int? remediationExit = null;
int? lastDetectExit = null;
string errorDetails = null;
if (root.TryGetProperty("Info", out var infoElement) && infoElement.ValueKind == JsonValueKind.Object)
{
firstDetectExit = TryGetInt(infoElement, "FirstDetectExitCode");
remediationExit = TryGetInt(infoElement, "RemediationExitCode");
lastDetectExit = TryGetInt(infoElement, "LastDetectExitCode");
errorDetails = TryGetString(infoElement, "ErrorDetails");
}
// Phase 1 (always): pre-detection — fires for every health-script run, including
// detect-only policies (RemediationStatus = 4 / NoRemediation).
var detection = BuildHealthScriptPhase(
policyId,
scriptPart: "detection",
exitCode: firstDetectExit,
stdout: TryGetString(root, "PreRemediationDetectScriptOutput"),
stderr: TryGetString(root, "PreRemediationDetectScriptError"),
runContext: runContext,
remediationStatus: remediationStatus,
targetType: targetType,
errorCode: errorCode,
errorDetails: errorDetails);
// complianceResult mirrors the legacy HS-COMPLIANCE event semantics: True when the
// pre-detection script reported compliant (exit 0), False otherwise.
detection.ComplianceResult = firstDetectExit == 0 ? "True" : (firstDetectExit.HasValue ? "False" : null);
EmitScriptEvent(detection);
// Phase 2 (conditional): remediation script — only when the policy attached one and
// detection returned non-compliant. Either Remediation* output/error is populated, or
// RemediationExitCode is non-null (the latter is the canonical "remediation actually ran" signal).
var remediationStdout = TryGetString(root, "RemediationScriptOutputDetails");
var remediationStderr = TryGetString(root, "RemediationScriptErrorDetails");
if (remediationExit.HasValue || !string.IsNullOrEmpty(remediationStdout) || !string.IsNullOrEmpty(remediationStderr))
{
var remediation = BuildHealthScriptPhase(
policyId,
scriptPart: "remediation",
exitCode: remediationExit,
stdout: remediationStdout,
stderr: remediationStderr,
runContext: runContext,
remediationStatus: remediationStatus,
targetType: targetType,
errorCode: errorCode,
errorDetails: errorDetails);
// No complianceResult on the remediation phase — there is nothing to be compliant about.
EmitScriptEvent(remediation);
}
// Phase 3 (conditional): post-detection — IME's re-run of the detection script after
// remediation completed, used to confirm the fix took effect. Same signal logic as phase 2.
var postStdout = TryGetString(root, "PostRemediationDetectScriptOutput");
var postStderr = TryGetString(root, "PostRemediationDetectScriptError");
if (lastDetectExit.HasValue || !string.IsNullOrEmpty(postStdout) || !string.IsNullOrEmpty(postStderr))
{
var postDetection = BuildHealthScriptPhase(
policyId,
scriptPart: "post-detection",
exitCode: lastDetectExit,
stdout: postStdout,
stderr: postStderr,
runContext: runContext,
remediationStatus: remediationStatus,
targetType: targetType,
errorCode: errorCode,
errorDetails: errorDetails);
postDetection.ComplianceResult = lastDetectExit == 0 ? "True" : (lastDetectExit.HasValue ? "False" : null);
EmitScriptEvent(postDetection);
}
_logger.Info($"ImeLogTracker: health-script result emitted for {policyId} " +
$"(status={remediationStatus?.ToString() ?? "?"}, " +
$"firstDetectExit={firstDetectExit?.ToString() ?? "n/a"}, " +
$"remediationExit={remediationExit?.ToString() ?? "n/a"}, " +
$"lastDetectExit={lastDetectExit?.ToString() ?? "n/a"})");
}
private static ScriptExecutionState BuildHealthScriptPhase(
string policyId,
string scriptPart,
int? exitCode,
string stdout,
string stderr,
string runContext,
int? remediationStatus,
int? targetType,
int? errorCode,
string errorDetails) =>
new ScriptExecutionState
{
PolicyId = policyId,
ScriptType = "remediation",
ScriptPart = scriptPart,
ExitCode = exitCode,
Stdout = TruncateOutput(stdout),
Stderr = TruncateOutput(stderr),
RunContext = runContext,
RemediationStatus = remediationStatus,
TargetType = targetType,
ErrorCode = errorCode,
ErrorDetails = errorDetails
};
private static string TryGetString(JsonElement parent, string name)
{
if (!parent.TryGetProperty(name, out var prop)) return null;
return prop.ValueKind switch
{
JsonValueKind.String => prop.GetString(),
JsonValueKind.Null => null,
_ => null
};
}
private static int? TryGetInt(JsonElement parent, string name)
{
if (!parent.TryGetProperty(name, out var prop)) return null;
if (prop.ValueKind != JsonValueKind.Number) return null;
return prop.TryGetInt32(out var value) ? value : (int?)null;
}
private void EmitScriptEvent(ScriptExecutionState script)
@@ -403,6 +403,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
HandleScriptCompleted(match, pattern.Parameters);
break;
case "healthscriptresult":
HandleHealthScriptResult(match, pattern.Parameters);
break;
default:
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
break;
@@ -99,14 +99,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
"HealthScripts-????????-??????.log"
};
// Script execution state tracking (accumulates multi-line data before emitting events)
private ScriptExecutionState _currentRemediationScript;
// Platform-script multi-line state accumulator. Health-script (remediation) state is no
// longer accumulated line-by-line — the HS-NEW-RESULT pattern delivers the full
// pre-detection / remediation / post-detection JSON in one shot via HandleHealthScriptResult.
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
private string _lastPlatformScriptPolicyId;
private const int MaxScriptOutputLength = 2048;
private const int MaxMultiLineBufferLines = 100;
// Counter for HS-NEW-RESULT JSON parse failures — visible to operators via tracker
// metrics so we can detect IME log-format drift in production.
internal int HealthScriptResultParseFailures;
// Set synchronously during HandlePatternMatch so callbacks can read it.
// Setters are `internal` so V2.Core.Tests can drive the source-timestamp path
// through the adapter's TriggerXxxFromTest seams without spinning up a real log file.
@@ -125,6 +130,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
/// <summary>
/// Fires when IME logs the start of a health script (HS-SCRIPT-START pattern).
/// Drives the live "running" indicator in the UI before the consolidated final result
/// (HS-NEW-RESULT) arrives — the latency between start and result is typically
/// 30 s – 3 min depending on script duration and IME's batched reporting cycle.
/// </summary>
public Action<ScriptStartedInfo> OnScriptStarted { get; set; }
/// <summary>
/// Fires on every pattern match with the matched <c>PatternId</c>. Plan §4.x M4.4.4.
/// Invoked by <c>HandlePatternMatch</c> after <see cref="LastMatchedPatternId"/> is
@@ -3,6 +3,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
/// <summary>
/// Accumulates multi-line script execution data from IME logs before emitting
/// a single consolidated event. Used for both platform scripts and remediation scripts.
/// For health scripts (proactive remediations) one source IME log line
/// (<c>[HS] new result = {…}</c>) can produce up to three of these instances —
/// one per phase (pre-detection, remediation, post-detection).
/// </summary>
public class ScriptExecutionState
{
@@ -12,7 +15,7 @@ public class ScriptExecutionState
/// <summary>"platform" or "remediation".</summary>
public string ScriptType { get; set; }
/// <summary>"detection" or "remediation" (remediation scripts only).</summary>
/// <summary>"detection", "remediation", or "post-detection" (remediation scripts only).</summary>
public string ScriptPart { get; set; }
/// <summary>"System" or "User" execution context.</summary>
@@ -30,6 +33,44 @@ public class ScriptExecutionState
/// <summary>"Success" or "Failed" (platform scripts).</summary>
public string Result { get; set; }
/// <summary>"True" or "False" compliance result (remediation detection only).</summary>
/// <summary>"True" or "False" compliance result (remediation detection / post-detection only).</summary>
public string ComplianceResult { get; set; }
/// <summary>
/// IME RemediationStatus enum from the <c>[HS] new result</c> JSON. Known values:
/// 0 = Unknown, 1 = Compliant, 2 = Remediated, 3 = RemediationFailed,
/// 4 = NoRemediation (detection-only policy or no remediation script attached).
/// Set on every event of a health-script cycle (detection / remediation / post-detection).
/// </summary>
public int? RemediationStatus { get; set; }
/// <summary>
/// Target type from the <c>[HS] new result</c> JSON: 1 = User-targeted, 2 = Device-targeted.
/// Used by the UI to differentiate user/device remediations independent of <see cref="RunContext"/>.
/// </summary>
public int? TargetType { get; set; }
/// <summary>Top-level <c>ErrorCode</c> field from the <c>[HS] new result</c> JSON (0 = no error).</summary>
public int? ErrorCode { get; set; }
/// <summary>Top-level <c>Info.ErrorDetails</c> string from the <c>[HS] new result</c> JSON.</summary>
public string ErrorDetails { get; set; }
}
/// <summary>
/// Lightweight live-progress signal emitted when IME logs the start of a script
/// (currently only health scripts via the <c>HS-SCRIPT-START</c> pattern).
/// Gives the UI a "running" indicator before the consolidated final result arrives
/// (which may be 30 s – 3 min later).
/// </summary>
public class ScriptStartedInfo
{
/// <summary>Intune policy GUID.</summary>
public string PolicyId { get; set; }
/// <summary>"platform" or "remediation".</summary>
public string ScriptType { get; set; }
/// <summary>Numeric IME PolicyType from the start line (captured as string), e.g. "6" for health scripts.</summary>
public string PolicyType { get; set; }
}
@@ -71,6 +71,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<string>? _prevOnImeAgentVersion;
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
// Our own delegate instances — stored once so Dispose can compare by reference.
private readonly Action<string> _ourOnEspPhaseChanged;
@@ -80,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<string> _ourOnImeAgentVersion;
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
// Dedup state for DecisionSignals.
private string? _lastEspPhase;
@@ -127,6 +129,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_prevOnImeAgentVersion = _tracker.OnImeAgentVersion;
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
_prevOnScriptStarted = _tracker.OnScriptStarted;
// Store our delegate instances once — implicit method-group conversions
// create a new delegate each time, which would break Dispose's reference check.
@@ -137,6 +140,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_ourOnImeAgentVersion = OnImeAgentVersion;
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
_ourOnScriptCompleted = OnScriptCompleted;
_ourOnScriptStarted = OnScriptStarted;
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
_tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted;
@@ -145,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnImeAgentVersion = _ourOnImeAgentVersion;
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
_tracker.OnScriptStarted = _ourOnScriptStarted;
}
public void Dispose()
@@ -165,6 +170,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnDoTelemetryReceived = _prevOnDoTelemetryReceived;
if (ReferenceEquals(_tracker.OnScriptCompleted, _ourOnScriptCompleted))
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
_tracker.OnScriptStarted = _prevOnScriptStarted;
}
private void OnEspPhaseChanged(string phase)
@@ -209,6 +216,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitScriptCompleted(script);
}
private void OnScriptStarted(ScriptStartedInfo info)
{
_prevOnScriptStarted?.Invoke(info);
EmitScriptStarted(info);
}
internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase);
internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted();
internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) =>
@@ -217,6 +230,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
/// <summary>
/// Prefer the CMTrace log entry timestamp recorded by the most recent pattern match
@@ -731,10 +745,27 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
// Severity derived from script result; failure = exit code != 0 OR Result=Failed.
// 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.
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)
|| (script.ExitCode.HasValue && script.ExitCode.Value != 0);
|| (!isHealthComplianceReport
&& script.ExitCode.HasValue && script.ExitCode.Value != 0);
var severity = isFailure ? EventSeverity.Error : EventSeverity.Info;
var eventType = isFailure ? SharedEventTypes.ScriptFailed : SharedEventTypes.ScriptCompleted;
var culture = System.Globalization.CultureInfo.InvariantCulture;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
@@ -747,6 +778,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
if (!string.IsNullOrEmpty(script.RunContext)) data["runContext"] = script.RunContext!;
if (!string.IsNullOrEmpty(script.Result)) data["result"] = script.Result!;
if (!string.IsNullOrEmpty(script.ComplianceResult)) data["complianceResult"] = script.ComplianceResult!;
if (script.RemediationStatus.HasValue) data["remediationStatus"] = script.RemediationStatus.Value.ToString(culture);
if (script.TargetType.HasValue) data["targetType"] = script.TargetType.Value.ToString(culture);
if (script.ErrorCode.HasValue) data["errorCode"] = script.ErrorCode.Value.ToString(culture);
if (!string.IsNullOrEmpty(script.ErrorDetails)) data["errorDetails"] = script.ErrorDetails!;
if (!string.IsNullOrEmpty(script.Stdout)) data["stdout"] = script.Stdout!;
if (!string.IsNullOrEmpty(script.Stderr)) data["stderr"] = script.Stderr!;
var patternId = _tracker.LastMatchedPatternId;
@@ -774,7 +809,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var msg = $"{label} {shortId}: {messageCore}{exitSuffix}";
_post.Emit(
eventType: SharedEventTypes.ScriptCompleted,
eventType: eventType,
source: SourceLabel,
message: msg,
severity: severity,
@@ -787,6 +822,38 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
$"ImeAdapter: script completed policyId={shortId} type={script.ScriptType ?? "?"} result={script.Result ?? "?"} exit={(script.ExitCode.HasValue ? script.ExitCode.Value.ToString() : "n/a")}");
}
private void EmitScriptStarted(ScriptStartedInfo info)
{
if (info == null || string.IsNullOrEmpty(info.PolicyId)) return;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["policyId"] = info.PolicyId,
};
if (!string.IsNullOrEmpty(info.ScriptType)) data["scriptType"] = info.ScriptType!;
if (!string.IsNullOrEmpty(info.PolicyType)) data["policyType"] = info.PolicyType!;
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
var label = IsRemediation(info.ScriptType) ? "Health script" : "Platform script";
var shortId = info.PolicyId.Length >= 8 ? info.PolicyId.Substring(0, 8) : info.PolicyId;
_post.Emit(
eventType: SharedEventTypes.ScriptStarted,
source: SourceLabel,
message: $"{label} {shortId}: started",
// Live UI indicator — flush immediately so the running card appears within seconds,
// not at the next batch boundary (which would defeat the purpose of a live signal).
immediateUpload: true,
data: data,
occurredAtUtc: now);
_logger?.Debug($"ImeAdapter: script started policyId={shortId} type={info.ScriptType ?? "?"} policyType={info.PolicyType ?? "?"}");
}
private static bool IsRemediation(string? scriptType) =>
string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase);
@@ -1 +1 @@
667
684
@@ -1 +1 @@
1288
1300
@@ -137,6 +137,7 @@ public class BuiltInRulesTests
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation",
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
"healthScriptResult",
"captureExitCode", "captureHResult",
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
"captureAttemptNumber", "captureDetectionResult"
@@ -40,6 +40,7 @@ export const EVENT_TYPES_CATALOG = {
'performance_snapshot',
'log_entry',
'gather_result',
'script_started',
'script_completed',
'script_failed',
'ime_agent_version',
@@ -27,7 +27,7 @@ const KNOWN_EVENT_TYPES = [
'network_interface_info', 'aad_join_status', 'enrollment_type_detected',
'error_detected', 'software_inventory_analysis', 'vulnerability_report',
'performance_snapshot', 'log_entry', 'gather_result',
'script_completed', 'script_failed', 'ime_agent_version',
'script_started', 'script_completed', 'script_failed', 'ime_agent_version',
'do_telemetry', 'download_progress', 'agent_started',
'esp_phase_changed', 'shadow_discrepancy',
];
@@ -21,7 +21,7 @@ const KEY_EVENT_TYPES = new Set([
'enrollment_complete', 'enrollment_failed', 'completion_check',
'desktop_arrived', 'hello_policy_detected', 'waiting_for_hello', 'hello_completion_timeout',
'agent_started', 'agent_shutdown', 'trace_event',
'script_completed', 'script_failed', 'vulnerability_report',
'script_started', 'script_completed', 'script_failed', 'vulnerability_report',
]);
const SEVERITY_RANK: Record<string, number> = { Trace: -1, Debug: 0, Info: 1, Warning: 2, Error: 3, Critical: 4 };
const PHASE_NAMES: Record<number, string> = {
@@ -240,6 +240,7 @@ namespace AutopilotMonitor.Shared
public const string EnrollmentFailed = "enrollment_failed";
public const string DesktopArrived = "desktop_arrived";
public const string CompletionCheck = "completion_check";
public const string ScriptStarted = "script_started";
public const string ScriptCompleted = "script_completed";
public const string ScriptFailed = "script_failed";
public const string EspProvisioningStatus = "esp_provisioning_status";
@@ -305,7 +305,7 @@ export function SectionAgentInternals() {
<p><span className="font-medium">Pattern-Based:</span> All regex patterns come from the backend &mdash; no hardcoded patterns. Allows updates without agent rebuild.</p>
<p><span className="font-medium">Log Files Monitored:</span> IntuneManagementExtension.log, AppWorkload.log, AgentExecutor.log, HealthScripts.log (including rotated archives)</p>
<p><span className="font-medium">App Lifecycle:</span> download_started &rarr; downloading &rarr; installing &rarr; installed / failed / skipped / postponed</p>
<p><span className="font-medium">Script Execution:</span> Platform Scripts + Remediation Scripts with exit code, stdout, stderr, compliance status</p>
<p><span className="font-medium">Script Execution:</span> Platform Scripts + Health Scripts (Proactive Remediations). Health scripts emit a live <code className="text-xs">script_started</code> indicator on policy start and up to three <code className="text-xs">script_completed</code> events per cycle (pre-detection / remediation / post-detection) parsed from the consolidated <code className="text-xs">[HS] new result</code> JSON line — exit code, stdout, stderr, compliance status, RemediationStatus.</p>
<p><span className="font-medium">DO Telemetry:</span> Per-app Delivery Optimization data (bytes from peers/HTTP/LAN, peer caching %)</p>
<p><span className="font-medium">Phase Isolation:</span> Device-phase apps do not bleed into AccountSetup tracking</p>
<p><span className="font-medium">State Persistence:</span> Log positions + app states survive agent crashes/restarts</p>
@@ -82,10 +82,12 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
description: "Delivery Optimization peer/mode telemetry for an application download." },
// -------- Scripts --------
{ value: "script_started", label: "script_started", category: "script",
description: "Live indicator: IME has started executing a platform or health (remediation) script. The consolidated outcome arrives later via script_completed / script_failed (typically 30s-3min for health scripts)." },
{ value: "script_completed", label: "script_completed", category: "script",
description: "A platform or remediation script completed successfully." },
description: "A platform script completed, or one phase (detection / remediation / post-detection) of a health script completed. Health scripts emit up to three of these per policy run." },
{ value: "script_failed", label: "script_failed", category: "script",
description: "A platform or remediation script failed." },
description: "A platform script crashed (exit code != 0), or the remediation phase of a health script crashed. Health-script detection / post-detection phases use exit code as a compliance verdict (non-zero = non-compliant) and emit script_completed even on non-zero exit; only a true script crash in those phases would surface here." },
// -------- Diagnostics / misc --------
{ value: "error_detected", label: "error_detected", category: "diagnostics",
@@ -362,8 +362,12 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
const [copiedDetail, setCopiedDetail] = useState(false);
const rawDetailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
// Filter stdout from script events when showScriptOutput is false
const isScriptEvent = event.eventType === "script_completed" || event.eventType === "script_failed";
// Filter stdout from script events when showScriptOutput is false.
// script_started has no stdout/stderr (live indicator only) but include it so the
// timeline still applies the same script-event styling/iconography.
const isScriptEvent = event.eventType === "script_started"
|| event.eventType === "script_completed"
|| event.eventType === "script_failed";
const detailData = useMemo(() => {
if (!rawDetailData || !isScriptEvent || showScriptOutput !== false) return rawDetailData;
const filtered = { ...rawDetailData };
@@ -549,12 +549,14 @@ export default function SessionDetailPage() {
</div>
)}
{/* Script Executions (from script_completed, script_failed events) */}
{/* Script Executions (from script_started, script_completed, script_failed events) */}
{!isGatherRulesSession && (
<div id="section-scripts">
<ScriptExecutions
events={events.filter(
e => e.eventType === "script_completed" || e.eventType === "script_failed"
e => e.eventType === "script_started"
|| e.eventType === "script_completed"
|| e.eventType === "script_failed"
)}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
@@ -1,91 +1,38 @@
"use client";
import { useMemo, useState } from "react";
import { compareVersions, extractBootstrapVersion } from "@/utils/bootstrapVersion";
interface ScriptEvent {
timestamp: string;
eventType?: string;
data?: Record<string, any>;
}
import { useEffect, useMemo, useState } from "react";
import { compareVersions } from "@/utils/bootstrapVersion";
import {
buildScriptItemLabel,
isDetectOnlyRow,
isNonCompliantReport,
mapRemediationStatus,
reduceScriptEvents,
STALE_RUNNING_THRESHOLD_SECONDS,
type ScriptInputEvent,
type ScriptItem,
} from "@/lib/scriptExecutions";
interface ScriptExecutionsProps {
events: ScriptEvent[];
events: ScriptInputEvent[];
showScriptOutput?: boolean;
latestBootstrapVersion?: string | null;
}
interface ScriptItem {
policyId: string;
scriptType: string; // "platform" or "remediation"
scriptPart?: string; // "detection" or "remediation" (for remediation scripts)
runContext?: string; // "System" or "User"
exitCode?: number;
result?: string; // "Success" or "Failed"
complianceResult?: string; // "True" or "False"
stdout?: string;
stderr?: string;
state: "Success" | "Failed";
timestamp: string;
firstSeenIndex: number;
bootstrapVersion?: string | null; // Parsed from stdout if this is the Autopilot-Monitor bootstrap script
}
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion }: ScriptExecutionsProps) {
const scripts = useMemo(() => {
if (events.length === 0) return [];
const sorted = [...events].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const items: ScriptItem[] = [];
const seen = new Set<string>();
for (let idx = 0; idx < sorted.length; idx++) {
const evt = sorted[idx];
const d = evt.data;
if (!d) continue;
const policyId = d.policyId ?? d.policy_id ?? "";
const scriptType = d.scriptType ?? d.script_type ?? "platform";
const scriptPart = d.scriptPart ?? d.script_part;
// Dedupe key includes scriptPart for remediation (detection vs remediation phase).
// Use eventId as fallback when policyId is missing to avoid collapsing unrelated scripts.
const dedupeId = policyId || `_noid_${idx}`;
const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
const isSuccess = evt.eventType === "script_completed";
items.push({
policyId,
scriptType,
scriptPart,
runContext: d.runContext ?? d.run_context,
exitCode: d.exitCode ?? d.exit_code,
result: d.result,
complianceResult: d.complianceResult ?? d.compliance_result,
stdout: d.stdout,
stderr: d.stderr,
state: isSuccess ? "Success" : "Failed",
timestamp: evt.timestamp,
firstSeenIndex: items.length,
bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(d.stdout) : null,
});
}
return items;
}, [events]);
const scripts = useMemo(() => reduceScriptEvents(events), [events]);
const [expanded, setExpanded] = useState(true);
if (scripts.length === 0) return null;
const successCount = scripts.filter(s => s.state === "Success").length;
// Distinguish "script ran fine and was compliant" from "script ran fine but reported
// non-compliance" — the latter isn't a failure but deserves visibility (it's the
// "needs attention" middle ground for health-script detection / post-detection).
const nonCompliantCount = scripts.filter(isNonCompliantReport).length;
const successCount = scripts.filter(s => s.state === "Success").length - nonCompliantCount;
const failedCount = scripts.filter(s => s.state === "Failed").length;
const runningCount = scripts.filter(s => s.state === "Running").length;
const platformCount = scripts.filter(s => s.scriptType === "platform").length;
const remediationCount = scripts.filter(s => s.scriptType === "remediation").length;
@@ -101,11 +48,21 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
</svg>
<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">
{runningCount} running
</span>
)}
{successCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
{successCount} succeeded
</span>
)}
{nonCompliantCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium" title="Detection script ran successfully but reported a non-compliant state">
{nonCompliantCount} non-compliant
</span>
)}
{failedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{failedCount} failed
@@ -132,7 +89,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
<div className="space-y-3 mt-4">
{scripts.map((item) => (
<ScriptItemRow
key={`${item.policyId}-${item.scriptPart ?? ""}-${item.firstSeenIndex}`}
key={`${item.policyId}-${item.scriptPart ?? "_running"}-${item.firstSeenIndex}`}
item={item}
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
@@ -153,44 +110,69 @@ function getIntuneScriptUrl(policyId: string, scriptType: string): string | null
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null }) {
const [showDetails, setShowDetails] = useState(false);
// Re-render every 5s while in Running state so elapsed-time updates live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (item.state !== "Running") return;
const id = setInterval(() => setNow(Date.now()), 5000);
return () => clearInterval(id);
}, [item.state]);
const elapsedSeconds = item.state === "Running"
? Math.max(0, Math.floor((now - new Date(item.timestamp).getTime()) / 1000))
: null;
const isStale = elapsedSeconds !== null && elapsedSeconds > STALE_RUNNING_THRESHOLD_SECONDS;
const label = buildScriptItemLabel(item);
const isDetectOnly = isDetectOnlyRow(item);
const isNonCompliant = isNonCompliantReport(item);
const containerClass = item.state === "Failed"
? "bg-red-50 border border-red-200"
: "bg-green-50 border border-green-200";
: item.state === "Running"
? `bg-blue-50 border border-blue-200 ${isStale ? "" : "animate-pulse"}`
: isNonCompliant
? "bg-amber-50 border border-amber-200"
: "bg-green-50 border border-green-200";
const shortId = item.policyId
? (item.policyId.length >= 8 ? item.policyId.substring(0, 8) : item.policyId)
: "unknown";
const intuneUrl = getIntuneScriptUrl(item.policyId, item.scriptType);
// Build label: "Platform Script" or "Remediation Detection" / "Remediation"
let label: string;
if (item.scriptType === "remediation") {
label = item.scriptPart === "remediation" ? "Remediation Script" : "Remediation Detection";
} else {
label = "Platform Script";
}
// Build status text
// Status text for the right-hand summary cell
let statusText: string;
if (item.scriptType === "remediation" && item.complianceResult) {
if (item.state === "Running") {
statusText = isStale ? `Running (${elapsedSeconds}s — stuck?)` : `Running (${elapsedSeconds}s)`;
} else if (item.scriptType === "remediation" && item.complianceResult) {
statusText = item.complianceResult === "True" ? "Compliant" : "Non-compliant";
} else {
statusText = item.result ?? (item.state === "Success" ? "Success" : "Failed");
}
const hasStdout = showScriptOutput !== false && item.stdout && item.stdout.trim().length > 0;
const hasStderr = item.stderr && item.stderr.trim().length > 0;
const hasStdout = item.state !== "Running" && showScriptOutput !== false && item.stdout && item.stdout.trim().length > 0;
const hasStderr = item.state !== "Running" && item.stderr && item.stderr.trim().length > 0;
const hasOutput = hasStdout || hasStderr;
const remediationStatusLabel = mapRemediationStatus(item.remediationStatus);
return (
<div className={`rounded-lg p-3 ${containerClass}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 min-w-0">
{item.state === "Failed" ? (
{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">
<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>
) : item.state === "Failed" ? (
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : isNonCompliant ? (
// Triangle warning icon — script ran fine but reported non-compliant state.
<svg className="w-4 h-4 text-amber-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
) : (
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -209,6 +191,14 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
}`}>
{item.scriptType}
</span>
{isDetectOnly && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600"
title="Detection-only policy — no remediation script attached"
>
detect-only
</span>
)}
{item.bootstrapVersion && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-700"
@@ -230,24 +220,35 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
)}
</div>
<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" : "text-green-600"}`}>
<span className={`font-medium ${
item.state === "Failed" ? "text-red-600"
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-blue-600")
: isNonCompliant ? "text-amber-700"
: "text-green-600"
}`}>
{statusText}
</span>
{item.exitCode != null && (
<span className={`font-mono ${item.exitCode !== 0 ? "text-red-600" : "text-gray-500"}`}>
<span className={`font-mono ${
item.state === "Failed" && item.exitCode !== 0 ? "text-red-600"
: isNonCompliant ? "text-amber-700"
: "text-gray-500"
}`}>
exit {item.exitCode}
</span>
)}
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showDetails ? 'Hide' : 'Details'}
</button>
{item.state !== "Running" && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showDetails ? 'Hide' : 'Details'}
</button>
)}
</div>
</div>
{showDetails && (
{showDetails && item.state !== "Running" && (
<div className="mt-3 space-y-2">
{/* Metadata */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
@@ -257,12 +258,23 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite
<span className="font-mono">{item.policyId}</span>
)}</span>
{item.runContext && <span><span className="font-medium text-gray-700">Context:</span> {item.runContext}</span>}
{item.targetType != null && <span><span className="font-medium text-gray-700">Target:</span> {item.targetType === 2 ? "Device" : "User"}</span>}
{item.exitCode != null && <span><span className="font-medium text-gray-700">Exit Code:</span> <span className="font-mono">{item.exitCode}</span></span>}
{item.result && <span><span className="font-medium text-gray-700">Result:</span> {item.result}</span>}
{item.complianceResult && <span><span className="font-medium text-gray-700">Compliance:</span> {item.complianceResult === "True" ? "Compliant" : "Non-compliant"}</span>}
{remediationStatusLabel && <span><span className="font-medium text-gray-700">Status:</span> {remediationStatusLabel}</span>}
{item.errorCode != null && item.errorCode !== 0 && (
<span><span className="font-medium text-gray-700">Error Code:</span> <span className="font-mono">{item.errorCode}</span></span>
)}
<span><span className="font-medium text-gray-700">Time:</span> {new Date(item.timestamp).toLocaleTimeString()}</span>
</div>
{item.errorDetails && (
<div className="text-xs text-red-700 bg-red-50 border border-red-200 rounded p-2">
<span className="font-medium">Error details:</span> {item.errorDetails}
</div>
)}
{/* stdout */}
{hasStdout && (
<div>
@@ -0,0 +1,440 @@
import { describe, it, expect } from "vitest";
import {
buildScriptItemLabel,
isDetectOnlyRow,
isNonCompliantReport,
mapRemediationStatus,
reduceScriptEvents,
toNumber,
type ScriptInputEvent,
type ScriptItem,
} from "../scriptExecutions";
const ts = (offsetSeconds: number) => new Date(1700000000000 + offsetSeconds * 1000).toISOString();
const finalEvent = (overrides: Partial<{ eventType: string; ts: number; data: Record<string, any> }> = {}): ScriptInputEvent => ({
timestamp: ts(overrides.ts ?? 0),
eventType: overrides.eventType ?? "script_completed",
data: overrides.data ?? {},
});
describe("toNumber", () => {
it("returns numbers as-is", () => {
expect(toNumber(0)).toBe(0);
expect(toNumber(42)).toBe(42);
expect(toNumber(-1)).toBe(-1);
});
it("coerces numeric strings (V2 wire format)", () => {
expect(toNumber("0")).toBe(0);
expect(toNumber("4")).toBe(4);
expect(toNumber("-1")).toBe(-1);
});
it("returns undefined for null / undefined / empty / non-numeric", () => {
expect(toNumber(null)).toBeUndefined();
expect(toNumber(undefined)).toBeUndefined();
expect(toNumber("")).toBeUndefined();
expect(toNumber("abc")).toBeUndefined();
expect(toNumber(NaN)).toBeUndefined();
});
});
describe("mapRemediationStatus", () => {
it("maps known IME RemediationStatus codes to labels", () => {
expect(mapRemediationStatus(0)).toBe("Unknown");
expect(mapRemediationStatus(1)).toBe("Compliant");
expect(mapRemediationStatus(2)).toBe("Remediated");
expect(mapRemediationStatus(3)).toBe("RemediationFailed");
expect(mapRemediationStatus(4)).toBe("NoRemediation");
});
it("returns null for undefined or unknown codes", () => {
expect(mapRemediationStatus(undefined)).toBeNull();
expect(mapRemediationStatus(99)).toBeNull();
});
});
describe("buildScriptItemLabel", () => {
const base = (overrides: Partial<ScriptItem> = {}): Pick<ScriptItem, "scriptType" | "scriptPart" | "state" | "remediationStatus"> => ({
scriptType: "remediation",
scriptPart: "detection",
state: "Success",
remediationStatus: 4,
...overrides,
});
it("Health Script (running) for live remediation placeholder", () => {
expect(buildScriptItemLabel(base({ state: "Running", scriptPart: undefined }))).toBe("Health Script (running)");
});
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("Platform Script for completed platform scripts", () => {
expect(buildScriptItemLabel(base({ scriptType: "platform", scriptPart: undefined, state: "Success" }))).toBe("Platform Script");
});
it("Platform Script (running) for live platform placeholder", () => {
expect(buildScriptItemLabel(base({ scriptType: "platform", scriptPart: undefined, state: "Running" }))).toBe("Platform Script (running)");
});
});
describe("isDetectOnlyRow", () => {
const base = (overrides: Partial<ScriptItem> = {}): Pick<ScriptItem, "scriptType" | "scriptPart" | "remediationStatus" | "state"> => ({
scriptType: "remediation",
scriptPart: "detection",
remediationStatus: 4,
state: "Success",
...overrides,
});
it("true when remediation+detection+RemediationStatus=4 (NoRemediation) and not Running", () => {
expect(isDetectOnlyRow(base())).toBe(true);
});
it("false for the remediation phase", () => {
expect(isDetectOnlyRow(base({ scriptPart: "remediation", remediationStatus: 2 }))).toBe(false);
});
it("false when remediation actually ran (status != 4)", () => {
expect(isDetectOnlyRow(base({ remediationStatus: 2 }))).toBe(false);
});
it("false for live Running placeholder", () => {
expect(isDetectOnlyRow(base({ state: "Running" }))).toBe(false);
});
it("false for platform scripts", () => {
expect(isDetectOnlyRow(base({ scriptType: "platform", scriptPart: undefined }))).toBe(false);
});
});
describe("reduceScriptEvents", () => {
it("returns empty for empty input", () => {
expect(reduceScriptEvents([])).toEqual([]);
});
it("emits a Running placeholder when only script_started is seen", () => {
const items = reduceScriptEvents([
{ ...finalEvent({ eventType: "script_started", ts: 0, data: { policyId: "p1", scriptType: "remediation" } }) },
]);
expect(items).toHaveLength(1);
expect(items[0].state).toBe("Running");
expect(items[0].policyId).toBe("p1");
expect(items[0].scriptPart).toBeUndefined();
});
it("suppresses the Running placeholder once any final phase has been seen", () => {
const items = reduceScriptEvents([
finalEvent({ eventType: "script_started", ts: 0, data: { policyId: "p1", scriptType: "remediation" } }),
finalEvent({
eventType: "script_completed", ts: 30,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", complianceResult: "True", exitCode: 0 },
}),
]);
// Only the final detection row remains — the Running placeholder is dropped.
expect(items).toHaveLength(1);
expect(items[0].state).toBe("Success");
expect(items[0].scriptPart).toBe("detection");
});
it("renders all three phases of a full remediation cycle as Success (detection non-compliance is a report, not a failure)", () => {
// Phase-aware semantics: a successfully-remediated cycle (status=2) means ALL THREE
// phases reflect a healthy run. Detection's exit 1 is a non-compliance report,
// remediation actually fixed the issue (exit 0), and post-detection confirms (exit 0).
// None of these are script failures — the cycle is healthy and metrics should not
// count it as failed.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 5,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", complianceResult: "False", exitCode: 1, remediationStatus: 2 },
}),
finalEvent({
eventType: "script_completed", ts: 6,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "remediation", exitCode: 0, remediationStatus: 2 },
}),
finalEvent({
eventType: "script_completed", ts: 7,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "post-detection", complianceResult: "True", exitCode: 0, remediationStatus: 2 },
}),
]);
expect(items).toHaveLength(3);
expect(items.map(i => i.scriptPart)).toEqual(["detection", "remediation", "post-detection"]);
expect(items.map(i => i.state)).toEqual(["Success", "Success", "Success"]);
});
it("dedupes re-fetched events with the same timestamp (refresh / SignalR replay)", () => {
const same = {
eventType: "script_completed",
ts: 0,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0 },
};
const items = reduceScriptEvents([
finalEvent(same),
finalEvent(same),
finalEvent(same),
]);
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.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0, complianceResult: "True" },
}),
finalEvent({
eventType: "script_completed", ts: 86400,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 1, complianceResult: "False", remediationStatus: 4 },
}),
]);
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[0].complianceResult).toBe("True");
expect(items[1].complianceResult).toBe("False");
});
it("two runs interleaved with started signals each show their own Running placeholder until matched", () => {
// started(t=0) → completed(t=5) → started(t=100) [no completion yet]
// First run finalizes; second run still pending → one final + one running placeholder.
const items = reduceScriptEvents([
finalEvent({ eventType: "script_started", ts: 0, data: { policyId: "p1", scriptType: "remediation" } }),
finalEvent({
eventType: "script_completed", ts: 5,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0 },
}),
finalEvent({ eventType: "script_started", ts: 100, data: { policyId: "p1", scriptType: "remediation" } }),
]);
expect(items).toHaveLength(2);
const final = items.find(i => i.state === "Success");
const running = items.find(i => i.state === "Running");
expect(final).toBeDefined();
expect(running).toBeDefined();
expect(running!.timestamp).toBe(new Date(1700000000000 + 100 * 1000).toISOString());
});
it("script_started AFTER an existing final emits a new Running placeholder (treated as second run, not out-of-order)", () => {
// After Codex finding 3 we changed dedup semantics: timestamps are part of the key,
// so a started event with a later timestamp than a known final is treated as the
// start of a SECOND run rather than out-of-order delivery of the first. This is the
// production-dominant case (health scripts on schedules) and SignalR maintains order
// within a connection, so genuine reordering of started/completed for a single run
// is vanishingly rare. The Stale-warning >600s catches any actually-stuck placeholder.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 5,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0 },
}),
finalEvent({ eventType: "script_started", ts: 10, data: { policyId: "p1", scriptType: "remediation" } }),
]);
expect(items).toHaveLength(2);
expect(items.find(i => i.state === "Success")).toBeDefined();
expect(items.find(i => i.state === "Running")).toBeDefined();
});
it("script_started BEFORE its own final does not emit a stale placeholder", () => {
// Normal flow: started lands first, completion later. The placeholder must vanish
// when the final for the same policyId/scriptType arrives at-or-after the started ts.
const items = reduceScriptEvents([
finalEvent({ eventType: "script_started", ts: 0, data: { policyId: "p1", scriptType: "remediation" } }),
finalEvent({
eventType: "script_completed", ts: 5,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 0 },
}),
]);
expect(items).toHaveLength(1);
expect(items[0].state).toBe("Success");
});
it("propagates new RemediationStatus / TargetType / ErrorCode fields onto items", () => {
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: {
policyId: "p1", scriptType: "remediation", scriptPart: "detection",
remediationStatus: 4, targetType: 2, errorCode: 0,
errorDetails: null,
},
}),
]);
expect(items[0].remediationStatus).toBe(4);
expect(items[0].targetType).toBe(2);
expect(items[0].errorCode).toBe(0);
});
it("accepts both camelCase and snake_case field names (forward+backward compat)", () => {
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: {
policy_id: "p1", script_type: "remediation", script_part: "detection",
compliance_result: "True", exit_code: 0,
remediation_status: 4, target_type: 1,
},
}),
]);
expect(items[0].policyId).toBe("p1");
expect(items[0].scriptPart).toBe("detection");
expect(items[0].complianceResult).toBe("True");
expect(items[0].exitCode).toBe(0);
expect(items[0].remediationStatus).toBe(4);
expect(items[0].targetType).toBe(1);
});
it("Running placeholder is keyed per (policyId, scriptType) — separate platform vs remediation", () => {
const items = reduceScriptEvents([
finalEvent({ eventType: "script_started", ts: 0, data: { policyId: "p1", scriptType: "remediation" } }),
finalEvent({ eventType: "script_started", ts: 1, data: { policyId: "p1", scriptType: "platform" } }),
]);
expect(items).toHaveLength(2);
expect(items.map(i => i.scriptType).sort()).toEqual(["platform", "remediation"]);
});
// ── Codex finding 2: wire-format coercion (Backend serializes ints as strings) ───────
it("coerces V2 wire-format string fields to numbers (Codex finding 2)", () => {
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: {
// Real V2 wire shape: every numeric field arrives as a string
// (Dictionary<string, string> serialization in the Functions ingest).
policyId: "p1",
scriptType: "remediation",
scriptPart: "detection",
exitCode: "0",
remediationStatus: "4",
targetType: "2",
errorCode: "0",
},
}),
]);
expect(items[0].exitCode).toBe(0);
expect(items[0].remediationStatus).toBe(4);
expect(items[0].targetType).toBe(2);
expect(items[0].errorCode).toBe(0);
});
it("detect-only badge condition lights up on V2 wire shape (string remediationStatus='4')", () => {
// Codex finding 2 follow-on: with the prior bug, RemediationStatus arriving as "4"
// (string) was dropped → isDetectOnlyRow returned false → badge never showed. With
// toNumber coercion it now lights up correctly.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection",
exitCode: "0", remediationStatus: "4" },
}),
]);
expect(isDetectOnlyRow(items[0])).toBe(true);
});
// ── Phase-aware failure detection ────────────────────────────────────────────────
it("health-script DETECTION with exit 1 is Success-with-non-compliant (script ran fine, just reported state)", () => {
// The user-visible UX win: a non-compliant detection that gets remediated must NOT
// count as failed. The script ran perfectly; it's just reporting "non-compliant".
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: 1, complianceResult: "False" },
}),
]);
expect(items[0].state).toBe("Success");
expect(items[0].complianceResult).toBe("False");
expect(isNonCompliantReport(items[0])).toBe(true);
});
it("health-script POST-DETECTION with exit 1 is Success-with-non-compliant (remediation didn't take)", () => {
// Even when remediation didn't fix the underlying issue, the post-detection script
// itself ran correctly. RemediationStatus=3 conveys the cycle outcome separately.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "post-detection", exitCode: 1, complianceResult: "False", remediationStatus: 3 },
}),
]);
expect(items[0].state).toBe("Success");
expect(isNonCompliantReport(items[0])).toBe(true);
});
it("health-script REMEDIATION phase with exit != 0 IS Failed (genuine script crash)", () => {
// The remediation phase is the one that does treat non-zero as a real failure: the
// script crashed while attempting to fix the issue.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_failed", ts: 0,
data: { policyId: "p1", scriptType: "remediation", scriptPart: "remediation", exitCode: 1 },
}),
]);
expect(items[0].state).toBe("Failed");
expect(isNonCompliantReport(items[0])).toBe(false);
});
it("PLATFORM script with non-zero exit is Failed (no compliance-report exemption)", () => {
// Platform scripts have no detection/post-detection concept; non-zero exit = crash.
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: { policyId: "p1", scriptType: "platform", exitCode: 1 },
}),
]);
expect(items[0].state).toBe("Failed");
});
it("treats script_completed with result='Failed' as Failed (defense)", () => {
const items = reduceScriptEvents([
finalEvent({
eventType: "script_completed", ts: 0,
data: { policyId: "p1", scriptType: "platform", exitCode: 0, result: "Failed" },
}),
]);
expect(items[0].state).toBe("Failed");
});
it("script_failed event type renders as Failed even with exit 0 (rare but defensive)", () => {
const items = reduceScriptEvents([
finalEvent({
eventType: "script_failed", ts: 0,
data: { policyId: "p1", scriptType: "platform", exitCode: 0 },
}),
]);
expect(items[0].state).toBe("Failed");
});
});
describe("isNonCompliantReport", () => {
it("true for detection with complianceResult=False", () => {
expect(isNonCompliantReport({ scriptType: "remediation", scriptPart: "detection", complianceResult: "False", state: "Success" })).toBe(true);
});
it("true for post-detection with complianceResult=False", () => {
expect(isNonCompliantReport({ scriptType: "remediation", scriptPart: "post-detection", complianceResult: "False", state: "Success" })).toBe(true);
});
it("false when complianceResult is True", () => {
expect(isNonCompliantReport({ scriptType: "remediation", scriptPart: "detection", complianceResult: "True", state: "Success" })).toBe(false);
});
it("false for the remediation phase", () => {
expect(isNonCompliantReport({ scriptType: "remediation", scriptPart: "remediation", complianceResult: undefined, state: "Success" })).toBe(false);
});
it("false when state is Failed", () => {
expect(isNonCompliantReport({ scriptType: "remediation", scriptPart: "detection", complianceResult: "False", state: "Failed" })).toBe(false);
});
it("false for platform scripts", () => {
expect(isNonCompliantReport({ scriptType: "platform", scriptPart: undefined, complianceResult: undefined, state: "Success" })).toBe(false);
});
});
@@ -0,0 +1,227 @@
/**
* Pure logic for the ScriptExecutions panel: turning a stream of script_started /
* script_completed / script_failed events into the deduped, ordered list of items
* the UI renders. Extracted from the React component so the 2-pass reducer + label
* mapping can be unit-tested without React Testing Library.
*/
import { extractBootstrapVersion } from "@/utils/bootstrapVersion";
export interface ScriptInputEvent {
timestamp: string;
eventType?: string;
data?: Record<string, any>;
}
export interface ScriptItem {
policyId: string;
scriptType: string; // "platform" | "remediation"
scriptPart?: string; // "detection" | "remediation" | "post-detection"
runContext?: string;
exitCode?: number;
result?: string;
complianceResult?: string;
remediationStatus?: number;
targetType?: number;
errorCode?: number;
errorDetails?: string;
stdout?: string;
stderr?: string;
state: "Running" | "Success" | "Failed";
timestamp: string;
firstSeenIndex: number;
bootstrapVersion?: string | null;
}
/** Threshold above which a Running placeholder is rendered as "stuck?" rather than animated. */
export const STALE_RUNNING_THRESHOLD_SECONDS = 600;
/**
* 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
* raw numbers. Accept both shapes so the reducer doesn't quietly drop fields based on the
* wire encoding. Returns undefined for null / undefined / non-numeric strings.
*/
export function toNumber(v: unknown): number | undefined {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.length > 0) {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
}
/** Map RemediationStatus enum to the human-readable label shown in the detail panel. */
export function mapRemediationStatus(status?: number): string | null {
switch (status) {
case 0: return "Unknown";
case 1: return "Compliant";
case 2: return "Remediated";
case 3: return "RemediationFailed";
case 4: return "NoRemediation";
default: return null;
}
}
/** Pick the headline label for a row. Five distinct shapes — see in-code comments. */
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" ? "Platform Script (running)" : "Platform Script";
}
/** 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"
&& item.scriptType === "remediation"
&& item.scriptPart === "detection"
&& item.remediationStatus === 4;
}
/**
* True when this row represents a non-compliant health-script reading (detection or
* post-detection that returned exit != 0). The script ran successfully — state stays
* "Success" — but the compliance verdict is False, so the UI should style it amber to
* draw attention without crying "failure".
*/
export function isNonCompliantReport(item: Pick<ScriptItem, "scriptType" | "scriptPart" | "complianceResult" | "state">): boolean {
return item.state === "Success"
&& item.scriptType === "remediation"
&& (item.scriptPart === "detection" || item.scriptPart === "post-detection")
&& item.complianceResult === "False";
}
/**
* 2-pass reducer:
* 1. Sort events by timestamp; collect every script_completed / script_failed final.
* Dedupe by (policyId, scriptType, scriptPart) so re-fetched events don't double-render.
* 2. For every script_started whose policyId hasn't been finalized yet, append a "Running"
* placeholder. When a final lands later (SignalR or re-fetch) the placeholder vanishes
* naturally on the next render because the second pass re-runs.
*
* Returns rows in insertion order: finals first (sorted by timestamp), then any live
* placeholders. The component sees a stable, deduped list with optimistic live indicators.
*/
export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
if (events.length === 0) return [];
const sorted = [...events].sort(
(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[]>();
for (let idx = 0; idx < sorted.length; idx++) {
const evt = sorted[idx];
if (evt.eventType !== "script_completed" && evt.eventType !== "script_failed") continue;
const d = evt.data;
if (!d) continue;
const policyId = d.policyId ?? d.policy_id ?? "";
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 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);
// 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.
const isHealthComplianceReport = scriptType === "remediation"
&& (scriptPart === "detection" || scriptPart === "post-detection");
const isFailureSignal = evt.eventType === "script_failed"
|| (!isHealthComplianceReport && exitCode != null && exitCode !== 0)
|| d.result === "Failed";
items.push({
policyId,
scriptType,
scriptPart,
runContext: d.runContext ?? d.run_context,
exitCode,
result: d.result,
complianceResult: d.complianceResult ?? d.compliance_result,
remediationStatus,
targetType,
errorCode,
errorDetails: d.errorDetails ?? d.error_details,
stdout: d.stdout,
stderr: d.stderr,
state: isFailureSignal ? "Failed" : "Success",
timestamp: evt.timestamp,
firstSeenIndex: items.length,
bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(d.stdout) : null,
});
}
const seenRunning = new Set<string>();
for (let idx = 0; idx < sorted.length; idx++) {
const evt = sorted[idx];
if (evt.eventType !== "script_started") continue;
const d = evt.data;
if (!d) continue;
const policyId = d.policyId ?? d.policy_id ?? "";
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 startedTs = new Date(evt.timestamp).getTime();
const finals = finalTimestampsByPolicy.get(`${policyId}-${scriptType}`);
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);
items.push({
policyId,
scriptType,
state: "Running",
timestamp: evt.timestamp,
firstSeenIndex: items.length,
bootstrapVersion: null,
});
}
return items;
}