mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix(agent/v2,rules): two-stage health-script emission to close short-session timing gap
The post-PR-HS1 single-source design (HS-NEW-RESULT JSON only) had a real timing gap: IME emits the [HS] new result = JSON line only AFTER its batch send to the Microsoft service, ~30-90 s after the actual script ran (verified in session c1e714e6: 34-74 s gap per policy). Short Autopilot enrollments often end inside that window, so health- script rows were entirely missing from completed sessions even though IME had executed the scripts. Two-stage approach (matches original V1 timing for the early signal, while keeping HS-NEW-RESULT as the canonical truth): - Re-add HS-COMPLIANCE pattern matching BOTH `pre-remdiation` AND `post-remdiation` detection compliance lines → HandleHealthScriptDetectionResult emits a script_completed event immediately. Single regex (`(?<part>pre|post)-remdiation`) covers both phases. - Re-add HS-RUN-CONTEXT / HS-EXITCODE / HS-STDOUT / HS-STDERR patterns feeding a single-slot `_pendingHealthScript` accumulator (IME runs health scripts sequentially within a session — verified). HS-COMPLIANCE merges the slot data onto the early-signal event so exit / stdout / stderr / runContext are populated from the start, not just the inferred compliance verdict. - HS-NEW-RESULT continues to fire later with the full JSON (RemediationStatus, TargetType, ErrorCode, plus the actual remediation phase's data). UI reducer's dataCompleteness() scoring keeps the more complete entry — early-signal row gets enriched in place, no duplicate cards. Remediation phase itself has no early-signal source in IME logs (only the late JSON exposes Info.RemediationExitCode and RemediationScriptOutputDetails). A follow-up based on the agentexecutor invocation line's _RemediationScript.output suffix is possible once we have a real non-compliant cycle log to validate against. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+57
@@ -1,6 +1,28 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"rules": [
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-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": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"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 health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees the real exit code (not just the inferred 0/1) before HS-NEW-RESULT arrives.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "HS-NEW-RESULT",
|
||||
"category": "always",
|
||||
@@ -12,6 +34,17 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"patternId": "HS-RUN-CONTEXT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] The policy needs be run as (?<context>System|User)",
|
||||
"action": "scriptContext",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Captures the execution context (System or User) for a health-script. Feeds the early-signal slot consumed by HS-COMPLIANCE so context is visible immediately rather than waiting for HS-NEW-RESULT (which exposes RunAsAccount).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "HS-SCRIPT-START",
|
||||
"category": "always",
|
||||
@@ -23,6 +56,30 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"patternId": "HS-STDERR",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] err output = (?<output>.*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": {
|
||||
"scriptType": "remediation",
|
||||
"outputType": "stderr"
|
||||
},
|
||||
"description": "Captures standard error from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so stderr is visible immediately rather than waiting for HS-NEW-RESULT.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "HS-STDOUT",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] std output = (?<output>.*)",
|
||||
"action": "scriptOutput",
|
||||
"parameters": {
|
||||
"scriptType": "remediation",
|
||||
"outputType": "stdout"
|
||||
},
|
||||
"description": "Captures standard output from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees stdout immediately rather than waiting for HS-NEW-RESULT.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-AGENT-VERSION",
|
||||
"category": "always",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-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": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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 health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees the real exit code (not just the inferred 0/1) before HS-NEW-RESULT arrives.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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 the execution context (System or User) for a health-script. Feeds the early-signal slot consumed by HS-COMPLIANCE so context is visible immediately rather than waiting for HS-NEW-RESULT (which exposes RunAsAccount).",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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 from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so stderr is visible immediately rather than waiting for HS-NEW-RESULT.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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 from a health-script execution. Feeds the early-signal slot consumed by HS-COMPLIANCE so the user sees stdout immediately rather than waiting for HS-NEW-RESULT.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -69,6 +69,7 @@
|
||||
"scriptOutput",
|
||||
"scriptCompleted",
|
||||
"healthScriptResult",
|
||||
"healthScriptDetectionResult",
|
||||
"captureExitCode",
|
||||
"captureHResult",
|
||||
"captureAppVersion",
|
||||
|
||||
+81
@@ -966,6 +966,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("verified ok", post.Payload["stdout"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthScriptDetectionResult_compliant_emits_sparse_completed_event_immediately()
|
||||
{
|
||||
// Early-signal fallback pattern (HS-COMPLIANCE) fires from the
|
||||
// [HS] the pre-remdiation detection script compliance result line, ~30-90 s
|
||||
// before the consolidated HS-NEW-RESULT JSON line. We emit a sparse
|
||||
// script_completed so short Autopilot enrollments that end inside that gap
|
||||
// still surface SOMETHING for each detection.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.HandleHealthScriptDetectionResultForTest("75d14a95-d49f-473d-9d65-d4b006bc7468", "True", "pre");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Equal("75d14a95-d49f-473d-9d65-d4b006bc7468", info.Payload!["policyId"]);
|
||||
Assert.Equal("remediation", info.Payload["scriptType"]);
|
||||
Assert.Equal("detection", info.Payload["scriptPart"]);
|
||||
Assert.Equal("True", info.Payload["complianceResult"]);
|
||||
Assert.Equal("0", info.Payload["exitCode"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthScriptDetectionResult_non_compliant_routes_to_script_failed_via_inferred_exit_1()
|
||||
{
|
||||
// The handler infers exit=1 from compliance=False. Phase-aware exit handling kicks
|
||||
// in at the adapter — detection exit != 0 stays as script_completed (compliance
|
||||
// verdict, not crash). Non-compliant report renders amber via isNonCompliantReport.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.HandleHealthScriptDetectionResultForTest("7980b14e-0e5a-48c7-a8e5-d6018407ca22", "False", "pre");
|
||||
|
||||
// Detection exit != 0 → still script_completed (compliance report, not crash).
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptFailed));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Equal("False", info.Payload!["complianceResult"]);
|
||||
Assert.Equal("1", info.Payload["exitCode"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthScriptDetectionResult_enriches_event_with_slot_data_from_preceding_HS_lines()
|
||||
{
|
||||
// Real flow: HS-SCRIPT-START → HS-RUN-CONTEXT / HS-EXITCODE / HS-STDOUT / HS-STDERR
|
||||
// → HS-COMPLIANCE. The compliance handler must merge the accumulated slot data so
|
||||
// the early-signal event carries actual exit / context / stdout / stderr (not just
|
||||
// the inferred compliance verdict). Same fields V1 used to surface.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.HandleHealthScriptDetectionResultForTest(
|
||||
"e39d92ae-20bd-4b37-b36a-838abe50ac89",
|
||||
"False", "pre",
|
||||
exitCode: 7,
|
||||
runContext: "System",
|
||||
stdout: "Inventory Updated 2026-05-11",
|
||||
stderr: "Invoke-WebRequest : The remote name could not be resolved");
|
||||
|
||||
// stderr present → adapter routes to script_failed (stderr-as-failure rule).
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptFailed));
|
||||
Assert.Equal("e39d92ae-20bd-4b37-b36a-838abe50ac89", info.Payload!["policyId"]);
|
||||
Assert.Equal("detection", info.Payload["scriptPart"]);
|
||||
Assert.Equal("False", info.Payload["complianceResult"]);
|
||||
Assert.Equal("7", info.Payload["exitCode"]); // real exit, not inferred
|
||||
Assert.Equal("System", info.Payload["runContext"]);
|
||||
Assert.Equal("Inventory Updated 2026-05-11", info.Payload["stdout"]);
|
||||
Assert.Contains("Invoke-WebRequest", info.Payload["stderr"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthScriptDetectionResult_post_part_maps_to_post_detection_phase()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.HandleHealthScriptDetectionResultForTest("11111111-2222-3333-4444-555555555555", "True", "post");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
Assert.Equal("post-detection", info.Payload!["scriptPart"]);
|
||||
Assert.Equal("True", info.Payload["complianceResult"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthScriptResult_remediation_phase_with_non_zero_exit_emits_script_failed()
|
||||
{
|
||||
|
||||
+134
-5
@@ -186,9 +186,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 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.
|
||||
// indicator immediately. Also (re)init the per-policy slot that the
|
||||
// HS-RUN-CONTEXT / HS-EXITCODE / HS-STDOUT / HS-STDERR handlers fill, so the
|
||||
// early-signal HS-COMPLIANCE event carries real exit / stdout / stderr / context
|
||||
// instead of just the inferred compliance verdict. The consolidated outcome
|
||||
// arrives later via HS-NEW-RESULT → HandleHealthScriptResult.
|
||||
var policyType = match.Groups["policyType"]?.Value;
|
||||
_pendingHealthScript = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "remediation",
|
||||
};
|
||||
_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}"); }
|
||||
@@ -212,13 +220,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
// Routes line-by-line script-data handlers (context / exitCode / output) to the right
|
||||
// accumulator based on the pattern's scriptType parameter. Health-script lines feed the
|
||||
// single-slot _pendingHealthScript; platform-script lines feed _pendingPlatformScripts.
|
||||
private ScriptExecutionState GetCurrentScriptForLineUpdate(Dictionary<string, string> parameters)
|
||||
{
|
||||
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
|
||||
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
|
||||
? _pendingHealthScript
|
||||
: GetCurrentPlatformScript();
|
||||
}
|
||||
|
||||
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var context = match.Groups["context"]?.Value;
|
||||
if (string.IsNullOrEmpty(context)) return;
|
||||
|
||||
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
|
||||
var script = GetCurrentPlatformScript();
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
if (script != null)
|
||||
{
|
||||
script.RunContext = runContext;
|
||||
@@ -231,7 +250,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 = GetCurrentPlatformScript();
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
if (script != null)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
@@ -242,7 +261,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 = GetCurrentPlatformScript();
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
if (script == null) return;
|
||||
|
||||
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
|
||||
@@ -315,6 +334,116 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// invalid JSON is logged once at warning level and counted via
|
||||
/// <see cref="ImeLogTracker.HealthScriptResultParseFailures"/>.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Early-signal fallback emit for health-script detection / post-detection.
|
||||
/// IME logs <c>[HS] the (pre|post)-remdiation detection script compliance result</c>
|
||||
/// immediately after each script runs, but the consolidated <c>[HS] new result = {…}</c>
|
||||
/// JSON line — the canonical source — only fires after IME's batch-send to the
|
||||
/// Microsoft service, typically 30-90 s later. Short Autopilot enrollments often end
|
||||
/// inside that gap, so this handler guarantees we have at least the compliance verdict
|
||||
/// for each detection. When HS-NEW-RESULT later fires with the full payload, the UI
|
||||
/// reducer's dataCompleteness scoring keeps the more complete entry.
|
||||
/// </summary>
|
||||
private void HandleHealthScriptDetectionResult(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
var compliance = match.Groups["compliance"]?.Value;
|
||||
var part = match.Groups["part"]?.Value; // "pre" or "post"
|
||||
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(compliance))
|
||||
{
|
||||
_logger.Debug("ImeLogTracker: HS-COMPLIANCE missing id or compliance, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var isCompliant = string.Equals(compliance, "True", StringComparison.OrdinalIgnoreCase);
|
||||
var scriptPart = string.Equals(part, "post", StringComparison.OrdinalIgnoreCase) ? "post-detection" : "detection";
|
||||
|
||||
// Pull line-by-line slot data accumulated since the matching HS-SCRIPT-START so the
|
||||
// early-signal event carries real exit / stdout / stderr / context — same fields V1
|
||||
// surfaced. The slot is keyed by the most recent script-start, so for the post-
|
||||
// detection compliance line it may be empty (lines for the remediation phase that
|
||||
// ran in between went into a different cycle's slot). That's fine: HS-NEW-RESULT
|
||||
// arrives later and fills any gaps via the UI dataCompleteness dedupe.
|
||||
int? exitCode = null;
|
||||
string runContext = null;
|
||||
string stdout = null;
|
||||
string stderr = null;
|
||||
if (_pendingHealthScript != null && string.Equals(_pendingHealthScript.PolicyId, id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
exitCode = _pendingHealthScript.ExitCode;
|
||||
runContext = _pendingHealthScript.RunContext;
|
||||
stdout = _pendingHealthScript.Stdout;
|
||||
stderr = _pendingHealthScript.Stderr;
|
||||
}
|
||||
|
||||
// If we don't have a real exit code from the line-by-line capture, fall back to
|
||||
// the inferred value: IME treats exit 0 = compliant, non-zero = non-compliant for
|
||||
// detection / post-detection scripts.
|
||||
if (exitCode == null) exitCode = isCompliant ? 0 : 1;
|
||||
|
||||
var enriched = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "remediation",
|
||||
ScriptPart = scriptPart,
|
||||
ComplianceResult = isCompliant ? "True" : "False",
|
||||
ExitCode = exitCode,
|
||||
RunContext = runContext,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
};
|
||||
|
||||
_logger.Info($"ImeLogTracker: health-script {scriptPart} early-signal for {id}: " +
|
||||
$"compliance={compliance}, exit={exitCode}, " +
|
||||
$"stdout={(stdout == null ? "n/a" : stdout.Length + " chars")}, " +
|
||||
$"stderr={(stderr == null ? "n/a" : stderr.Length + " chars")}");
|
||||
|
||||
EmitScriptEvent(enriched);
|
||||
|
||||
// Clear slot — next HS-SCRIPT-START reinitialises it for the next script.
|
||||
_pendingHealthScript = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: simulates the full HS-SCRIPT-START → HS-RUN-CONTEXT / HS-EXITCODE /
|
||||
/// HS-STDOUT / HS-STDERR → HS-COMPLIANCE sequence by directly populating the
|
||||
/// per-policy slot and then invoking the compliance handler. Lets unit tests verify
|
||||
/// the early-signal enrichment without driving the full regex pipeline.
|
||||
/// </summary>
|
||||
internal void HandleHealthScriptDetectionResultForTest(
|
||||
string id,
|
||||
string compliance,
|
||||
string part,
|
||||
int? exitCode = null,
|
||||
string runContext = null,
|
||||
string stdout = null,
|
||||
string stderr = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && (exitCode.HasValue || runContext != null || stdout != null || stderr != null))
|
||||
{
|
||||
_pendingHealthScript = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "remediation",
|
||||
ExitCode = exitCode,
|
||||
RunContext = runContext,
|
||||
Stdout = stdout,
|
||||
Stderr = stderr,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a synthetic Match with the named capture groups by running the regex on
|
||||
// a constructed line — far simpler than building a Match instance directly.
|
||||
var pcs = string.Equals(part, "post", StringComparison.OrdinalIgnoreCase) ? "post" : "pre";
|
||||
var sample = $"[HS] the {pcs}-remdiation detection script compliance result for {id} is {compliance}";
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
sample,
|
||||
@"\[HS\] the (?<part>pre|post)-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)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
HandleHealthScriptDetectionResult(match, new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
private void HandleHealthScriptResult(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var json = match.Groups["json"]?.Value;
|
||||
|
||||
+4
@@ -407,6 +407,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HandleHealthScriptResult(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
case "healthscriptdetectionresult":
|
||||
HandleHealthScriptDetectionResult(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
|
||||
break;
|
||||
|
||||
+12
-3
@@ -99,12 +99,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"HealthScripts-????????-??????.log"
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Platform-script multi-line state accumulator (per-policy dict because PS-AGENT-* lines
|
||||
// arrive across multiple log entries and IME may interleave platform scripts).
|
||||
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
|
||||
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastPlatformScriptPolicyId;
|
||||
|
||||
// Health-script (remediation) line-by-line accumulator — single slot because IME
|
||||
// executes health scripts SEQUENTIALLY within a session (verified across multiple
|
||||
// diagnostic captures: ProcessScript → context → exit → stdout → stderr → compliance,
|
||||
// one policy at a time). The slot fills as IME emits HS-RUN-CONTEXT / HS-EXITCODE /
|
||||
// HS-STDOUT / HS-STDERR; HS-COMPLIANCE merges + emits + clears the slot. The full
|
||||
// payload (RemediationStatus, TargetType, ErrorCode, plus the actual remediation
|
||||
// phase's data) arrives later via HS-NEW-RESULT and replaces this entry through the
|
||||
// UI dedupe (dataCompleteness scoring).
|
||||
private ScriptExecutionState _pendingHealthScript;
|
||||
private const int MaxScriptOutputLength = 2048;
|
||||
private const int MaxMultiLineBufferLines = 100;
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
687
|
||||
692
|
||||
|
||||
@@ -1 +1 @@
|
||||
1301
|
||||
1303
|
||||
|
||||
@@ -137,7 +137,7 @@ public class BuiltInRulesTests
|
||||
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation",
|
||||
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
|
||||
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
|
||||
"healthScriptResult",
|
||||
"healthScriptResult", "healthScriptDetectionResult",
|
||||
"captureExitCode", "captureHResult",
|
||||
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
|
||||
"captureAttemptNumber", "captureDetectionResult"
|
||||
|
||||
@@ -241,6 +241,51 @@ describe("reduceScriptEvents", () => {
|
||||
expect(items[0].stdout).toBe("first run details");
|
||||
});
|
||||
|
||||
it("HS-COMPLIANCE early-signal sparse event is replaced by HS-NEW-RESULT full payload via dataCompleteness dedupe", () => {
|
||||
// Real-world health-script timing: HS-COMPLIANCE pattern fires immediately at the
|
||||
// [HS] pre-remdiation line (~30-90 s before HS-NEW-RESULT JSON). Both events are
|
||||
// emitted by the agent. The reducer must keep the entry with more complete data —
|
||||
// the later JSON entry wins because it carries stdout/stderr/RemediationStatus too.
|
||||
const items = reduceScriptEvents([
|
||||
// Sparse early-signal entry: just policyId + scriptPart + compliance + inferred exit.
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection",
|
||||
complianceResult: "True", exitCode: 0 },
|
||||
}),
|
||||
// Full payload arrives ~74 s later from the [HS] new result = JSON.
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 74,
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection",
|
||||
complianceResult: "True", exitCode: 0,
|
||||
stdout: "LocalAdminIsEnabled=False",
|
||||
remediationStatus: 4, targetType: 2, runContext: "System" },
|
||||
}),
|
||||
]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].stdout).toBe("LocalAdminIsEnabled=False");
|
||||
expect(items[0].remediationStatus).toBe(4);
|
||||
expect(items[0].runContext).toBe("System");
|
||||
});
|
||||
|
||||
it("HS-COMPLIANCE alone (session ended before HS-NEW-RESULT) still surfaces a row", () => {
|
||||
// The whole point of the early-signal fallback: short Autopilot enrollments often
|
||||
// end before IME emits the consolidated JSON. Without HS-COMPLIANCE we'd see no
|
||||
// health-script rows at all in those cases. With it, we get the compliance verdict
|
||||
// even though stdout / RemediationStatus are missing.
|
||||
const items = reduceScriptEvents([
|
||||
finalEvent({
|
||||
eventType: "script_completed", ts: 0,
|
||||
data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection",
|
||||
complianceResult: "False", exitCode: 1 },
|
||||
}),
|
||||
]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].complianceResult).toBe("False");
|
||||
expect(items[0].state).toBe("Success"); // detection exit != 0 is non-compliant report, not failure
|
||||
expect(items[0].stdout).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collapses platform-script re-emissions across ESP phases (real-world Autopilot pattern)", () => {
|
||||
// Pattern observed in session e2929c97: same platform script ran in DeviceSetup
|
||||
// (with full data) and again in AccountSetup (degraded data, no exit code). Earlier
|
||||
|
||||
Reference in New Issue
Block a user