From 5af695b6cb60df17a2946553ffeedbda0bef5ba1 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 15 Dec 2025 15:26:12 -0800 Subject: [PATCH 1/2] Invoke the endpoints health check in web o11y and render result in toolbar --- .changeset/empty-yaks-follow.md | 5 + .changeset/twenty-parents-type.md | 5 + packages/core/src/runtime.ts | 27 ++- packages/web/src/app/layout-client.tsx | 4 +- .../display-utils/endpoints-health-status.tsx | 221 ++++++++++++++++++ 5 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 .changeset/empty-yaks-follow.md create mode 100644 .changeset/twenty-parents-type.md create mode 100644 packages/web/src/components/display-utils/endpoints-health-status.tsx diff --git a/.changeset/empty-yaks-follow.md b/.changeset/empty-yaks-follow.md new file mode 100644 index 000000000..66711106d --- /dev/null +++ b/.changeset/empty-yaks-follow.md @@ -0,0 +1,5 @@ +--- +"@workflow/web": patch +--- + +Invoke the endpoints health check in web o11y and render result in toolbar diff --git a/.changeset/twenty-parents-type.md b/.changeset/twenty-parents-type.md new file mode 100644 index 000000000..d30babda9 --- /dev/null +++ b/.changeset/twenty-parents-type.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Add CORS headers to endpoints health check response diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index f235bb182..e0825ddb8 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -263,6 +263,16 @@ async function getAllWorkflowRunEvents(runId: string): Promise { return allEvents; } +/** + * CORS headers for health check responses. + * Allows the observability UI to check endpoint health from a different origin. + */ +const HEALTH_CHECK_CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + /** * Wraps a request/response handler and adds a health check "mode" * based on the presence of a `__health` query parameter. @@ -273,10 +283,25 @@ function withHealthCheck( return async (req) => { const url = new URL(req.url); const isHealthCheck = url.searchParams.has('__health'); + if (isHealthCheck) { + // Handle CORS preflight for health check + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: HEALTH_CHECK_CORS_HEADERS, + }); + } + return new Response( `Workflow DevKit "${url.pathname}" endpoint is healthy`, - { status: 200, headers: { 'Content-Type': 'text/plain' } } + { + status: 200, + headers: { + 'Content-Type': 'text/plain', + ...HEALTH_CHECK_CORS_HEADERS, + }, + } ); } return await handler(req); diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index 286923356..b4e4a75d7 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { ThemeProvider, useTheme } from 'next-themes'; import { useEffect, useRef } from 'react'; import { ConnectionStatus } from '@/components/display-utils/connection-status'; +import { EndpointsHealthStatus } from '@/components/display-utils/endpoints-health-status'; import { SettingsDropdown } from '@/components/settings-dropdown'; import { Toaster } from '@/components/ui/sonner'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; @@ -144,7 +145,8 @@ function LayoutContent({ children }: LayoutClientProps) { -
+
+
diff --git a/packages/web/src/components/display-utils/endpoints-health-status.tsx b/packages/web/src/components/display-utils/endpoints-health-status.tsx new file mode 100644 index 000000000..bad42f398 --- /dev/null +++ b/packages/web/src/components/display-utils/endpoints-health-status.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { AlertCircleIcon, CheckCircle2Icon, LoaderIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { WorldConfig } from '@/lib/config-world'; + +interface EndpointsHealthStatusProps { + config: WorldConfig; +} + +interface HealthCheckResult { + flow: 'pending' | 'success' | 'error'; + step: 'pending' | 'success' | 'error'; + flowMessage?: string; + stepMessage?: string; + checkedAt?: string; +} + +const HEALTH_CHECK_SESSION_KEY = 'workflow-endpoints-health-check'; + +function getSessionHealthCheck(configKey: string): HealthCheckResult | null { + try { + const stored = sessionStorage.getItem( + `${HEALTH_CHECK_SESSION_KEY}-${configKey}` + ); + if (stored) { + return JSON.parse(stored); + } + } catch { + // Ignore sessionStorage errors (e.g., in SSR or private browsing) + } + return null; +} + +function setSessionHealthCheck( + configKey: string, + result: HealthCheckResult +): void { + try { + sessionStorage.setItem( + `${HEALTH_CHECK_SESSION_KEY}-${configKey}`, + JSON.stringify(result) + ); + } catch { + // Ignore sessionStorage errors + } +} + +function getConfigKey(config: WorldConfig): string { + // Create a unique key based on relevant config values + return `${config.backend || 'local'}-${config.port || '3000'}`; +} + +async function checkEndpointHealth( + baseUrl: string, + endpoint: 'flow' | 'step' +): Promise<{ success: boolean; message: string }> { + try { + const url = new URL( + `/.well-known/workflow/v1/${endpoint}?__health`, + baseUrl + ); + const response = await fetch(url.toString(), { + method: 'POST', + // Short timeout for health checks + signal: AbortSignal.timeout(5000), + }); + + if (response.ok) { + const text = await response.text(); + return { success: true, message: text }; + } + return { + success: false, + message: `HTTP ${response.status}: ${response.statusText}`, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Connection failed'; + return { success: false, message }; + } +} + +export function EndpointsHealthStatus({ config }: EndpointsHealthStatusProps) { + const [healthCheck, setHealthCheck] = useState({ + flow: 'pending', + step: 'pending', + }); + const [isChecking, setIsChecking] = useState(false); + + useEffect(() => { + const configKey = getConfigKey(config); + const cached = getSessionHealthCheck(configKey); + + // If we have a cached result from this session, use it + if (cached) { + setHealthCheck(cached); + return; + } + + // Otherwise, perform the health check + const performHealthCheck = async () => { + setIsChecking(true); + + // Determine base URL based on config + const port = config.port || '3000'; + const baseUrl = `http://localhost:${port}`; + + const [flowResult, stepResult] = await Promise.all([ + checkEndpointHealth(baseUrl, 'flow'), + checkEndpointHealth(baseUrl, 'step'), + ]); + + const result: HealthCheckResult = { + flow: flowResult.success ? 'success' : 'error', + step: stepResult.success ? 'success' : 'error', + flowMessage: flowResult.message, + stepMessage: stepResult.message, + checkedAt: new Date().toISOString(), + }; + + setHealthCheck(result); + setSessionHealthCheck(configKey, result); + setIsChecking(false); + }; + + performHealthCheck(); + }, [config]); + + const allSuccess = + healthCheck.flow === 'success' && healthCheck.step === 'success'; + const anyError = healthCheck.flow === 'error' || healthCheck.step === 'error'; + const isPending = + healthCheck.flow === 'pending' || healthCheck.step === 'pending'; + + const getStatusIcon = () => { + if (isChecking || isPending) { + return ( + + ); + } + if (allSuccess) { + return ; + } + if (anyError) { + return ; + } + return ; + }; + + const getStatusText = () => { + if (isChecking || isPending) { + return 'Checking endpoints...'; + } + if (allSuccess) { + return 'Endpoints healthy'; + } + if (anyError) { + return 'Endpoint issues'; + } + return 'Unknown status'; + }; + + const getEndpointStatus = (status: 'pending' | 'success' | 'error') => { + if (status === 'success') { + return ; + } + if (status === 'error') { + return ; + } + return ; + }; + + return ( + + +
+ {getStatusIcon()} + {getStatusText()} +
+
+ +
+
Workflow Endpoint Health
+
+
+ {getEndpointStatus(healthCheck.flow)} + /.well-known/workflow/v1/flow +
+ {healthCheck.flowMessage && ( +
+ {healthCheck.flowMessage} +
+ )} +
+
+
+ {getEndpointStatus(healthCheck.step)} + /.well-known/workflow/v1/step +
+ {healthCheck.stepMessage && ( +
+ {healthCheck.stepMessage} +
+ )} +
+ {healthCheck.checkedAt && ( +
+ Checked at {new Date(healthCheck.checkedAt).toLocaleTimeString()} +
+ )} +
+
+
+ ); +} From 61aefad5c3de59b5229e3fa5cf48ca376bb10163 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 23 Dec 2025 14:19:30 +0100 Subject: [PATCH 2/2] Fix changeset --- .changeset/twenty-parents-type.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/twenty-parents-type.md diff --git a/.changeset/twenty-parents-type.md b/.changeset/twenty-parents-type.md new file mode 100644 index 000000000..d30babda9 --- /dev/null +++ b/.changeset/twenty-parents-type.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Add CORS headers to endpoints health check response