mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Portal, MCP and backend apply script_output_reconciliation: a corrected platform run shows its own output and "exit n/a" (D-316)
Web: the script panel applies the correction to the run its runId names: the saved output replaces the emitted one, a foreign end block's exit code shows as n/a with an explanation, the state follows the corrected data instead of the emitted event type, and the output carries a short "from IME's saved result" note. The timeline marks the original row as corrected later, and the correction event falls under the script-output setting like the other script events. The session page passes the event to the panel; the gather-rule event list knows the type. MCP: get_session_summary no longer counts a script_failed as an error, ranks it as one or attaches the exit code's catalog text when a foreign correction with IME result Success names its run. Backend: the correction never heals a stalled session and never counts as a script run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+52
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<EnrollmentEvent>
|
||||
{
|
||||
Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary<string, object> { ["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<EnrollmentEvent>
|
||||
{
|
||||
Event("script_failed", new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["exitCode"] = "1" }),
|
||||
Event(Constants.EventTypes.ScriptOutputReconciliation, new Dictionary<string, object> { ["scriptType"] = "platform", ["runId"] = "run-1", ["outcome"] = "foreign" }),
|
||||
});
|
||||
|
||||
Assert.True(classification.HasNonPeriodicRealEvent);
|
||||
Assert.Equal(1, classification.PlatformScriptCount);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
Timestamp = At,
|
||||
EventType = eventType,
|
||||
Source = "RegistryScriptResult",
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, unknown>, 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<string, { handler: ToolHandler }> })._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<string, unknown>) => ({
|
||||
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<Record<string, any>> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<EnrollmentEvent>): 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;
|
||||
|
||||
@@ -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<string, unknown> }>,
|
||||
): Set<string> {
|
||||
const runs = new Set<string>();
|
||||
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<string, unknown> | undefined,
|
||||
foreignRuns: ReadonlySet<string>,
|
||||
): 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ReadonlyMap<string, "foreign" | "repaired">>(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<string, "foreign" | "repaired">();
|
||||
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 = (
|
||||
<div className="space-y-6">
|
||||
{/* Search + Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
@@ -356,6 +371,8 @@ export default function EventTimeline({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <CorrectedRunsContext.Provider value={correctedRuns}>{timeline}</CorrectedRunsContext.Provider>;
|
||||
}
|
||||
|
||||
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:
|
||||
<span className="text-sm font-medium text-gray-900">{event.eventType}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600" title={event.message || undefined}>{shortenBuildHashInMessage(event.message)}</p>
|
||||
{laterCorrection && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{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."}
|
||||
</p>
|
||||
)}
|
||||
{/* 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;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
)}
|
||||
{item.exitCode == null && item.outputCorrection === "foreign" && (
|
||||
<span className="font-mono text-gray-400" title={EXIT_UNVERIFIED_TITLE}>exit n/a</span>
|
||||
)}
|
||||
{durationLabel && (
|
||||
<span
|
||||
className={`font-mono ${isSlowDuration ? "text-amber-600 font-medium" : "text-gray-500"}`}
|
||||
@@ -564,6 +575,9 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
{!nested && item.runContext && <span><span className="font-medium text-gray-700">Context:</span> {item.runContext}</span>}
|
||||
{!nested && 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.exitCode == null && item.outputCorrection === "foreign" && (
|
||||
<span title={EXIT_UNVERIFIED_TITLE}><span className="font-medium text-gray-700">Exit Code:</span> <span className="font-mono text-gray-400">n/a</span></span>
|
||||
)}
|
||||
{detailDurationLabel && <span><span className="font-medium text-gray-700">Duration:</span> <span className={`font-mono ${isSlowDuration ? "text-amber-600" : ""}`}>{detailDurationLabel}</span></span>}
|
||||
{!detailDurationLabel && item.durationSuppressedReason && (
|
||||
<span title={DURATION_SUPPRESSED_TITLE}><span className="font-medium text-gray-700">Duration:</span> <span className="font-mono text-gray-400">n/a</span></span>
|
||||
@@ -601,7 +615,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
|
||||
{hasStdout && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">stdout</div>
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">
|
||||
stdout
|
||||
{item.outputCorrection && (
|
||||
<span className="font-normal text-gray-400" title={OUTPUT_CORRECTED_TITLE[item.outputCorrection]}>{" · from IME's saved result"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap break-words">{item.stdout}</pre>
|
||||
</div>
|
||||
@@ -614,7 +633,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
|
||||
{hasStderr && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-red-500 mb-1">stderr</div>
|
||||
<div className="text-xs font-medium text-red-500 mb-1">
|
||||
stderr
|
||||
{item.outputCorrection && (
|
||||
<span className="font-normal text-gray-400" title={OUTPUT_CORRECTED_TITLE[item.outputCorrection]}>{" · from IME's saved result"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 bg-gray-900 rounded text-xs text-red-300 font-mono overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap break-words">{item.stderr}</pre>
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, ScriptInputEvent[]>();
|
||||
// IME's saved result corrects a platform run after the fact; the correction names the run.
|
||||
const correctionsByRunId = new Map<string, ScriptEventData>();
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user