diff --git a/.gitignore b/.gitignore index c8201b89..e234f62f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,4 @@ tasks/ db-backups/ plans/REFACTOR_AGENT_V2.md .claude/scheduled_tasks.lock +.claude/plans/health-script-remediation-fix.md diff --git a/rules/dist/ime-log-patterns.json b/rules/dist/ime-log-patterns.json index 979f21b6..defc4fe2 100644 --- a/rules/dist/ime-log-patterns.json +++ b/rules/dist/ime-log-patterns.json @@ -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 (?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?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 (?-?\\d+)", - "action": "scriptExitCode", + "pattern": "\\[HS\\] new result = (?\\{[\\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 (?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 = (?.*)", - "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 = (?.*)", - "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 }, { diff --git a/rules/ime-log-patterns/HS-COMPLIANCE.json b/rules/ime-log-patterns/HS-COMPLIANCE.json deleted file mode 100644 index be5d6c28..00000000 --- a/rules/ime-log-patterns/HS-COMPLIANCE.json +++ /dev/null @@ -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 (?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?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 -} diff --git a/rules/ime-log-patterns/HS-EXITCODE.json b/rules/ime-log-patterns/HS-EXITCODE.json deleted file mode 100644 index 772d5ed3..00000000 --- a/rules/ime-log-patterns/HS-EXITCODE.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../schema/ime-log-pattern.schema.json", - "patternId": "HS-EXITCODE", - "category": "always", - "pattern": "\\[HS\\] exit code of the script is (?-?\\d+)", - "action": "scriptExitCode", - "parameters": { "scriptType": "remediation" }, - "description": "Captures the exit code of a remediation script.", - "enabled": true -} diff --git a/rules/ime-log-patterns/HS-NEW-RESULT.json b/rules/ime-log-patterns/HS-NEW-RESULT.json new file mode 100644 index 00000000..6bad9b46 --- /dev/null +++ b/rules/ime-log-patterns/HS-NEW-RESULT.json @@ -0,0 +1,10 @@ +{ + "$schema": "../schema/ime-log-pattern.schema.json", + "patternId": "HS-NEW-RESULT", + "category": "always", + "pattern": "\\[HS\\] new result = (?\\{[\\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 +} diff --git a/rules/ime-log-patterns/HS-RUN-CONTEXT.json b/rules/ime-log-patterns/HS-RUN-CONTEXT.json deleted file mode 100644 index 83384af9..00000000 --- a/rules/ime-log-patterns/HS-RUN-CONTEXT.json +++ /dev/null @@ -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 (?System|User)", - "action": "scriptContext", - "parameters": { "scriptType": "remediation" }, - "description": "Captures whether a remediation script runs as System or User context.", - "enabled": true -} diff --git a/rules/ime-log-patterns/HS-SCRIPT-START.json b/rules/ime-log-patterns/HS-SCRIPT-START.json index 83ab49d2..1f7e29b5 100644 --- a/rules/ime-log-patterns/HS-SCRIPT-START.json +++ b/rules/ime-log-patterns/HS-SCRIPT-START.json @@ -5,6 +5,6 @@ "pattern": "\\[HS\\] ProcessScript PolicyId: (?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) 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 } diff --git a/rules/ime-log-patterns/HS-STDERR.json b/rules/ime-log-patterns/HS-STDERR.json deleted file mode 100644 index c0b80f7d..00000000 --- a/rules/ime-log-patterns/HS-STDERR.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../schema/ime-log-pattern.schema.json", - "patternId": "HS-STDERR", - "category": "always", - "pattern": "\\[HS\\] err output = (?.*)", - "action": "scriptOutput", - "parameters": { "scriptType": "remediation", "outputType": "stderr" }, - "description": "Captures standard error output (stderr) from a remediation script execution.", - "enabled": true -} diff --git a/rules/ime-log-patterns/HS-STDOUT.json b/rules/ime-log-patterns/HS-STDOUT.json deleted file mode 100644 index c02841a3..00000000 --- a/rules/ime-log-patterns/HS-STDOUT.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../schema/ime-log-pattern.schema.json", - "patternId": "HS-STDOUT", - "category": "always", - "pattern": "\\[HS\\] std output = (?.*)", - "action": "scriptOutput", - "parameters": { "scriptType": "remediation", "outputType": "stdout" }, - "description": "Captures standard output (stdout) from a remediation script execution.", - "enabled": true -} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs index a08285cc..9d60a414 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -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); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs index 585a1431..bf8a182b 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs @@ -157,14 +157,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime // PowerShell script tracking handlers // ----------------------------------------------------------------------- - private ScriptExecutionState GetCurrentScript(Dictionary 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 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 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; + } + + /// + /// Parses the IME [HS] new result = {…} JSON and emits one to three + /// events covering the pre-detection, + /// remediation, and post-detection phases of a health script run. + /// All three field groups (PreRemediationDetectScript* / + /// RemediationScript* / PostRemediationDetectScript*) are nullable — + /// only the populated phases produce an event. Defensive against parse errors: + /// invalid JSON is logged once at warning level and counted via + /// . + /// + private void HandleHealthScriptResult(Match match, Dictionary 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); + } + + /// + /// Test seam that bypasses the regex-match plumbing and feeds a raw [HS] new result + /// JSON payload directly into the parser. Production code path runs through + /// ; 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. + /// + 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) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs index b441baf9..8ad63028 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs @@ -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; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs index b3eb67c0..3f56c8d3 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs @@ -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 _pendingPlatformScripts = new Dictionary(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 OnDoTelemetryReceived { get; set; } public Action OnScriptCompleted { get; set; } + /// + /// 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. + /// + public Action OnScriptStarted { get; set; } + /// /// Fires on every pattern match with the matched PatternId. Plan §4.x M4.4.4. /// Invoked by HandlePatternMatch after is diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs index e6070d3e..7df55034 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs @@ -3,6 +3,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; /// /// 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 +/// ([HS] new result = {…}) can produce up to three of these instances — +/// one per phase (pre-detection, remediation, post-detection). /// public class ScriptExecutionState { @@ -12,7 +15,7 @@ public class ScriptExecutionState /// "platform" or "remediation". public string ScriptType { get; set; } - /// "detection" or "remediation" (remediation scripts only). + /// "detection", "remediation", or "post-detection" (remediation scripts only). public string ScriptPart { get; set; } /// "System" or "User" execution context. @@ -30,6 +33,44 @@ public class ScriptExecutionState /// "Success" or "Failed" (platform scripts). public string Result { get; set; } - /// "True" or "False" compliance result (remediation detection only). + /// "True" or "False" compliance result (remediation detection / post-detection only). public string ComplianceResult { get; set; } + + /// + /// IME RemediationStatus enum from the [HS] new result 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). + /// + public int? RemediationStatus { get; set; } + + /// + /// Target type from the [HS] new result JSON: 1 = User-targeted, 2 = Device-targeted. + /// Used by the UI to differentiate user/device remediations independent of . + /// + public int? TargetType { get; set; } + + /// Top-level ErrorCode field from the [HS] new result JSON (0 = no error). + public int? ErrorCode { get; set; } + + /// Top-level Info.ErrorDetails string from the [HS] new result JSON. + public string ErrorDetails { get; set; } +} + +/// +/// Lightweight live-progress signal emitted when IME logs the start of a script +/// (currently only health scripts via the HS-SCRIPT-START pattern). +/// Gives the UI a "running" indicator before the consolidated final result arrives +/// (which may be 30 s – 3 min later). +/// +public class ScriptStartedInfo +{ + /// Intune policy GUID. + public string PolicyId { get; set; } + + /// "platform" or "remediation". + public string ScriptType { get; set; } + + /// Numeric IME PolicyType from the start line (captured as string), e.g. "6" for health scripts. + public string PolicyType { get; set; } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs index a4397ed0..42fba8cc 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -71,6 +71,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private readonly Action? _prevOnImeAgentVersion; private readonly Action? _prevOnDoTelemetryReceived; private readonly Action? _prevOnScriptCompleted; + private readonly Action? _prevOnScriptStarted; // Our own delegate instances — stored once so Dispose can compare by reference. private readonly Action _ourOnEspPhaseChanged; @@ -80,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private readonly Action _ourOnImeAgentVersion; private readonly Action _ourOnDoTelemetryReceived; private readonly Action _ourOnScriptCompleted; + private readonly Action _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); /// /// 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(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(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); diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index a24ddf93..9084fe1a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -667 +684 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 80a61005..d7725305 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1288 +1300 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs index 39ca4738..3c99ab79 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BuiltInRulesTests.cs @@ -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" diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 994ab0be..22857bac 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -40,6 +40,7 @@ export const EVENT_TYPES_CATALOG = { 'performance_snapshot', 'log_entry', 'gather_result', + 'script_started', 'script_completed', 'script_failed', 'ime_agent_version', diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index 50c2a60c..aefad9b6 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -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', ]; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 3767a4b0..fdc069fb 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -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 = { Trace: -1, Debug: 0, Info: 1, Warning: 2, Error: 3, Critical: 4 }; const PHASE_NAMES: Record = { diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 4b48be45..ff59095f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -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"; diff --git a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx index de354cff..163d9e2a 100644 --- a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx +++ b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx @@ -305,7 +305,7 @@ export function SectionAgentInternals() {

Pattern-Based: All regex patterns come from the backend — no hardcoded patterns. Allows updates without agent rebuild.

Log Files Monitored: IntuneManagementExtension.log, AppWorkload.log, AgentExecutor.log, HealthScripts.log (including rotated archives)

App Lifecycle: download_started → downloading → installing → installed / failed / skipped / postponed

-

Script Execution: Platform Scripts + Remediation Scripts with exit code, stdout, stderr, compliance status

+

Script Execution: Platform Scripts + Health Scripts (Proactive Remediations). Health scripts emit a live script_started indicator on policy start and up to three script_completed events per cycle (pre-detection / remediation / post-detection) parsed from the consolidated [HS] new result JSON line — exit code, stdout, stderr, compliance status, RemediationStatus.

DO Telemetry: Per-app Delivery Optimization data (bytes from peers/HTTP/LAN, peer caching %)

Phase Isolation: Device-phase apps do not bleed into AccountSetup tracking

State Persistence: Log positions + app states survive agent crashes/restarts

diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index a352891b..a3f2c26a 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -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", diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx index 0e872e76..a1a56728 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx @@ -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 }; diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 5c0da7c7..043958e4 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -549,12 +549,14 @@ export default function SessionDetailPage() { )} - {/* Script Executions (from script_completed, script_failed events) */} + {/* Script Executions (from script_started, script_completed, script_failed events) */} {!isGatherRulesSession && (
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} diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx index 97ba55ce..20d293f5 100644 --- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx +++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx @@ -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; -} +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(); - - 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

Script Executions

+ {runningCount > 0 && ( + + {runningCount} running + + )} {successCount > 0 && ( {successCount} succeeded )} + {nonCompliantCount > 0 && ( + + {nonCompliantCount} non-compliant + + )} {failedCount > 0 && ( {failedCount} failed @@ -132,7 +89,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
{scripts.map((item) => ( 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 (
- {item.state === "Failed" ? ( + {item.state === "Running" ? ( + + + + + ) : item.state === "Failed" ? ( + ) : isNonCompliant ? ( + // Triangle warning icon — script ran fine but reported non-compliant state. + + + ) : ( @@ -209,6 +191,14 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite }`}> {item.scriptType} + {isDetectOnly && ( + + detect-only + + )} {item.bootstrapVersion && (
- + {statusText} {item.exitCode != null && ( - + exit {item.exitCode} )} - + {item.state !== "Running" && ( + + )}
- {showDetails && ( + {showDetails && item.state !== "Running" && (
{/* Metadata */}
@@ -257,12 +258,23 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { ite {item.policyId} )} {item.runContext && Context: {item.runContext}} + {item.targetType != null && Target: {item.targetType === 2 ? "Device" : "User"}} {item.exitCode != null && Exit Code: {item.exitCode}} {item.result && Result: {item.result}} {item.complianceResult && Compliance: {item.complianceResult === "True" ? "Compliant" : "Non-compliant"}} + {remediationStatusLabel && Status: {remediationStatusLabel}} + {item.errorCode != null && item.errorCode !== 0 && ( + Error Code: {item.errorCode} + )} Time: {new Date(item.timestamp).toLocaleTimeString()}
+ {item.errorDetails && ( +
+ Error details: {item.errorDetails} +
+ )} + {/* stdout */} {hasStdout && (
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts new file mode 100644 index 00000000..a7b0f11d --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts @@ -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 }> = {}): 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 = {}): Pick => ({ + 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 = {}): Pick => ({ + 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 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); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts new file mode 100644 index 00000000..b19699db --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts @@ -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; +} + +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` 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): 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): 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): 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(); + // 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(); + + 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(); + 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; +}