From 3a5588569624b4bb73453626205bdf81de0e50a9 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Sun, 25 Oct 2026 01:01:58 +0530 Subject: [PATCH 1/2] Test workflow deploy engine readiness --- .../lib/workflow/__tests__/multimodal.test.ts | 3 +- .../lib/workflow/__tests__/readiness.test.ts | 55 +++++++++++++------ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/dashboard/src/lib/workflow/__tests__/multimodal.test.ts b/apps/dashboard/src/lib/workflow/__tests__/multimodal.test.ts index bdefde37..3017b62a 100644 --- a/apps/dashboard/src/lib/workflow/__tests__/multimodal.test.ts +++ b/apps/dashboard/src/lib/workflow/__tests__/multimodal.test.ts @@ -130,7 +130,8 @@ describe("vision model choice", () => { it("recommends the catalog models when nothing matches", () => { expect(nodeReadiness({ kind: "vision", config: {} }, [dep("qwen2-5-1-5b", "inference")])).toEqual({ state: "needsDeployment", recommended: MODEL_CATALOG.vision }); expect(MODEL_CATALOG.vision).toMatchObject({ modelId: "moondream", engine: "ollama", modelType: "inference", deploymentName: "moondream-vision" }); - expect(MODEL_CATALOG.imageGeneration).toMatchObject({ modelId: "stabilityai/sd-turbo", engine: "generic", modelType: "image_generation", deploymentName: "sd-turbo-image" }); + expect(MODEL_CATALOG.imageGeneration).toMatchObject({ modelId: "stabilityai/sd-turbo", engine: "inferia-diffusion", modelType: "image_generation", deploymentName: "sd-turbo-image" }); + expect(MODEL_CATALOG.imageGeneration!.job).toMatchObject({ image: "docker.io/inferiaai/inferiadiffusion:latest", gpu: true }); }); }); diff --git a/apps/dashboard/src/lib/workflow/__tests__/readiness.test.ts b/apps/dashboard/src/lib/workflow/__tests__/readiness.test.ts index e8cb41d5..d5eb3a8e 100644 --- a/apps/dashboard/src/lib/workflow/__tests__/readiness.test.ts +++ b/apps/dashboard/src/lib/workflow/__tests__/readiness.test.ts @@ -8,7 +8,8 @@ function dep(modelName: string, model_type: string): Deployment { } const stt = { kind: "speechToText" as const, config: {} }; -const whisperCatalog = MODEL_CATALOG.speechToText!; +const llm = { kind: "llm" as const, config: {} }; +const qwenCatalog = MODEL_CATALOG.llm!; describe("nodeReadiness", () => { it("treats nodes without a model field, or with a model set, as ready", () => { @@ -22,29 +23,47 @@ describe("nodeReadiness", () => { }); it("needs deployment when only non-matching deployments exist", () => { - expect(nodeReadiness(stt, [dep("kokoro-tts", "audio_generation"), dep("bank-llm", "inference")])).toEqual({ + expect(nodeReadiness(llm, [dep("whisper-stt", "audio_generation")])).toEqual({ state: "needsDeployment", - recommended: whisperCatalog, + recommended: qwenCatalog, }); - expect(nodeReadiness({ kind: "textToSpeech", config: {} }, [])).toEqual({ - state: "needsDeployment", - recommended: MODEL_CATALOG.textToSpeech, + }); + + it("asks for an external deployment for speech, which no pool engine can run", () => { + expect(nodeReadiness(stt, [dep("kokoro-tts", "audio_generation"), dep("bank-llm", "inference")])).toEqual({ + state: "needsExternal", }); + expect(nodeReadiness({ kind: "textToSpeech", config: {} }, [])).toEqual({ state: "needsExternal" }); }); - it("follows the pending deploy through deploying, waiting and deployed", () => { - expect(nodeReadiness(stt, [], { status: "deploying", deploymentName: "whisper-tiny-stt" })).toEqual({ + it("follows the pending deploy through deploying, waiting, failed and deployed", () => { + expect(nodeReadiness(llm, [], { status: "deploying", deploymentName: "qwen2-5-1-5b" })).toEqual({ state: "deploying", - recommended: whisperCatalog, + recommended: qwenCatalog, }); - expect(nodeReadiness(stt, [], { status: "waiting", reason: "no active compute pool" })).toEqual({ + expect(nodeReadiness(llm, [], { status: "waiting", reason: "no active compute pool" })).toEqual({ state: "waiting", - recommended: whisperCatalog, + recommended: qwenCatalog, reason: "no active compute pool", }); + expect(nodeReadiness(llm, [], { status: "failed", reason: "image pull failed" })).toEqual({ + state: "failed", + recommended: qwenCatalog, + reason: "image pull failed", + }); expect( - nodeReadiness(stt, [dep("whisper-tiny-stt", "audio_generation")], { status: "deploying", deploymentName: "whisper-tiny-stt" }), - ).toEqual({ state: "deployed", model: "whisper-tiny-stt" }); + nodeReadiness(llm, [dep("qwen2-5-1-5b", "inference")], { status: "deploying", deploymentName: "qwen2-5-1-5b" }), + ).toEqual({ state: "deployed", model: "qwen2-5-1-5b" }); + }); +}); + +describe("MODEL_CATALOG", () => { + it("only recommends engines that worker pools can run", () => { + const poolEngines = ["vllm", "vllm-omni", "ollama", "infinity", "triton", "inferia-diffusion"]; + for (const entry of Object.values(MODEL_CATALOG)) { + expect(poolEngines).toContain(entry!.engine); + expect(entry!.job.engine).toBe(entry!.engine); + } }); }); @@ -63,9 +82,12 @@ describe("runBlocker", () => { }); it("names the first blocked node and its reason", () => { - expect(runBlocker(wf([{}, { model: "l" }]), [], {})).toBe("Speech to text: Needs deployment"); - expect(runBlocker(wf([{}, { model: "l" }]), [], { s: { status: "waiting", reason: "no active compute pool" } })).toBe( - "Speech to text: Waiting for compute: no active compute pool", + expect(runBlocker(wf([{}, { model: "l" }]), [], {})).toBe("Speech to text: Needs an external deployment"); + expect(runBlocker(wf([{ model: "w" }, {}]), [], { l: { status: "waiting", reason: "no active compute pool" } })).toBe( + "LLM: Waiting for compute: no active compute pool", + ); + expect(runBlocker(wf([{ model: "w" }, {}]), [], { l: { status: "failed", reason: "image pull failed" } })).toBe( + "LLM: Deploy failed: image pull failed", ); expect(runBlocker(wf([{ model: "w" }, {}]), [], { l: { status: "deploying", deploymentName: "qwen2-5-1-5b" } })).toBe( "LLM: Deploying qwen2-5-1-5b", @@ -97,6 +119,5 @@ describe("deployRequests", () => { owner_id: "user-1", org_id: "org-1", }); - expect(deployRequests(whisperCatalog, "p", { orgId: "o" }).deploy.gpu_per_replica).toBe(0); }); }); From d8d201aa7d05cd8d28698612a2b82761e8204999 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Sun, 25 Oct 2026 01:07:43 +0530 Subject: [PATCH 2/2] Fix workflow deploy engine choices --- .../components/workflow/WorkflowNodeCard.tsx | 20 ++++++++- .../src/components/workflow/useNodeDeploys.ts | 29 +++++++++++- apps/dashboard/src/lib/workflow/readiness.ts | 45 ++++++++----------- 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/apps/dashboard/src/components/workflow/WorkflowNodeCard.tsx b/apps/dashboard/src/components/workflow/WorkflowNodeCard.tsx index 0805b46c..e17f5ed5 100644 --- a/apps/dashboard/src/components/workflow/WorkflowNodeCard.tsx +++ b/apps/dashboard/src/components/workflow/WorkflowNodeCard.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { Link } from "react-router-dom"; import { CheckCircle2, CloudUpload, Loader2, Mic, Square, Upload, X, XCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import { NODE_KINDS, type ConfigField } from "@/lib/workflow/nodeKinds"; @@ -90,7 +91,21 @@ function Elapsed({ run }: { run: NodeRun }) { function ReadinessBanner({ nodeId, readiness }: { nodeId: string; readiness: Readiness }) { const { deploy } = useCanvas(); if (readiness.state === "ready" || readiness.state === "selectModel" || readiness.state === "deployed") return null; + if (readiness.state === "needsExternal") { + return ( +
+ Needs an external deployment +

+ Compute pools can't run this model type yet.{" "} + + Add an external endpoint + +

+
+ ); + } const { recommended } = readiness; + const retry = readiness.state === "waiting" || readiness.state === "failed"; return (
@@ -98,6 +113,7 @@ function ReadinessBanner({ nodeId, readiness }: { nodeId: string; readiness: Rea {readiness.state === "needsDeployment" && "Needs deployment"} {readiness.state === "deploying" && "Deploying..."} {readiness.state === "waiting" && "Waiting for compute"} + {readiness.state === "failed" && "Deploy failed"} {readiness.state === "deploying" ? ( @@ -108,14 +124,14 @@ function ReadinessBanner({ nodeId, readiness }: { nodeId: string; readiness: Rea className="nodrag flex items-center gap-1 rounded-md bg-ember-600 px-2 py-0.5 font-medium text-white hover:bg-ember-700" > - {readiness.state === "waiting" ? "Retry" : "Deploy"} + {retry ? "Retry" : "Deploy"} )}
Recommended {recommended.modelId} on {recommended.engine}
- {readiness.state === "waiting" &&

{readiness.reason}

} + {retry &&

{readiness.reason}

}
); } diff --git a/apps/dashboard/src/components/workflow/useNodeDeploys.ts b/apps/dashboard/src/components/workflow/useNodeDeploys.ts index 7448ac0a..9bd4d730 100644 --- a/apps/dashboard/src/components/workflow/useNodeDeploys.ts +++ b/apps/dashboard/src/components/workflow/useNodeDeploys.ts @@ -1,9 +1,12 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAuth } from "@/context/AuthContext"; import { computeApi } from "@/lib/api"; import { deployRequests, type CatalogModel, type PendingDeploy } from "@/lib/workflow/readiness"; import { listPools } from "@/services/poolService"; +// A deploy that ends in one of these never comes up, so the node stops waiting on it. +const DEAD_STATES = new Set(["FAILED", "TERMINATED", "STOPPED"]); + interface PreflightResponse { ready: boolean; checks: Array<{ passed: boolean; message?: string | null; check: string }>; @@ -43,12 +46,34 @@ export function useNodeDeploys() { const failed = data.checks.filter((c) => !c.passed).map((c) => c.message || c.check); return set(nodeId, { status: "waiting", reason: failed.join("; ") || "preflight failed" }); } - await computeApi.post("/deployment/deploy", req.deploy); + const { data: created } = await computeApi.post<{ deployment_id?: string }>("/deployment/deploy", req.deploy); + set(nodeId, { status: "deploying", deploymentName: entry.deploymentName, deploymentId: created?.deployment_id }); } catch (err) { set(nodeId, { status: "waiting", reason: errorMessage(err) }); } }; + // The ready-deployments list never shows a failed deploy, so poll each one we started. + useEffect(() => { + const watched = Object.entries(pending).flatMap(([nodeId, p]) => + p.status === "deploying" && p.deploymentId ? [[nodeId, p.deploymentId] as const] : [], + ); + if (watched.length === 0) return; + const timer = setInterval(() => { + for (const [nodeId, id] of watched) { + computeApi + .get<{ state?: string; error_message?: string | null }>(`/deployment/status/${id}`) + .then(({ data }) => { + if (data.state && DEAD_STATES.has(data.state)) { + set(nodeId, { status: "failed", reason: data.error_message || `deployment ${data.state.toLowerCase()}` }); + } + }) + .catch(() => {}); + } + }, 5000); + return () => clearInterval(timer); + }, [pending, set]); + const clear = useCallback((nodeId: string) => set(nodeId, null), [set]); const reset = useCallback(() => setPending({}), []); return { pending, deploy, clear, reset }; diff --git a/apps/dashboard/src/lib/workflow/readiness.ts b/apps/dashboard/src/lib/workflow/readiness.ts index 8e335d9a..db603699 100644 --- a/apps/dashboard/src/lib/workflow/readiness.ts +++ b/apps/dashboard/src/lib/workflow/readiness.ts @@ -1,6 +1,7 @@ import type { Deployment } from "@/hooks/useReadyDeployments"; import { NODE_KINDS, matchingDeployments } from "./nodeKinds"; import type { NodeKind, Workflow, WorkflowNode } from "./types"; +import { buildDiffusionSpec } from "@/pages/newDeploymentSpec"; export interface CatalogModel { modelId: string; @@ -20,30 +21,9 @@ const ollama = (modelId: string) => ({ gpu: true, }); -const speaches = (modelId: string) => ({ - model_id: modelId, - engine: "speaches", - image: "ghcr.io/speaches-ai/speaches:latest-cpu", - env: { PRELOAD_MODELS: JSON.stringify([modelId]) }, - expose: [{ port: 8000, type: "http", health_checks: [{ path: "/health", type: "http", method: "GET", expected_status: 200 }] }], - gpu: false, -}); - +// Only engines that worker pools have a recipe for. Speech has none yet, so +// speech nodes ask for an external deployment instead of offering Deploy. export const MODEL_CATALOG: Partial> = { - speechToText: { - modelId: "Systran/faster-whisper-tiny.en", - engine: "speaches", - modelType: "audio_generation", - deploymentName: "whisper-tiny-stt", - job: speaches("Systran/faster-whisper-tiny.en"), - }, - textToSpeech: { - modelId: "speaches-ai/piper-en_US-amy-medium", - engine: "speaches", - modelType: "audio_generation", - deploymentName: "piper-amy-tts", - job: speaches("speaches-ai/piper-en_US-amy-medium"), - }, llm: { modelId: "qwen2.5:1.5b", engine: "ollama", @@ -60,22 +40,27 @@ export const MODEL_CATALOG: Partial> = { }, imageGeneration: { modelId: "stabilityai/sd-turbo", - engine: "generic", + engine: "inferia-diffusion", modelType: "image_generation", deploymentName: "sd-turbo-image", - job: { model_id: "stabilityai/sd-turbo", engine: "generic", expose: [{ port: 8000, type: "http" }], gpu: true }, + job: JSON.parse(buildDiffusionSpec({ modelId: "stabilityai/sd-turbo", modelType: "image_generation" })), }, }; // Deploy attempt the canvas started for a node; lives in UI state, never in the saved workflow. -export type PendingDeploy = { status: "deploying"; deploymentName: string } | { status: "waiting"; reason: string }; +export type PendingDeploy = + | { status: "deploying"; deploymentName: string; deploymentId?: string } + | { status: "waiting"; reason: string } + | { status: "failed"; reason: string }; export type Readiness = | { state: "ready" } | { state: "selectModel" } + | { state: "needsExternal" } | { state: "needsDeployment"; recommended: CatalogModel } | { state: "deploying"; recommended: CatalogModel } | { state: "waiting"; recommended: CatalogModel; reason: string } + | { state: "failed"; recommended: CatalogModel; reason: string } // The node's own deployment came up; the canvas selects it and the node becomes ready. | { state: "deployed"; model: string }; @@ -90,9 +75,11 @@ export function nodeReadiness( if (pending?.status === "deploying" && deployments.some((d) => d.modelName === pending.deploymentName)) { return { state: "deployed", model: pending.deploymentName }; } - if (matchingDeployments(field, deployments).length > 0 || !recommended) return { state: "selectModel" }; + if (matchingDeployments(field, deployments).length > 0) return { state: "selectModel" }; + if (!recommended) return { state: "needsExternal" }; if (pending?.status === "deploying") return { state: "deploying", recommended }; if (pending?.status === "waiting") return { state: "waiting", recommended, reason: pending.reason }; + if (pending?.status === "failed") return { state: "failed", recommended, reason: pending.reason }; return { state: "needsDeployment", recommended }; } @@ -103,12 +90,16 @@ export function readinessMessage(r: Readiness): string | null { return null; case "selectModel": return "Select a model"; + case "needsExternal": + return "Needs an external deployment"; case "needsDeployment": return "Needs deployment"; case "deploying": return `Deploying ${r.recommended.deploymentName}`; case "waiting": return `Waiting for compute: ${r.reason}`; + case "failed": + return `Deploy failed: ${r.reason}`; } }