From 594b3906ce8ff9f4f620f9378eefc4fd604d08ab Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 11 May 2026 22:36:49 +0200 Subject: [PATCH] feat(agent/v2,web): capture full health-script lifecycle with phase-aware semantics + live indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the line-by-line HS-COMPLIANCE / HS-EXITCODE / HS-STDOUT / HS-STDERR / HS-RUN-CONTEXT pattern set (only ever captured the pre-detection phase, reset state mid-cycle) with a single atomic source: HS-NEW-RESULT matches IME's "[HS] new result = {JSON}" line and emits up to three script_completed events per policy run (pre-detection / remediation / post-detection). HS-SCRIPT-START now also fires a live script_started event so the UI shows a "running" indicator immediately, before the consolidated final result lands ~30s-3min later. Phase-aware failure semantics: detection / post-detection scripts use exit code as a compliance verdict (not a crash signal), so a non-compliant detection that gets remediated no longer counts as a "failed" event in metrics — the row renders with amber styling via isNonCompliantReport(). Only the remediation phase + platform scripts route non-zero exit to script_failed. Defensive against wire-format drift: regex uses [\s\S]* with end-anchor, JSON parsed with try/catch + counter, frontend toNumber() accepts both numeric and string fields (backend serializes ints as strings via Dictionary). Reducer dedupes with timestamp included so repeated runs of the same policy in long sessions don't collapse into one row. V2 only — V1 (Agent.Core) phasing out. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + rules/dist/ime-log-patterns.json | 57 +-- rules/ime-log-patterns/HS-COMPLIANCE.json | 10 - rules/ime-log-patterns/HS-EXITCODE.json | 10 - rules/ime-log-patterns/HS-NEW-RESULT.json | 10 + rules/ime-log-patterns/HS-RUN-CONTEXT.json | 10 - rules/ime-log-patterns/HS-SCRIPT-START.json | 2 +- rules/ime-log-patterns/HS-STDERR.json | 10 - rules/ime-log-patterns/HS-STDOUT.json | 10 - .../ImeLogTrackerAdapterTests.cs | 337 +++++++++++++- .../Enrollment/Ime/ImeLogTracker.Handlers.cs | 304 +++++++++--- .../Ime/ImeLogTracker.LogProcessing.cs | 4 + .../Enrollment/Ime/ImeLogTracker.cs | 17 +- .../Enrollment/Ime/ScriptExecutionState.cs | 45 +- .../SignalAdapters/ImeLogTrackerAdapter.cs | 73 ++- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../BuiltInRulesTests.cs | 1 + .../src/resource-catalog.ts | 1 + .../autopilot-monitor-mcp/src/tools/search.ts | 2 +- .../src/tools/sessions.ts | 2 +- .../AutopilotMonitor.Shared/Constants.cs | 1 + .../docs/sections/SectionAgentInternals.tsx | 2 +- .../app/gather-rules/eventTypes.ts | 6 +- .../[sessionId]/components/EventTimeline.tsx | 8 +- .../app/sessions/[sessionId]/page.tsx | 6 +- .../components/ScriptExecutions.tsx | 206 ++++---- .../lib/__tests__/scriptExecutions.test.ts | 440 ++++++++++++++++++ .../lib/scriptExecutions.ts | 227 +++++++++ 29 files changed, 1519 insertions(+), 287 deletions(-) delete mode 100644 rules/ime-log-patterns/HS-COMPLIANCE.json delete mode 100644 rules/ime-log-patterns/HS-EXITCODE.json create mode 100644 rules/ime-log-patterns/HS-NEW-RESULT.json delete mode 100644 rules/ime-log-patterns/HS-RUN-CONTEXT.json delete mode 100644 rules/ime-log-patterns/HS-STDERR.json delete mode 100644 rules/ime-log-patterns/HS-STDOUT.json create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/scriptExecutions.ts 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; +}