feat(web): prefer backend-enriched errorCodeInfo, fall back to local catalog

Adds getEnrichedOrLookup() helper that returns the backend-provided
{description, confidence, source} sibling when present (set by the
backend ErrorCodeEnricher on event responses), otherwise looks up via
the local catalog. EventTimeline event badges now read the enriched
field first — older API responses without enrichment fall through to
the existing local-catalog path so there is no regression.

InstallProgress + apps/[appName] pages consume UI-model rows (not raw
event.Data) and are not touched — they continue to use the local
catalog which already covers their inputs. 5 new helper tests; 409/409
web tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-28 12:33:34 +02:00
parent 51d036ece2
commit 845fd862a1
3 changed files with 64 additions and 4 deletions
@@ -3,7 +3,7 @@
import { useState, useMemo } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap";
import { getErrorCodeEntry, getEnrichedOrLookup, formatErrorCode } from "@/utils/errorCodeMap";
interface EventTimelineProps {
filteredEvents: EnrollmentEvent[];
@@ -447,8 +447,10 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
const hr = event.data?.hresultFromWin32 ?? event.data?.hresult_from_win32;
const hasNonZero = (ec && String(ec) !== "0") || (hr && String(hr) !== "0");
if (!hasNonZero) return null;
const ecEntry = ec ? getErrorCodeEntry(String(ec)) : null;
const hrEntry = hr ? getErrorCodeEntry(String(hr)) : null;
// Prefer backend-enriched *Info sibling, fall back to local lookup for older
// responses that pre-date the backend ErrorCodeEnricher.
const ecEntry = ec ? getEnrichedOrLookup(event.data?.exitCodeInfo, String(ec)) : null;
const hrEntry = hr ? getEnrichedOrLookup(event.data?.hresultFromWin32Info, String(hr)) : null;
return (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
{ec && String(ec) !== "0" && (
@@ -489,7 +491,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
const isAdvisory = event.eventType === "esp_failure_advisory";
if (!code && !isAdvisory) return null;
const codeStr = code ? String(code) : null;
const entry = codeStr ? getErrorCodeEntry(codeStr) : null;
const entry = codeStr ? getEnrichedOrLookup(event.data?.errorCodeInfo, codeStr) : null;
// Advisory path uses warning-color palette (amber); the device continued past the
// failure via ContinueAnyway, so this is not a hard error. PR1 Session 4fa5a2d4.
const badgeBg = isAdvisory ? "bg-amber-100 text-amber-800" : "bg-red-100 text-red-800";
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
getErrorCodeDescription,
getErrorCodeEntry,
getEnrichedOrLookup,
formatErrorCode,
} from "../errorCodeMap";
import catalogFile from "../error-codes.json";
@@ -67,6 +68,43 @@ describe("errorCodeMap", () => {
});
});
describe("getEnrichedOrLookup", () => {
it("prefers backend-enriched sibling when present", () => {
const enriched = {
description: "Backend-provided description",
confidence: "high" as const,
source: "Backend Catalog v2",
};
const result = getEnrichedOrLookup(enriched, "0x80070005");
expect(result).toBe(enriched);
expect(result?.description).toBe("Backend-provided description");
});
it("falls back to local catalog when info is null", () => {
const result = getEnrichedOrLookup(null, "0x80070005");
expect(result).not.toBeNull();
expect(result?.description).toContain("Access denied");
});
it("falls back to local catalog when info is undefined", () => {
const result = getEnrichedOrLookup(undefined, "0x80070005");
expect(result).not.toBeNull();
expect(result?.description).toContain("Access denied");
});
it("returns null when both info and lookup miss", () => {
const result = getEnrichedOrLookup(null, "0xDEADBEEF");
expect(result).toBeNull();
});
it("ignores malformed info objects and falls back to lookup", () => {
// Backend would never emit this but guard against bad clients.
const malformed = { description: 42 } as unknown as { description: string; confidence: "high"; source: string };
const result = getEnrichedOrLookup(malformed, "0x80070005");
expect(result?.description).toContain("Access denied");
});
});
describe("formatErrorCode", () => {
it("uppercases hex codes", () => {
expect(formatErrorCode("0x80070005")).toBe("0X80070005");
@@ -95,6 +95,26 @@ export function getErrorCodeEntry(code: string | number | null | undefined): Err
return lookupEntry(code);
}
/**
* Backend-enriched info entry. The backend ErrorCodeEnricher injects sibling
* <code>{codeKey}Info</code> properties into event Data containing the same shape as
* <see cref="ErrorCodeEntry"/>. When present we prefer the server-side value (uses the
* authoritative catalog version + Confidence-level), and only fall back to the local
* lookup for older responses that don't carry the enrichment yet.
*
* Example: an event with <code>{ errorCode: "0x80070005", errorCodeInfo: {...} }</code>
* passes the info through without a second lookup.
*/
export function getEnrichedOrLookup(
info: ErrorCodeEntry | null | undefined,
code: string | number | null | undefined
): ErrorCodeEntry | null {
if (info && typeof info === "object" && typeof info.description === "string") {
return info;
}
return lookupEntry(code);
}
/**
* Format a raw numeric error code for display.
* Signed-decimal HRESULTs are converted to hex notation.