Skip to content

Commit 2610639

Browse files
Improvement #18: structured Tier-1 extractor ZIP upload errors.
Map upload Problem Details to semantic error codes with remediation copy, doc links, and Vitest coverage on the extract-upload settings page. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a22b708 commit 2610639

7 files changed

Lines changed: 391 additions & 100 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("@/lib/proxy-fetch-registration-scope", () => ({
5+
mergeRegistrationScopeForProxy: (init: RequestInit) => init,
6+
}));
7+
8+
vi.mock("@/lib/toast", () => ({
9+
showError: vi.fn(),
10+
showSuccess: vi.fn(),
11+
}));
12+
13+
import { ExtractUploadSettingsPageClient } from "./ExtractUploadSettingsPageClient";
14+
15+
describe("ExtractUploadSettingsPageClient", () => {
16+
afterEach(() => {
17+
vi.unstubAllGlobals();
18+
vi.clearAllMocks();
19+
});
20+
21+
it("renders structured upload failure with semantic error code and doc link", async () => {
22+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
23+
const url = String(input);
24+
25+
if (url.includes("workspace-baseline-artifacts") || url.includes("Get-ArchLucidAzurePackage.ps1")) {
26+
return new Response("{}", { status: 404 });
27+
}
28+
29+
if (url.includes("/v1/azure-extractor/upload") && init?.method === "POST") {
30+
return new Response(
31+
JSON.stringify({
32+
type: "https://archlucid.net/problems/validation-failed",
33+
title: "Bad Request",
34+
status: 400,
35+
detail: "Uploaded payload is not a valid ZIP archive.",
36+
errorCode: "VALIDATION_FAILED",
37+
failureKind: "archive",
38+
errors: ["Uploaded payload is not a valid ZIP archive."],
39+
}),
40+
{
41+
status: 400,
42+
headers: {
43+
"Content-Type": "application/json",
44+
"X-Correlation-ID": "corr-upload-1",
45+
},
46+
},
47+
);
48+
}
49+
50+
return new Response("not found", { status: 404 });
51+
});
52+
53+
vi.stubGlobal("fetch", fetchMock);
54+
55+
render(<ExtractUploadSettingsPageClient />);
56+
57+
const fileInput = screen.getByTestId("extract-upload-file-input");
58+
const file = new File(["not-a-zip"], "broken.zip", { type: "application/zip" });
59+
60+
fireEvent.change(fileInput, { target: { files: [file] } });
61+
62+
await waitFor(() => {
63+
expect(screen.getByTestId("extract-upload-failure-callout")).toBeInTheDocument();
64+
});
65+
66+
expect(screen.getByTestId("extract-upload-error-code")).toHaveTextContent("AZURE_EXTRACTOR_INVALID_ZIP_ARCHIVE");
67+
expect(screen.getByTestId("extract-upload-troubleshooting-link")).toHaveAttribute("href", expect.stringContaining("AZURE_EXTRACTOR.md"));
68+
expect(screen.getByText("Invalid ZIP archive")).toBeInTheDocument();
69+
});
70+
});

archlucid-ui/src/app/(operator)/settings/extract-upload/_sections/ExtractUploadSettingsPageClient.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { mergeRegistrationScopeForProxy } from "@/lib/proxy-fetch-registration-s
1111
import { showError, showSuccess } from "@/lib/toast";
1212
import type { ApiProblemDetails } from "@/lib/api-problem";
1313
import { buildApiRequestErrorFromParts } from "@/lib/api-error";
14+
import { parseAzureExtractorUploadFailure } from "@/lib/azure-extractor-upload-failure";
1415
import { ApiV1Routes } from "@/lib/api-v1-routes";
1516

1617
const EXTRACTOR_SCRIPT_CDN_URL =
@@ -102,7 +103,12 @@ export function ExtractUploadSettingsPageClient() {
102103
problem: apiError.problem,
103104
correlationId: apiError.correlationId ?? correlationId,
104105
});
105-
showError("Azure upload", apiError.message);
106+
const presentation = parseAzureExtractorUploadFailure(
107+
apiError.problem,
108+
apiError.message,
109+
apiError.correlationId ?? correlationId,
110+
);
111+
showError("Azure upload", presentation.heading);
106112

