diff --git a/src/models/componentSpec/validation/types.ts b/src/models/componentSpec/validation/types.ts index 617aa892c..013ec039f 100644 --- a/src/models/componentSpec/validation/types.ts +++ b/src/models/componentSpec/validation/types.ts @@ -8,6 +8,7 @@ export type ValidationIssueCode = | "MISSING_REQUIRED_INPUT" | "EMPTY_TASK_NAME" | "MISSING_COMPONENT_REF" + | "COMPONENT_HYDRATION_FAILED" | "BAD_INPUT_REFERENCE" | "BAD_TASK_REFERENCE" | "BAD_OUTPUT_REFERENCE" diff --git a/src/models/componentSpec/validation/validateSpec.ts b/src/models/componentSpec/validation/validateSpec.ts index ad10f7c43..b51b7f79c 100644 --- a/src/models/componentSpec/validation/validateSpec.ts +++ b/src/models/componentSpec/validation/validateSpec.ts @@ -213,6 +213,16 @@ function validateSingleTask( severity: "error", issueCode: "MISSING_COMPONENT_REF", }); + } else if (!task.subgraphSpec && !task.resolvedComponentSpec) { + // The ref points at a loadable component (url/digest/text) but hydration + // never populated its spec, so the component could not be resolved. + issues.push({ + type: "task", + message: "Failed to load component", + entityId: task.$id, + severity: "error", + issueCode: "COMPONENT_HYDRATION_FAILED", + }); } issues.push(...validateTaskArguments(task, spec)); diff --git a/src/routes/v2/pages/Editor/components/IssueResolution/ValidationIssueResolutionCard.tsx b/src/routes/v2/pages/Editor/components/IssueResolution/ValidationIssueResolutionCard.tsx index 2f0b6ba3a..ff9a38952 100644 --- a/src/routes/v2/pages/Editor/components/IssueResolution/ValidationIssueResolutionCard.tsx +++ b/src/routes/v2/pages/Editor/components/IssueResolution/ValidationIssueResolutionCard.tsx @@ -171,6 +171,10 @@ const RESOLUTION_MAP: Record = { DUPLICATE_OUTPUT_NAME: (p) => renderDuplicateNameResolution("output", p), MISSING_COMPONENT_REF: (p) => renderDeleteResolution("Delete Task", "task", p), + COMPONENT_HYDRATION_FAILED: () => + renderInfoResolution( + "The component could not be loaded from its source. Check the component URL or your connection, then reload the pipeline.", + ), BAD_INPUT_REFERENCE: renderBadRefResolution, BAD_TASK_REFERENCE: renderBadRefResolution, BAD_OUTPUT_REFERENCE: renderBadRefResolution, diff --git a/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts b/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts index e62093d11..ab2ceb642 100644 --- a/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts +++ b/src/routes/v2/pages/Editor/utils/__tests__/hydrateSpecRefs.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ComponentSpec } from "@/models/componentSpec/entities/componentSpec"; import { Task } from "@/models/componentSpec/entities/task"; import { IncrementingIdGenerator } from "@/models/componentSpec/factories/idGenerator"; +import { validateSpec } from "@/models/componentSpec/validation/validateSpec"; import { hydrateLoadedSpecRefs } from "@/routes/v2/pages/Editor/utils/hydrateSpecRefs"; import { hydrateComponentReference } from "@/services/componentService"; import type { @@ -66,6 +67,31 @@ describe("hydrateLoadedSpecRefs", () => { expect(task.resolvedComponentSpec?.name).toBe("Foo"); }); + it("leaves the ref untouched and surfaces a validation issue on failure", async () => { + const task = new Task({ + $id: idGen.next("task"), + name: "Process", + componentRef: { url: "https://example.com/comp.yaml", digest: "abc" }, + }); + const spec = specWithTask(task); + + mockHydrate.mockResolvedValue(null); + + await hydrateLoadedSpecRefs(spec); + + expect(task.componentRef.url).toBe("https://example.com/comp.yaml"); + expect(task.resolvedComponentSpec).toBeUndefined(); + + const issues = validateSpec(spec); + expect( + issues.some( + (issue) => + issue.entityId === task.$id && + issue.issueCode === "COMPONENT_HYDRATION_FAILED", + ), + ).toBe(true); + }); + it("recurses into subgraph tasks without re-hydrating the subgraph ref", async () => { const innerTask = new Task({ $id: idGen.next("task"),