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