diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ScriptOutputReconciliationClassificationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ScriptOutputReconciliationClassificationTests.cs new file mode 100644 index 00000000..bab8be70 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ScriptOutputReconciliationClassificationTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// D-316: the agent corrects an already reported platform run from IME's saved result with a +/// script_output_reconciliation. It is observation of an earlier run: it never heals a Stalled session and never +/// counts as another script run. +/// +public class ScriptOutputReconciliationClassificationTests +{ + private static readonly DateTime At = new(2026, 10, 3, 20, 59, 8, DateTimeKind.Utc); + + [Fact] + public void A_correction_alone_is_no_real_activity_and_no_script_run() + { + var classification = EventIngestProcessor.ClassifyEvents(new List + { + Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }), + }); + + Assert.False(classification.HasNonPeriodicRealEvent); + Assert.Equal(0, classification.PlatformScriptCount); + } + + [Fact] + public void The_corrected_run_counts_once_as_a_platform_script() + { + var classification = EventIngestProcessor.ClassifyEvents(new List + { + Event("script_failed", new Dictionary { ["scriptType"] = "platform", ["runId"] = "run-1", ["exitCode"] = "1" }), + Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }), + }); + + Assert.True(classification.HasNonPeriodicRealEvent); + Assert.Equal(1, classification.PlatformScriptCount); + } + + private static EnrollmentEvent Event(string eventType, Dictionary data) => new() + { + EventId = Guid.NewGuid().ToString(), + Timestamp = At, + EventType = eventType, + Source = "RegistryScriptResult", + Data = data, + }; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs index 8fdd3e1a..b1ea6bf6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs @@ -480,6 +480,9 @@ namespace AutopilotMonitor.Functions.Services // observation too — a device that slept through its stall must stay Stalled. "system_clock_changed" => true, "system_sleep_episode" => true, + // A platform run corrected after the fact from IME's saved result (D-316): registry + // observation of an earlier run, not progress — a stalled session stays Stalled. + Constants.EventTypes.ScriptOutputReconciliation => true, _ => false }; diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/script-output-correction.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/script-output-correction.test.ts new file mode 100644 index 00000000..a08a6361 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/script-output-correction.test.ts @@ -0,0 +1,95 @@ +/** + * D-316: a platform run whose script_failed came from another script's end block is corrected by a later + * script_output_reconciliation (outcome "foreign", IME result Success). get_session_summary must not count the + * stored script_failed as an error, must not rank it as one and must not attach the foreign exit code's catalog + * text; the correction itself stays a Warning key event. + * + * Pure unit tests: fetch is stubbed and the registered tool handler is invoked directly. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/server'; +import { registerTools } from '../tools.js'; +import { runWithCaller } from '../client.js'; +import { foreignSuccessRunIds, isCorrectedForeignFailure } from '../tools/shared.js'; + +type ToolHandler = (args: Record, extra: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }>; + +const SESSION = 'e259c121-1234-4abc-9def-0123456789ab'; +const GA = { token: 'ga', isGlobalAdmin: true }; +const extra = { signal: new AbortController().signal }; + +function summaryHandler(): ToolHandler { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + registerTools(server, undefined, undefined, undefined, true, true, false); + const registry = (server as unknown as { _registeredTools: Record })._registeredTools; + return registry['get_session_summary'].handler; +} + +function stubBackend(events: unknown[]): void { + const body = { + success: true, + session: { sessionId: SESSION, tenantId: 't', status: 'Succeeded', startedAt: '2026-10-03T20:49:00Z' }, + events, + results: [], + annotations: [], + count: events.length, + }; + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }) as unknown as Response)); +} + +afterEach(() => vi.unstubAllGlobals()); + +const foreignFailure = { + timestamp: '2026-10-03T20:59:04Z', + eventType: 'script_failed', + severity: 'Error', + source: 'ImeLogTracker', + message: 'Platform script d94468af: Success (exit: 1)', + data: { scriptType: 'platform', result: 'Success', exitCode: '1', runId: 'run-1' }, +}; + +const correction = (data: Record) => ({ + timestamp: '2026-10-03T20:59:08Z', + eventType: 'script_output_reconciliation', + severity: 'Warning', + source: 'RegistryScriptResult', + message: "Platform script d94468af: output corrected from IME's saved result", + data: { scriptType: 'platform', outcome: 'foreign', result: 'Success', runId: 'run-1', ...data }, +}); + +async function summary(events: unknown[]): Promise> { + stubBackend(events); + const result = await runWithCaller(GA, () => summaryHandler()({ sessionId: SESSION }, extra)); + return JSON.parse((result.content ?? []).map((c) => c.text ?? '').join('')); +} + +describe('get_session_summary with a corrected platform run', () => { + it('does not count the foreign script_failed as an error and drops its exit-code text', async () => { + const body = await summary([foreignFailure, correction({})]); + + expect(body.stats.errorCount).toBe(0); + expect(body.stats.warningCount).toBe(1); + const failed = body.keyEvents.find((e: { eventType: string }) => e.eventType === 'script_failed'); + expect(failed.correctedByImeSavedResult).toBe(true); + expect(failed.errorCode).toBeUndefined(); + expect(body.keyEvents.some((e: { eventType: string }) => e.eventType === 'script_output_reconciliation')).toBe(true); + }); + + it('keeps counting a failure IME itself reported, and an uncorrected one', async () => { + expect((await summary([foreignFailure, correction({ result: 'Failed' })])).stats.errorCount).toBe(1); + expect((await summary([foreignFailure, correction({ outcome: 'repaired' })])).stats.errorCount).toBe(1); + expect((await summary([foreignFailure, correction({ runId: 'another-run' })])).stats.errorCount).toBe(1); + expect((await summary([foreignFailure])).stats.errorCount).toBe(1); + }); +}); + +describe('foreignSuccessRunIds / isCorrectedForeignFailure', () => { + it('names only foreign corrections whose IME result is Success, and only script_failed rows', () => { + const runs = foreignSuccessRunIds([correction({}), correction({ runId: 'r2', result: 'Failed' }), correction({ runId: 'r3', outcome: 'repaired' })]); + expect([...runs]).toEqual(['run-1']); + expect(isCorrectedForeignFailure('script_failed', { runId: 'run-1' }, runs)).toBe(true); + expect(isCorrectedForeignFailure('script_completed', { runId: 'run-1' }, runs)).toBe(false); + expect(isCorrectedForeignFailure('script_failed', { runId: 'r2' }, runs)).toBe(false); + expect(isCorrectedForeignFailure('script_failed', undefined, runs)).toBe(false); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 9a87aeaa..49c886e0 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server'; import { z } from 'zod'; import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; -import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js'; +import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js'; import { toolError } from './error-handler.js'; import { lookupErrorCode } from '../error-code-catalog.js'; import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js'; @@ -534,6 +534,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: // Observation coverage: what the agent could and could not see, folded from the // health/lifecycle events that the triage timeline below ranks low or drops. const coverage = buildSessionCoverage(s, allEvents); + // Platform runs IME's saved result proved to carry another script's exit code (D-316). + const foreignRuns = foreignSuccessRunIds(allEvents); let errorCount = 0; let warningCount = 0; @@ -545,7 +547,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: const sev = String(e.severity ?? ''); // A compliant health-script detection mis-stamped script_failed/Error is benign — // exclude it so a green session's errorCount isn't inflated by routine compliance reports. - const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data); + const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data) + || isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns); if (!benign && (sev === 'Error' || sev === 'Critical')) errorCount++; if (sev === 'Warning') warningCount++; const et = String(e.eventType ?? ''); @@ -571,6 +574,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: const relevanceScore = (e: Partial): number => { // Benign compliant detection mis-stamped Error → rank as info-level, not top. if (isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)) return 10; + // A failure that was another script's exit code is not a failure of this one. + if (isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns)) return 10; const sev = SEVERITY_RANK[String(e.severity ?? '')] ?? -1; if (sev >= 3) return 100; // Error/Critical if (PHASE_EVENT_TYPES.has(String(e.eventType ?? ''))) return 60; @@ -597,7 +602,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: phase: phaseName(e.phase, s.enrollmentType), message: e.message, source: e.source, - ...keyEventErrorCode(e.data, e.source), + // The exit code on a corrected run was another script's: no catalog text for it. + ...(isCorrectedForeignFailure(String(e.eventType ?? ''), e.data, foreignRuns) + ? { correctedByImeSavedResult: true } + : keyEventErrorCode(e.data, e.source)), })); let analysis = null; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts index 1b0030f2..da084c7d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts @@ -75,6 +75,8 @@ export function leanFieldSelection( export const SUMMARY_EVENT_FIELDS = 'eventType,severity,source,phase,timestamp,message,sequence,' + 'data.scriptType,data.script_type,data.scriptPart,data.script_part,data.result,' + + // Platform-run corrections from IME's saved result (foreignSuccessRunIds below). + 'data.runId,data.outcome,' + 'data.rejectedSourceTimestamp,data.rejected_source_timestamp,' + 'data.errorCode,data.exitCode,data.hresult,data.hresultFromWin32,data.hresultSymbol,' + 'data.errorCodeInfo,data.exitCodeInfo,data.hresultInfo,data.hresultFromWin32Info,' + @@ -125,6 +127,38 @@ export function isBenignHealthDetectionReport( return true; } +/** + * Run ids of platform runs whose `script_failed` came from another script's end block while IME + * itself reported Success: `script_output_reconciliation` with outcome "foreign" and result Success + * (D-316 — the agent had attributed a foreign executor's exit code and output; IME's saved result + * proved it). The stored `script_failed` stays as written; read sides treat it like the benign + * detection reports above: no error count, info-level rank, no exit-code catalog text. + */ +export function foreignSuccessRunIds( + events: ReadonlyArray<{ eventType?: string | null; data?: Record }>, +): Set { + const runs = new Set(); + for (const e of events) { + if (e.eventType !== 'script_output_reconciliation' || !e.data) continue; + if (String(e.data.outcome ?? '') !== 'foreign') continue; + if (String(e.data.result ?? '').toLowerCase() !== 'success') continue; + const runId = e.data.runId; + if (typeof runId === 'string' && runId.length > 0) runs.add(runId); + } + return runs; +} + +/** True for a `script_failed` whose run `foreignSuccessRunIds` names. */ +export function isCorrectedForeignFailure( + eventType: string | undefined, + data: Record | undefined, + foreignRuns: ReadonlySet, +): boolean { + if (eventType !== 'script_failed' || !data || foreignRuns.size === 0) return false; + const runId = data.runId; + return typeof runId === 'string' && foreignRuns.has(runId); +} + /** * Builds the role-aware description for a tenant-boundable tool's `tenantId` argument. MCP clients weigh * the per-arg schema heavily, so a delegated (MSP) caller — for whom omitting tenantId is REJECTED, not diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 333040a1..f9510d60 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -152,6 +152,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ 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 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." }, + { value: "script_output_reconciliation", label: "script_output_reconciliation", category: "script", + description: "A platform script's reported output disagreed with the result IME saved after reporting the script batch. Outcome \"foreign\" (Warning): the run had been given another script's output and exit code; the saved output replaces it and the exit code is unknown. Outcome \"repaired\" (Info): the output had arrived incomplete. Carries runId (the corrected run), policyId, outcome, reason, result, stdout and stderr." }, { value: "script_timeout_suspected", label: "script_timeout_suspected", category: "script", description: "A platform script ran to the IME script-execution timeout (~30 min; threshold 25 min) and was marked Failed while the enrollment was in progress. IME runs platform scripts serially, so a hung script starves app installs and the Autopilot-Monitor bootstrap — the prime suspect behind a late agent start / pre-failed ESP. Advisory (one per policyId); carries durationSeconds, exitCode and espPhase." }, { value: "script_recurrence_summary", label: "script_recurrence_summary", category: "script", diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx index 9b11a4eb..550e656c 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/EventTimeline.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useDeferredValue } from "react"; +import { createContext, useContext, useState, useMemo, useDeferredValue } from "react"; import { EnrollmentEvent, Session } from "@/types"; import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers"; import { buildEventSearchMatcher, formatEventSearchTerm, parseEventSearchQuery } from "../utils/eventSearchQuery"; @@ -8,6 +8,11 @@ import { getEnrichedOrLookup, formatErrorCode, errorCodeTooltip, type ErrorCodeI import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/lib/timeProvenance"; import { isRebootOrRetryClass } from "@/lib/installProgress"; import { formatDuration, formatUtcOffset } from "@/lib/formatting"; +import { SCRIPT_OUTPUT_RECONCILIATION } from "@/lib/scriptExecutions"; + +// runId → outcome of every platform run that IME's saved result corrected later (D-316): the +// run's own row says so, so its emitted output is never read as the script's. +const CorrectedRunsContext = createContext>(new Map()); const SEARCH_SYNTAX_HINT = "Searches event type, message, source and the details JSON (e.g. gather output). " + @@ -104,10 +109,20 @@ export default function EventTimeline({ [events], ); + const correctedRuns = useMemo(() => { + const runs = new Map(); + for (const e of events) { + if (e.eventType !== SCRIPT_OUTPUT_RECONCILIATION) continue; + const runId = e.data?.runId; + if (typeof runId === "string" && runId.length > 0) runs.set(runId, e.data?.outcome === "foreign" ? "foreign" : "repaired"); + } + return runs; + }, [events]); + const filterPhaseEvents = (phaseEvents: EnrollmentEvent[]) => matchesSearch ? phaseEvents.filter(matchesSearch) : phaseEvents; - return ( + const timeline = (
{/* Search + Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
@@ -356,6 +371,8 @@ export default function EventTimeline({ )}
); + + return {timeline}; } function PhaseSection({ @@ -515,7 +532,13 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event: // timeline still applies the same script-event styling/iconography. const isScriptEvent = event.eventType === "script_started" || event.eventType === "script_completed" - || event.eventType === "script_failed"; + || event.eventType === "script_failed" + || event.eventType === SCRIPT_OUTPUT_RECONCILIATION; + const correctedRuns = useContext(CorrectedRunsContext); + const eventRunId = event.data?.runId; + const laterCorrection = event.eventType !== SCRIPT_OUTPUT_RECONCILIATION && typeof eventRunId === "string" + ? correctedRuns.get(eventRunId) + : undefined; const detailData = useMemo(() => { if (!rawDetailData || !isScriptEvent || showScriptOutput !== false) return rawDetailData; const filtered = { ...rawDetailData }; @@ -611,6 +634,13 @@ function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event: {event.eventType}

{shortenBuildHashInMessage(event.message)}

+ {laterCorrection && ( +

+ {laterCorrection === "foreign" + ? "Corrected later from IME's saved result: the output and exit code on this row belonged to another script." + : "Output completed later from IME's saved result."} +

+ )} {/* Exit code / HRESULT badge for app install events */} {(event.eventType === "app_install_failed" || event.eventType === "app_install_completed") && (() => { const ec = (event.data?.exitCode ?? event.data?.exit_code) as string | number | undefined; diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index 54db9a30..846003b0 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -234,6 +234,8 @@ function SessionDetailContent() { e => e.eventType === "script_started" || e.eventType === "script_completed" || e.eventType === "script_failed" + // Corrections of a platform run from IME's saved result (D-316) — applied by the reducer. + || e.eventType === "script_output_reconciliation" ), [events] ); diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx index 614f608a..f91851ec 100644 --- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx +++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx @@ -30,6 +30,14 @@ import PhaseDivider from "@/components/PhaseDivider"; // span is hours of offset error rather than run time (a 10 s script once read "1h 00m"). const DURATION_SUPPRESSED_TITLE = "Run time not measurable: the start and end log lines were resolved on different timezone assumptions, so their span would be offset error, not run time"; +// IME's saved result corrected the run (script_output_reconciliation): the log had attributed +// another script's end block to it, so its exit code is unknown and the output shown is IME's. +const EXIT_UNVERIFIED_TITLE = "Exit code unknown: the IME log had attributed another script's output and exit code to this run. The output shown is this script's own, as IME saved it."; +const OUTPUT_CORRECTED_TITLE: Record<"foreign" | "repaired", string> = { + foreign: "The IME log had attributed another script's output to this run. This is the script's own output, as IME saved it after reporting the result.", + repaired: "The IME log delivered this output incomplete. This is the full output, as IME saved it after reporting the result.", +}; + interface ScriptExecutionsProps { events: ScriptInputEvent[]; showScriptOutput?: boolean; @@ -528,6 +536,9 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, exit {item.exitCode} )} + {item.exitCode == null && item.outputCorrection === "foreign" && ( + exit n/a + )} {durationLabel && ( Context: {item.runContext}} {!nested && item.targetType != null && Target: {item.targetType === 2 ? "Device" : "User"}} {item.exitCode != null && Exit Code: {item.exitCode}} + {item.exitCode == null && item.outputCorrection === "foreign" && ( + Exit Code: n/a + )} {detailDurationLabel && Duration: {detailDurationLabel}} {!detailDurationLabel && item.durationSuppressedReason && ( Duration: n/a @@ -601,7 +615,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, {hasStdout && (
-
stdout
+
+ stdout + {item.outputCorrection && ( + {" · from IME's saved result"} + )} +
{item.stdout}
@@ -614,7 +633,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, {hasStderr && (
-
stderr
+
+ stderr + {item.outputCorrection && ( + {" · from IME's saved result"} + )} +
{item.stderr}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts index d62afedf..2fb39f5f 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts @@ -1201,3 +1201,81 @@ describe("isOnOffsetGrid", () => { expect(isOnOffsetGrid(0)).toBe(false); }); }); + +describe("reduceScriptEvents — correction from IME's saved result (D-316)", () => { + const POLICY = "d94468af-6ebe-4e94-9901-2a62d33ea6c1"; + const OWN = "=== Autopilot Monitor Bootstrap Started =====\nBootstrap script version: v2.5\nInstaller finished (exit code 0)."; + const FOREIGN = "VERBOSE: === Sample Suite Detection Started ===\nWARNING: === DETECTION FAILED ==="; + const correction = (data: Record, at = 60): ScriptInputEvent => ({ + timestamp: ts(at), + eventType: "script_output_reconciliation", + data: { policyId: POLICY, scriptType: "platform", ...data }, + }); + + it("a foreign end block is replaced: the script's own output, no exit code, Success", () => { + const items = reduceScriptEvents([ + finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r1", result: "Success", exitCode: "1", stdout: FOREIGN } }), + correction({ runId: "r1", outcome: "foreign", result: "Success", stdout: OWN, stderr: "", previousExitCode: "1" }), + ]); + expect(items).toHaveLength(1); + expect(items[0].state).toBe("Success"); + expect(items[0].exitCode).toBeUndefined(); + expect(items[0].stdout).toBe(OWN); + expect(items[0].stdout).not.toContain("Detection"); + expect(items[0].outputCorrection).toBe("foreign"); + expect(items[0].bootstrapVersion).toBe("2.5"); + }); + + it("an incomplete output is completed and the exit code stands", () => { + const items = reduceScriptEvents([ + finalEvent({ data: { policyId: POLICY, scriptType: "platform", runId: "r2", result: "Success", exitCode: "0" } }), + correction({ runId: "r2", outcome: "repaired", result: "Success", stdout: OWN, stderr: "" }), + ]); + expect(items[0].exitCode).toBe(0); + expect(items[0].stdout).toBe(OWN); + expect(items[0].outputCorrection).toBe("repaired"); + }); + + it("a stdout split into stderr is mended: the empty stderr clears the failure", () => { + const items = reduceScriptEvents([ + finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r3", result: "Success", exitCode: "0", stdout: "Copying files", stderr: "0 errors\nDone, error =" } }), + correction({ runId: "r3", outcome: "repaired", result: "Success", stdout: "Copying files, error = 0 errors\nDone", stderr: "" }), + ]); + expect(items[0].state).toBe("Success"); + expect(items[0].stderr).toBeUndefined(); + expect(items[0].stdout).toBe("Copying files, error = 0 errors\nDone"); + }); + + it("a correction only applies to the run it names", () => { + const items = reduceScriptEvents([ + finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r4", result: "Success", exitCode: "1", stdout: FOREIGN } }), + correction({ runId: "another-run", outcome: "foreign", result: "Success", stdout: OWN, stderr: "" }), + ]); + expect(items[0].state).toBe("Failed"); + expect(items[0].exitCode).toBe(1); + expect(items[0].outputCorrection).toBeUndefined(); + }); + + it("a correction without a stdout key keeps the emitted stdout", () => { + const items = reduceScriptEvents([ + finalEvent({ eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r5", result: "Failed", exitCode: "1", stdout: "partial" } }), + correction({ runId: "r5", outcome: "repaired", result: "Failed", stderr: "Access denied" }), + ]); + expect(items[0].stdout).toBe("partial"); + expect(items[0].stderr).toBe("Access denied"); + expect(items[0].state).toBe("Failed"); + }); + + it("two runs of the same script never surface the corrected run's foreign output", () => { + const items = reduceScriptEvents([ + finalEvent({ ts: 0, data: { policyId: POLICY, scriptType: "platform", runId: "old", result: "Success", exitCode: "0", runContext: "System", stdout: OWN } }), + finalEvent({ ts: 30, eventType: "script_failed", data: { policyId: POLICY, scriptType: "platform", runId: "r6", result: "Success", exitCode: "1", runContext: "System", stdout: FOREIGN } }), + correction({ runId: "r6", outcome: "foreign", result: "Success", stdout: OWN, stderr: "" }), + ]); + expect(items).toHaveLength(1); + expect(items[0].stdout).toBe(OWN); + expect(items[0].stdout).not.toContain("Detection"); + expect(items[0].state).toBe("Success"); + expect(items[0].exitCode === 1).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts index 08e94925..0185e516 100644 --- a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts +++ b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts @@ -36,8 +36,19 @@ interface ScriptEventData { sourceOffsetOrigin?: string; /** "true" when the line's own timestamp was rejected and the event was stamped from the clock. */ derivedTimestamp?: string | boolean; + /** Identity of one platform-script run; a script_output_reconciliation names the run it corrects (agents from 2026-10 on). */ + runId?: string; + /** On script_output_reconciliation: "foreign" (the run had another script's end block) or "repaired". */ + outcome?: string; } +/** + * A platform run corrected after the fact from IME's saved result (D-316). Its `runId` names the + * script_completed/script_failed it corrects; a present `stdout`/`stderr` key replaces the emitted + * value (empty clears it), `outcome` "foreign" makes the emitted exit code unverified. + */ +export const SCRIPT_OUTPUT_RECONCILIATION = "script_output_reconciliation"; + export interface ScriptItem { policyId: string; scriptType: string; // "platform" | "remediation" @@ -80,6 +91,12 @@ export interface ScriptItem { state: "Running" | "Success" | "Failed"; timestamp: string; bootstrapVersion?: string | null; + /** + * Set when IME's saved result corrected the run (script_output_reconciliation): "foreign" — the + * log had given the run another script's end block, so the output shown is the script's own from + * IME and the exit code is unknown; "repaired" — the log had delivered the output incomplete. + */ + outputCorrection?: "foreign" | "repaired"; /** * Set by applyObservationEnd, never by the fold: the script was still running when the * session's observation ended. Terminal, neither a failure nor a success — the outcome was @@ -198,6 +215,9 @@ export function partitionHistoricScriptEvents(events: ScriptInputEvent[]): Histo function dataCompleteness(item: ScriptItem): number { let score = 0; if (item.exitCode != null) score += 4; // exit code is the most important signal + // A run corrected from IME's saved result lost a foreign exit code, not data: it must not lose + // the merge against a sibling run for that. + if (item.outputCorrection === "foreign") score += 4; if (item.result) score += 2; if (item.complianceResult) score += 2; if (item.remediationStatus != null) score += 1; @@ -224,6 +244,18 @@ export function toNumber(v: unknown): number | undefined { return undefined; } +/** + * The value a correction publishes for `key`, or the emitted one: a present key replaces it (an + * empty string clears it), an absent key keeps it. + */ +function correctedText(correction: ScriptEventData | undefined, key: "stdout" | "stderr", emitted: unknown): string | undefined { + if (correction && typeof correction[key] === "string") { + const value = correction[key] as string; + return value.length > 0 ? value : undefined; + } + return typeof emitted === "string" ? emitted : undefined; +} + /** Map RemediationStatus enum to the human-readable label shown in the detail panel. */ export function mapRemediationStatus(status?: number): string | null { switch (status) { @@ -378,7 +410,14 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { // script_started events per (policyId, scriptType) in timestamp order — the start line a // final is paired with when its run duration is judged (see judgeDurationPair). const startsByPolicy = new Map(); + // IME's saved result corrects a platform run after the fact; the correction names the run. + const correctionsByRunId = new Map(); for (const evt of sorted) { + if (evt.eventType === SCRIPT_OUTPUT_RECONCILIATION) { + const c = evt.data as ScriptEventData | undefined; + if (c && typeof c.runId === "string" && c.runId.length > 0) correctionsByRunId.set(c.runId, c); + continue; + } if (evt.eventType !== "script_started") continue; const d = evt.data as ScriptEventData | undefined; const startPolicyId = d?.policyId ?? d?.policy_id; @@ -402,7 +441,10 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { const key = `${dedupeId}-${scriptType}-${scriptPart ?? ""}`; if (policyId) policyIdsWithFinal.add(`${policyId}-${scriptType}`); - const exitCode = toNumber(d.exitCode ?? d.exit_code); + const correction = typeof d.runId === "string" ? correctionsByRunId.get(d.runId) : undefined; + const outputCorrection = correction ? (correction.outcome === "foreign" ? "foreign" : "repaired") : undefined; + // A foreign end block's exit code belongs to another script: unknown, not the emitted value. + const exitCode = outputCorrection === "foreign" ? undefined : toNumber(d.exitCode ?? d.exit_code); // Duration normalization — the wire field durationSeconds carries two different // semantics, named by durationBasis (see the ScriptItem field docs): @@ -435,8 +477,9 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { const remediationStatus = toNumber(d.remediationStatus ?? d.remediation_status); const targetType = toNumber(d.targetType ?? d.target_type); const errorCode = toNumber(d.errorCode ?? d.error_code); - const stdout = typeof d.stdout === "string" ? d.stdout : undefined; - const stderr = typeof d.stderr === "string" ? d.stderr : undefined; + const stdout = correctedText(correction, "stdout", d.stdout); + const stderr = correctedText(correction, "stderr", d.stderr); + const result = (typeof correction?.result === "string" ? correction.result : undefined) ?? d.result; const hasStderr = !!stderr && stderr.trim().length > 0; // State derivation — rules in priority order (mirrors the agent's EmitScriptCompleted): @@ -450,8 +493,10 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { // 3. Defensive: explicit script_failed eventType OR result === "Failed" → Failed. const isHealthComplianceReport = scriptType === "remediation" && (scriptPart === "detection" || scriptPart === "post-detection"); - const isFailureSignal = evt.eventType === "script_failed" - || d.result === "Failed" + // A corrected run is judged on its corrected data: the agent chose script_failed from the + // emitted values the correction replaced. + const isFailureSignal = (!correction && evt.eventType === "script_failed") + || result === "Failed" || (!isHealthComplianceReport && (hasStderr || (exitCode != null && exitCode !== 0))); const candidate: ScriptItem = { @@ -460,7 +505,7 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { scriptPart, runContext: d.runContext ?? d.run_context, exitCode, - result: d.result, + result, complianceResult: d.complianceResult ?? d.compliance_result, remediationStatus, targetType, @@ -476,6 +521,7 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] { timestamp: evt.timestamp, bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(stdout) : null, isIncomplete: false, + outputCorrection, }; // Keep the most-complete entry, but merge the duration fields across both: the