107113
return;
108114
}

archlucid-ui/src/components/AzureExtractorUploadFailureCallout.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"use client";
22

3+
import Link from "next/link";
34
import { useCallback, useState } from "react";
45
import { Copy } from "lucide-react";
56

67
import { OperatorErrorCallout } from "@/components/OperatorShellMessage";
78
import { Button } from "@/components/ui/button";
89
import type { ApiProblemDetails } from "@/lib/api-problem";
910
import { parseAzureExtractorUploadFailure } from "@/lib/azure-extractor-upload-failure";
11+
import { toDocsBlobUrl } from "@/lib/contextual-help-content";
1012

1113
type AzureExtractorUploadFailureCalloutProps = {
1214
fallbackMessage: string;
@@ -37,8 +39,27 @@ export function AzureExtractorUploadFailureCallout(props: AzureExtractorUploadFa
3739
}, [copyText]);
3840

3941
return (
40-
<OperatorErrorCallout data-testid="extract-upload-failure-callout">
42+
<div data-testid="extract-upload-failure-callout">
43+
<OperatorErrorCallout>
4144
<strong>{presentation.heading}</strong>
45+
<p className="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
46+
Error code:{" "}
47+
<code
48+
className="rounded bg-neutral-100 px-1 py-0.5 font-mono dark:bg-neutral-800"
49+
data-testid="extract-upload-error-code"
50+
>
51+
{presentation.errorCode}
52+
</code>
53+
{presentation.apiErrorCode ? (
54+
<>
55+
{" "}
56+
· API:{" "}
57+
<code className="rounded bg-neutral-100 px-1 py-0.5 font-mono dark:bg-neutral-800">
58+
{presentation.apiErrorCode}
59+
</code>
60+
</>
61+
) : null}
62+
</p>
4263
<p className="mt-2">{presentation.guidance}</p>
4364
{presentation.errors.length > 0 ? (
4465
<ul className="mt-2 list-inside list-disc text-sm">
@@ -47,6 +68,17 @@ export function AzureExtractorUploadFailureCallout(props: AzureExtractorUploadFa
4768
))}
4869
</ul>
4970
) : null}
71+
<p className="mt-2.5 text-sm">
72+
<Link
73+
href={toDocsBlobUrl(presentation.docPath)}
74+
className="font-medium text-teal-800 underline underline-offset-2 dark:text-teal-300"
75+
target="_blank"
76+
rel="noopener noreferrer"
77+
data-testid="extract-upload-troubleshooting-link"
78+
>
79+
Open troubleshooting guide
80+
</Link>
81+
</p>
5082
{props.correlationId ? (
5183
<p className="mt-2.5 text-xs text-neutral-600 dark:text-neutral-400">
5284
Correlation ID:{" "}
@@ -59,6 +91,7 @@ export function AzureExtractorUploadFailureCallout(props: AzureExtractorUploadFa
5991
{copied ? "Copied" : "Copy error details"}
6092
</Button>
6193
</div>
62-
</OperatorErrorCallout>
94+
</OperatorErrorCallout>
95+
</div>
6396
);
6497
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { ApiProblemDetails } from "@/lib/api-problem";
2+
3+
export type AzureExtractorUploadFailureKind = "schema" | "archive" | "validation" | "unknown";
4+
5+
export const AZURE_EXTRACTOR_UPLOAD_DOC_PATHS = {
6+
primary: "/docs/library/AZURE_EXTRACTOR.md",
7+
ingestRunbook: "/docs/runbooks/AZURE_EXTRACTOR_INGEST.md",
8+
} as const;
9+
10+
export type AzureExtractorUploadSemanticCode =
11+
| "AZURE_EXTRACTOR_UNSUPPORTED_SCHEMA_VERSION"
12+
| "AZURE_EXTRACTOR_MISSING_MANIFEST"
13+
| "AZURE_EXTRACTOR_INVALID_MANIFEST_JSON"
14+
| "AZURE_EXTRACTOR_MISSING_SCHEMA_VERSION"
15+
| "AZURE_EXTRACTOR_MISSING_RESOURCES_JSON"
16+
| "AZURE_EXTRACTOR_INVALID_ZIP_ARCHIVE"
17+
| "AZURE_EXTRACTOR_RUN_SCOPE_MISMATCH"
18+
| "AZURE_EXTRACTOR_ZIP_TOO_LARGE"
19+
| "AZURE_EXTRACTOR_NO_FILE_UPLOADED"
20+
| "AZURE_EXTRACTOR_UPLOAD_UNKNOWN";
21+
22+
export type AzureExtractorUploadErrorResolution = {
23+
semanticCode: AzureExtractorUploadSemanticCode;
24+
failureKind: AzureExtractorUploadFailureKind;
25+
heading: string;
26+
guidance: string;
27+
docPath: string;
28+
};
29+
30+
function readFailureKind(problem: ApiProblemDetails | null): AzureExtractorUploadFailureKind {
31+
const raw = problem?.failureKind;
32+
33+
if (raw === "schema" || raw === "archive" || raw === "validation") {
34+
return raw;
35+
}
36+
37+
const detail = problem?.detail?.toLowerCase() ?? "";
38+
39+
if (detail.includes("schemaversion") || detail.includes("manifest.json")) {
40+
return "schema";
41+
}
42+
43+
if (detail.includes("zip") || detail.includes("archive")) {
44+
return "archive";
45+
}
46+
47+
if (detail.includes("run id") || detail.includes("resources.json") || detail.includes("maximum size")) {
48+
return "validation";
49+
}
50+
51+
return "unknown";
52+
}
53+
54+
function resolveSemanticCode(detail: string, failureKind: AzureExtractorUploadFailureKind): AzureExtractorUploadSemanticCode {
55+
const normalized = detail.toLowerCase();
56+
57+
if (normalized.includes("unsupported manifest schemaversion")) {
58+
return "AZURE_EXTRACTOR_UNSUPPORTED_SCHEMA_VERSION";
59+
}
60+
61+
if (normalized.includes("does not contain manifest.json")) {
62+
return "AZURE_EXTRACTOR_MISSING_MANIFEST";
63+
}
64+
65+
if (normalized.includes("not valid json")) {
66+
return "AZURE_EXTRACTOR_INVALID_MANIFEST_JSON";
67+
}
68+
69+
if (normalized.includes("schemaversion") && normalized.includes("missing")) {
70+
return "AZURE_EXTRACTOR_MISSING_SCHEMA_VERSION";
71+
}
72+
73+
if (normalized.includes("does not contain resources.json")) {
74+
return "AZURE_EXTRACTOR_MISSING_RESOURCES_JSON";
75+
}
76+
77+
if (normalized.includes("not a valid zip archive")) {
78+
return "AZURE_EXTRACTOR_INVALID_ZIP_ARCHIVE";
79+
}
80+
81+
if (normalized.includes("run id is not recognized")) {
82+
return "AZURE_EXTRACTOR_RUN_SCOPE_MISMATCH";
83+
}
84+
85+
if (normalized.includes("exceeds maximum size")) {
86+
return "AZURE_EXTRACTOR_ZIP_TOO_LARGE";
87+
}
88+
89+
if (normalized.includes("no file uploaded")) {
90+
return "AZURE_EXTRACTOR_NO_FILE_UPLOADED";
91+
}
92+
93+
if (failureKind === "schema") {
94+
return "AZURE_EXTRACTOR_MISSING_SCHEMA_VERSION";
95+
}
96+
97+
if (failureKind === "archive") {
98+
return "AZURE_EXTRACTOR_INVALID_ZIP_ARCHIVE";
99+
}
100+
101+
return "AZURE_EXTRACTOR_UPLOAD_UNKNOWN";
102+
}
103+
104+
function guidanceForSemanticCode(code: AzureExtractorUploadSemanticCode): string {
105+
switch (code) {
106+
case "AZURE_EXTRACTOR_UNSUPPORTED_SCHEMA_VERSION":
107+
return "Re-run Get-ArchLucidAzurePackage.ps1 from the current CDN script so manifest.json uses a supported schemaVersion, then upload the new ZIP.";
108+
case "AZURE_EXTRACTOR_MISSING_MANIFEST":
109+
return "The ZIP must contain manifest.json at the archive root. Re-run the extractor script and upload the complete package.";
110+
case "AZURE_EXTRACTOR_INVALID_MANIFEST_JSON":
111+
return "manifest.json is not valid JSON. Re-run the extractor locally and confirm the file opens cleanly before uploading.";
112+
case "AZURE_EXTRACTOR_MISSING_SCHEMA_VERSION":
113+
return "manifest.json must include schemaVersion 1. Download the latest extractor script and regenerate the ZIP.";
114+
case "AZURE_EXTRACTOR_MISSING_RESOURCES_JSON":
115+
return "The ZIP must include resources.json from Get-ArchLucidAzurePackage.ps1. Do not upload a manifest-only archive.";
116+
case "AZURE_EXTRACTOR_INVALID_ZIP_ARCHIVE":
117+
return "Upload a complete .zip produced by Get-ArchLucidAzurePackage.ps1. Partial downloads or renamed folders often fail archive validation.";
118+
case "AZURE_EXTRACTOR_RUN_SCOPE_MISMATCH":
119+
return "The runId query parameter does not match a review in this workspace. Upload without runId or open the correct workspace scope first.";
120+
case "AZURE_EXTRACTOR_ZIP_TOO_LARGE":
121+
return "Reduce extractor scope (subscription or resource group) or use chunked upload when enabled. Confirm the ZIP is within the server size limit.";
122+
case "AZURE_EXTRACTOR_NO_FILE_UPLOADED":
123+
return "Select a .zip file in the upload control. The multipart form field must be named file.";
124+
default:
125+
return "Review the error detail, fix the extractor package, and retry. Include the copied error details when opening a support ticket.";
126+
}
127+
}
128+
129+
function headingForFailureKind(failureKind: AzureExtractorUploadFailureKind): string {
130+
if (failureKind === "schema") {
131+
return "Extractor manifest rejected";
132+
}
133+
134+
if (failureKind === "archive") {
135+
return "Invalid ZIP archive";
136+
}
137+
138+
return "Azure extractor upload failed";
139+
}
140+
141+
function docPathForSemanticCode(code: AzureExtractorUploadSemanticCode): string {
142+
if (
143+
code === "AZURE_EXTRACTOR_RUN_SCOPE_MISMATCH" ||
144+
code === "AZURE_EXTRACTOR_ZIP_TOO_LARGE" ||
145+
code === "AZURE_EXTRACTOR_NO_FILE_UPLOADED"
146+
) {
147+
return AZURE_EXTRACTOR_UPLOAD_DOC_PATHS.ingestRunbook;
148+
}
149+
150+
return AZURE_EXTRACTOR_UPLOAD_DOC_PATHS.primary;
151+
}
152+
153+
/** Maps Problem Details from extractor upload endpoints into semantic codes and remediation copy. */
154+
export function resolveAzureExtractorUploadError(
155+
problem: ApiProblemDetails | null,
156+
fallbackMessage: string,
157+
): AzureExtractorUploadErrorResolution {
158+
const failureKind = readFailureKind(problem);
159+
const detail = problem?.detail?.trim() ?? fallbackMessage.trim();
160+
const semanticCode = resolveSemanticCode(detail, failureKind);
161+
162+
return {
163+
semanticCode,
164+
failureKind,
165+
heading: headingForFailureKind(failureKind),
166+
guidance: guidanceForSemanticCode(semanticCode),
167+
docPath: docPathForSemanticCode(semanticCode),
168+
};
169+
}

0 commit comments

Comments
 (0)