import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { Archive, CheckCircle2, CopyPlus, Download, GitFork, ListChecks, Plus, RotateCcw, Save, Settings2, Trash2, Upload } from "lucide-react"; import { ReactFlowProvider } from "@xyflow/react"; import { useSearchParams } from "react-router"; import { ActionToolbar, Button, ConfirmDialog, ContentGrid, ContentSection, Dialog, DocumentationHelpLink, DismissibleAlert, DefinitionPalette, DefinitionPaletteGroup, DefinitionPaletteItem, FilterBar, FloatingStatus, FormField, IconButton, LoadingFrame, ReferenceSelect, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, ToggleSwitch, WorkspaceActionBar, WorkspaceFrame, WorkspaceLayout, hasScope, isApiError, useUnsavedChanges, useUnsavedDraftGuard, useEffectiveView, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { activateWorkflowDefinition, archiveWorkflowDefinition, compareWorkflowDefinitionToStandard, compileWorkflowBpmn, createWorkflowDefinition, deleteWorkflowDefinition, deriveWorkflowDefinition, listWorkflowDefinitions, listWorkflowNodeTypes, listWorkflowRevisions, reconcileWorkflowStandards, renderWorkflowBpmn, resetWorkflowDefinitionToStandard, updateWorkflowDefinition, validateWorkflowDefinition, workflowScopeReferenceProvider, type WorkflowDefinition, type WorkflowDiagnostic, type WorkflowGraphEdge, type WorkflowNodeType, type WorkflowRevision, type WorkflowStandardDiff } from "../../api/workflow"; import WorkflowCanvas, { updateWorkflowGraphNode } from "./WorkflowCanvas"; import WorkflowInspector from "./WorkflowInspector"; import WorkflowRunsDialog from "./WorkflowRunsDialog"; import { FALLBACK_WORKFLOW_LIBRARY, draftFromDefinition, newWorkflowNode, sampleWorkflowDraft, workflowFingerprint, workflowPayload, type WorkflowDraft } from "./model"; const CATEGORY_ORDER = [ "bpmn_event", "bpmn_activity", "bpmn_gateway", "bpmn_data", "bpmn_collaboration", "bpmn_artifact" ]; export default function WorkflowPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo; }) { const { requestDiscard, requestNavigation } = useUnsavedChanges(); const [searchParams, setSearchParams] = useSearchParams(); const requestedDefinitionId = searchParams.get("definition"); const requestedRunId = searchParams.get("run"); const [definitions, setDefinitions] = useState([]); const [draft, setDraft] = useState(null); const [savedDraft, setSavedDraft] = useState(null); const [revisions, setRevisions] = useState([]); const [historicalRevision, setHistoricalRevision] = useState(null); const [nodeLibrary, setNodeLibrary] = useState( FALLBACK_WORKFLOW_LIBRARY ); const [allowsCycles, setAllowsCycles] = useState(true); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); const [working, setWorking] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [diagnostics, setDiagnostics] = useState([]); const [deleteOpen, setDeleteOpen] = useState(false); const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false); const [deriveOpen, setDeriveOpen] = useState(false); const [runsOpen, setRunsOpen] = useState(false); const [resetOpen, setResetOpen] = useState(false); const [compareOpen, setCompareOpen] = useState(false); const bpmnFileInputRef = useRef(null); const canWrite = hasScope(auth, "workflow:definition:write") || hasScope(auth, "workflow:instance:admin"); const canEdit = canWrite && ( !draft?.id || draft.governance?.actions.edit?.allowed !== false ); const canActivate = Boolean( canWrite && (draft?.standard?.kind === "baseline" || canEdit) ); const canReuse = Boolean( draft?.id && canWrite && draft.governance?.actions.derive?.allowed ); const canStart = Boolean( draft?.id && (hasScope(auth, "workflow:instance:start") || hasScope(auth, "workflow:instance:admin")) && draft.governance?.actions.start?.allowed !== false ); const canTransition = hasScope(auth, "workflow:instance:transition") || hasScope(auth, "workflow:instance:admin"); const selectedDefinition = useMemo( () => definitions.find((item) => item.id === draft?.id) ?? null, [definitions, draft?.id] ); const dirty = Boolean(draft) && workflowFingerprint(draft) !== workflowFingerprint(savedDraft); const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null; const readOnly = !canEdit || historicalRevision !== null; const graphReadOnly = readOnly; const selectedNode = useMemo( () => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null, [displayedGraph, selectedNodeId] ); const selectedEdge = useMemo( () => displayedGraph?.edges.find((edge) => edge.id === selectedEdgeId) ?? null, [displayedGraph, selectedEdgeId] ); const visibleDefinitions = useMemo(() => { const query = search.trim().toLocaleLowerCase(); if (!query) return definitions; return definitions.filter((definition) => `${definition.name} ${definition.key} ${definition.description ?? ""} ${definition.status}` .toLocaleLowerCase() .includes(query) ); }, [definitions, search]); const paletteGroups = useMemo( () => CATEGORY_ORDER.map((category) => ({ category, label: nodeLibrary.find((item) => item.category === category) ?.category_label ?? category, nodes: nodeLibrary.filter((item) => item.category === category) })).filter((group) => group.nodes.length), [nodeLibrary] ); const applyDefinition = useCallback((definition: WorkflowDefinition) => { const next = draftFromDefinition(definition); setDraft(next); setSavedDraft(structuredClone(next)); setHistoricalRevision(null); setSelectedNodeId(next.graph.nodes[0]?.id ?? null); setSelectedEdgeId(null); setDiagnostics([]); }, []); const reload = useCallback(async (preferredId?: string | null) => { setLoading(true); setError(""); try { const items = await listWorkflowDefinitions(settings); setDefinitions(items); const selected = items.find((item) => item.id === preferredId) ?? items[0]; if (selected) { applyDefinition(selected); } else { setDraft(null); setSavedDraft(null); setRevisions([]); setHistoricalRevision(null); setSelectedNodeId(null); } } catch (loadError) { setError(apiErrorMessage(loadError)); } finally { setLoading(false); } }, [applyDefinition, settings]); useEffect(() => { void reload(requestedDefinitionId); let cancelled = false; void listWorkflowNodeTypes(settings) .then((library) => { if (cancelled) return; if (library.nodes.length) setNodeLibrary(library.nodes); setAllowsCycles(library.allows_cycles); }) .catch(() => { if (!cancelled) setNodeLibrary(FALLBACK_WORKFLOW_LIBRARY); }); return () => { cancelled = true; }; }, [reload, requestedDefinitionId, settings]); useEffect(() => { if ( requestedRunId && draft?.id && (!requestedDefinitionId || draft.id === requestedDefinitionId) ) { setRunsOpen(true); } }, [draft?.id, requestedDefinitionId, requestedRunId]); useEffect(() => { if (!draft?.id) { setRevisions([]); return; } let cancelled = false; void listWorkflowRevisions(settings, draft.id) .then((items) => { if (!cancelled) setRevisions(items); }) .catch((loadError) => { if (!cancelled) setError(apiErrorMessage(loadError)); }); return () => { cancelled = true; }; }, [draft?.id, draft?.currentRevision, settings]); const discardDraft = useCallback(() => { if (!savedDraft) { setDraft(null); return; } const next = structuredClone(savedDraft); setDraft(next); setHistoricalRevision(null); setSelectedNodeId(next.graph.nodes[0]?.id ?? null); setSelectedEdgeId(null); setDiagnostics([]); setError(""); setSuccess(""); }, [savedDraft]); const saveDraft = useCallback(async (): Promise => { if (!draft || !canEdit || !draft.name.trim()) { setError( !draft?.name.trim() ? "Workflow name is required." : "You cannot save this workflow." ); return false; } setWorking(true); setError(""); setSuccess(""); try { const saved = draft.id && draft.currentRevision ? await updateWorkflowDefinition(settings, draft.id, { ...workflowPayload(draft), expected_revision: draft.currentRevision }) : await createWorkflowDefinition(settings, workflowPayload(draft)); applyDefinition(saved); setDefinitions((current) => [ saved, ...current.filter((item) => item.id !== saved.id) ]); setSuccess(`Saved revision ${saved.current_revision}.`); return true; } catch (saveError) { setError(apiErrorMessage(saveError)); return false; } finally { setWorking(false); } }, [applyDefinition, canEdit, draft, settings]); useUnsavedDraftGuard({ dirty, title: "Unsaved workflow", message: "Save or discard the workflow changes before leaving this workspace.", onSave: saveDraft, onDiscard: discardDraft }); useEffect(() => { const beforeUnload = (event: BeforeUnloadEvent) => { if (!dirty) return; event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", beforeUnload); return () => window.removeEventListener("beforeunload", beforeUnload); }, [dirty]); const selectDefinition = (definition: WorkflowDefinition) => { requestNavigation(() => { applyDefinition(definition); setError(""); setSuccess(""); }); }; const createNew = () => { requestNavigation(() => { const next = sampleWorkflowDraft(); setDraft(next); setSavedDraft(null); setRevisions([]); setHistoricalRevision(null); setSelectedNodeId(next.graph.nodes[0]?.id ?? null); setSelectedEdgeId(null); setDiagnostics([]); setError(""); setSuccess(""); }); }; const validate = async () => { if (!displayedGraph) return; setWorking(true); setError(""); setSuccess(""); try { const result = await validateWorkflowDefinition(settings, displayedGraph); setDiagnostics(result.diagnostics); setSuccess(result.valid ? "BPMN workflow graph is valid." : ""); } catch (validationError) { setError(apiErrorMessage(validationError)); } finally { setWorking(false); } }; const importBpmnFile = async (file: File | null) => { if (!file || readOnly) return; setWorking(true); setError(""); setSuccess(""); try { const xml = await file.text(); const imported = await compileWorkflowBpmn(settings, { xml, adapter_id: "govoplan.native.bpmn", adapter_version: "1.0.0" }); setDraft((current) => current ? { ...current, graph: imported.graph } : current); setSelectedNodeId(imported.graph.nodes[0]?.id ?? null); setSelectedEdgeId(null); setDiagnostics([]); setSuccess("Imported BPMN XML into the native graph."); } catch (fileError) { setError(apiErrorMessage(fileError)); } finally { setWorking(false); } }; const exportBpmnFile = async () => { if (!displayedGraph) return; setWorking(true); setError(""); try { const rendered = await renderWorkflowBpmn(settings, { graph: displayedGraph, name: draft?.name ?? "Workflow" }); const url = URL.createObjectURL( new Blob([rendered.xml], { type: "application/xml" }) ); const link = document.createElement("a"); link.href = url; link.download = `${fileName(draft?.name || "workflow")}.bpmn`; link.click(); URL.revokeObjectURL(url); } catch (exportError) { setError(apiErrorMessage(exportError)); } finally { setWorking(false); } }; const selectRevision = (revisionNumber: number) => { if (!draft?.id) return; setDiagnostics([]); setSelectedEdgeId(null); if (revisionNumber === draft.currentRevision) { setHistoricalRevision(null); setSelectedNodeId(draft.graph.nodes[0]?.id ?? null); return; } const historical = revisions.find( (item) => item.revision === revisionNumber ) ?? null; setHistoricalRevision(historical); setSelectedNodeId(historical?.graph.nodes[0]?.id ?? null); }; const activate = async () => { if (!draft?.id || dirty) return; setWorking(true); setError(""); try { const saved = await activateWorkflowDefinition( settings, draft.id, draft.currentRevision ); applyDefinition(saved); setDefinitions((current) => current.map((item) => item.id === saved.id ? saved : item )); setSuccess(`Activated revision ${saved.active_revision}.`); } catch (activationError) { setError(apiErrorMessage(activationError)); } finally { setWorking(false); } }; const archiveDefinition = async () => { if (!draft?.id || dirty) return; setWorking(true); setError(""); try { const saved = await archiveWorkflowDefinition(settings, draft.id); applyDefinition(saved); setDefinitions((current) => current.map((item) => item.id === saved.id ? saved : item )); setSuccess("Workflow archived."); } catch (archiveError) { setError(apiErrorMessage(archiveError)); } finally { setWorking(false); } }; const removeDefinition = async () => { if (!draft?.id) return; setWorking(true); setError(""); try { await deleteWorkflowDefinition(settings, draft.id); setDeleteOpen(false); await reload(); } catch (deleteError) { setError(apiErrorMessage(deleteError)); } finally { setWorking(false); } }; const reconcileStandards = async () => { setWorking(true); setError(""); setSuccess(""); try { const result = await reconcileWorkflowStandards(settings); await reload(draft?.id); setSuccess( `Module standards: ${result.created} installed, ${result.updated} updated, ${result.blocked} blocked.` ); } catch (reconcileError) { setError(apiErrorMessage(reconcileError)); } finally { setWorking(false); } }; const resetToStandard = async () => { if (!draft?.id) return; setWorking(true); setError(""); try { const baseline = await resetWorkflowDefinitionToStandard( settings, draft.id ); setResetOpen(false); await reload(baseline.id); setSuccess("The local override was archived and the module standard restored."); } catch (resetError) { setError(apiErrorMessage(resetError)); } finally { setWorking(false); } }; const updateGraph = (graph: WorkflowDraft["graph"]) => { if (graphReadOnly) return; setDraft((current) => current ? { ...current, graph } : current); setDiagnostics([]); setSuccess(""); }; const removeNode = (nodeId: string) => { if (!draft || graphReadOnly) return; updateGraph({ ...draft.graph, nodes: draft.graph.nodes.filter((node) => node.id !== nodeId), edges: draft.graph.edges.filter( (edge) => edge.source !== nodeId && edge.target !== nodeId ) }); setSelectedNodeId(null); }; const updateEdge = (updatedEdge: WorkflowGraphEdge) => { if (!draft || graphReadOnly) return; updateGraph({ ...draft.graph, edges: draft.graph.edges.map((edge) => edge.id === updatedEdge.id ? updatedEdge : edge ) }); }; const removeEdge = (edgeId: string) => { if (!draft || graphReadOnly) return; updateGraph({ ...draft.graph, edges: draft.graph.edges.filter((edge) => edge.id !== edgeId) }); setSelectedEdgeId(null); }; const addNodeFromPalette = (nodeType: string) => { if (!draft || graphReadOnly) return; const index = draft.graph.nodes.length; const node = newWorkflowNode( nodeType, { x: 80 + (index % 4) * 220, y: 80 + Math.floor(index / 4) * 150 }, nodeLibrary ); updateGraph({ ...draft.graph, nodes: [...draft.graph.nodes, node] }); setSelectedNodeId(node.id); }; return ( void reload(draft?.id), loading: loading || working }} contextActions={<> Workflows {hasScope(auth, "workflow:instance:admin") ? ( } variant="ghost" onClick={() => void reconcileStandards()} disabled={loading || working} /> ) : null} } helpAction={} createAction={} variant="primary" onClick={createNew} disabled={!canWrite} disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined} />} /> setSearch(event.target.value)} placeholder="Search workflows" aria-label="Search workflows" /> {visibleDefinitions.map((definition) => ( selectDefinition(definition)} > ))} {!visibleDefinitions.length ? ( ) : null} } > {draft && displayedGraph ? ( <> setDraft({ ...draft, name: event.target.value })} disabled={readOnly} aria-label="Workflow name" /> setDraft({ ...draft, description: event.target.value })} disabled={readOnly} placeholder="Description" aria-label="Workflow description" /> } primaryActions={ {draft.id ? ( ) : null} {draft.standard?.kind === "override" && draft.standard.update_available ? ( ) : null} {draft.standard?.kind === "override" && draft.standard.reset_available ? ( ) : null} {historicalRevision ? ( ) : null} } variant="ghost" onClick={() => bpmnFileInputRef.current?.click()} disabled={readOnly || working} /> { void importBpmnFile(event.target.files?.[0] ?? null); event.target.value = ""; }} /> } variant="ghost" onClick={() => void exportBpmnFile()} disabled={!displayedGraph || working} /> {draft.id ? ( ) : null} } variant="ghost" onClick={() => setDefinitionSettingsOpen(true)} /> {draft.id ? ( } variant="ghost" onClick={() => setDeriveOpen(true)} disabled={!canReuse} /> ) : null} {draft.id && draft.status !== "archived" && draft.standard?.kind !== "baseline" ? ( ) : null} {draft.id && ( draft.status !== "active" || draft.activeRevision !== draft.currentRevision ) ? ( ) : null} } destructiveActions={draft.id && draft.standard?.kind !== "baseline" ? ( } variant="danger" onClick={() => setDeleteOpen(true)} disabled={!canEdit || working} /> ) : undefined} discardAction={{ label: <> Discard, onClick: () => requestDiscard(() => void reload(draft.id)) }} saveAction={{ label: <> Save, onClick: () => void saveDraft(), disabled: !canEdit || readOnly }} />
{error ? ( {error} ) : null} {success ? ( {success} ) : null}
{paletteGroups.map((group) => ( {group.nodes.map((nodeType) => ( } label={nodeType.label} draggable={!graphReadOnly} disabled={graphReadOnly} onDragStart={(event) => startPaletteDrag(event, nodeType.type) } onClick={() => addNodeFromPalette(nodeType.type)} title={nodeType.description} /> ))} ))}
{ if (!draft || graphReadOnly) return; updateGraph(updateWorkflowGraphNode(draft.graph, node)); }} onDelete={removeNode} onEdgeChange={updateEdge} onEdgeDelete={removeEdge} /> {diagnostics.length ? (
Diagnostics item.severity === "error") ? "error" : "warning" } label={String(diagnostics.length)} />
{diagnostics.map((item, index) => ( ))}
) : null}
{working ? Working... : null} ) : ( } title="No workflow selected" actions={canWrite ? ( ) : undefined} /> )}
setDeleteOpen(false)} onConfirm={() => void removeDefinition()} /> setResetOpen(false)} onConfirm={() => void resetToStandard()} /> setCompareOpen(false)} /> setDraft((current) => current ? { ...current, ...patch } : current)} onClose={() => setDefinitionSettingsOpen(false)} /> setDeriveOpen(false)} onDerived={(definition) => { applyDefinition(definition); setDefinitions((current) => [ definition, ...current.filter((item) => item.id !== definition.id) ]); setDeriveOpen(false); setSuccess("Created a pinned scoped copy."); }} /> { setRunsOpen(false); if (requestedRunId) { const next = new URLSearchParams(searchParams); next.delete("run"); setSearchParams(next, { replace: true }); } }} />
); } function WorkflowStandardComparisonDialog({ open, settings, override, onClose }: { open: boolean; settings: ApiSettings; override: WorkflowDefinition | null; onClose: () => void; }) { const [comparison, setComparison] = useState(null); const [error, setError] = useState(""); useEffect(() => { if (!open || !override) return; let cancelled = false; setError(""); setComparison(null); void compareWorkflowDefinitionToStandard(settings, override.id).then((result) => { if (cancelled) return; setComparison(result); }).catch((loadError) => { if (!cancelled) setError(apiErrorMessage(loadError)); }); return () => { cancelled = true; }; }, [open, override, settings]); return ( Close} > {error ? ( {error} ) : null} {!error && !comparison ? (
) : null} {comparison ? (
Pinned revision {comparison.pinned_baseline_revision} Local revision {comparison.local_revision} Latest standard {comparison.latest_baseline_revision}
{comparison.items.map((item) => (
{item.resource_id} {item.resource_type} · {item.changed_fields.join(", ")}

{standardDiffAction(item.recommended_action)}

))} {!comparison.items.length ? (

The local override and latest standard contain no semantic changes.

) : null}
) : null}
); } function standardDiffLabel(state: string): string { return ({ local_only: "Local change", upstream_only: "Standard update", same_change: "Same change", conflict: "Conflict", unchanged: "Unchanged" } as Record)[state] ?? state; } function standardDiffAction(action: string): string { return ({ keep_local: "The local change can be retained automatically.", adopt_upstream: "The standard update can be adopted automatically.", either: "Both revisions made the same semantic change.", manual_resolution: "Local and upstream changes overlap and need a decision.", none: "No action is required." } as Record)[action] ?? action; } function WorkflowDefinitionSettingsDialog({ open, settings, draft, editable, onChange, onClose }: { open: boolean; settings: ApiSettings; draft: WorkflowDraft | null; editable: boolean; onChange: (patch: Partial) => void; onClose: () => void; }) { const provenance = draft?.governance?.actions.edit?.source_path ?? []; const effectiveView = useEffectiveView(); const referenceScopeType = draft?.scopeType === "group" ? "group" : "user"; const scopeProvider = useMemo( () => workflowScopeReferenceProvider(settings, referenceScopeType), [ referenceScopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey ] ); return ( Close} > {draft ? (
{draft.scopeType === "user" || draft.scopeType === "group" ? ( onChange({ scopeId: value })} provider={scopeProvider} aria-label={ draft.scopeType === "user" ? "Definition user" : "Definition group" } placeholder={ draft.scopeType === "user" ? "Select a user" : "Select a group" } disabled={!editable || Boolean(draft.id)} required /> ) : null} onChange({ inheritToLowerScopes: value })} /> onChange({ allowStart: value })} /> onChange({ allowReuse: value })} /> onChange({ allowAutomation: value })} /> {draft.governance?.automation_runtime_reason ? ( {draft.governance.automation_runtime_reason} ) : null} {draft.governance?.derived_from_definition_id ? ( Derived from {draft.governance.derived_from_definition_id} {" · revision "} {draft.governance.derived_from_revision} {draft.governance.derived_from_hash} ) : null} {provenance.length ? ( Effective Policy path {provenance.map((item, index) => ( {String(item.label ?? item.path ?? item.scope_type)} ))} {!editable && draft.governance?.actions.edit?.reason ? ( {draft.governance.actions.edit.reason} ) : null} ) : null}
) : null}
); } function DeriveWorkflowDialog({ open, settings, definition, onClose, onDerived }: { open: boolean; settings: ApiSettings; definition: { id: string; name: string; revision: number } | null; onClose: () => void; onDerived: (definition: WorkflowDefinition) => void; }) { const [name, setName] = useState(""); const [kind, setKind] = useState<"flow" | "template">("flow"); const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant"); const [scopeId, setScopeId] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const scopeProvider = useMemo( () => workflowScopeReferenceProvider( settings, scopeType === "group" ? "group" : "user" ), [ scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey ] ); useEffect(() => { if (!open) return; setName(`${definition?.name ?? "Workflow"} copy`); setKind("flow"); setScopeType("tenant"); setScopeId(""); setError(""); }, [open, definition?.id]); const derive = async () => { if (!definition || !name.trim()) return; setBusy(true); setError(""); try { onDerived(await deriveWorkflowDefinition(settings, definition.id, { name: name.trim(), source_revision: definition.revision, metadata: {}, scope_type: scopeType, scope_id: scopeId.trim() || null, definition_kind: kind, inherit_to_lower_scopes: false, allow_start: true, allow_reuse: false, allow_automation: false })); } catch (deriveError) { setError(apiErrorMessage(deriveError)); } finally { setBusy(false); } }; return ( )} >
{error ? {error} : null} setName(event.target.value)} /> {scopeType !== "tenant" ? ( setScopeId(value)} provider={scopeProvider} aria-label={ scopeType === "user" ? "Copy target user" : "Copy target group" } placeholder={ scopeType === "user" ? "Select a user" : "Select a group" } disabled={busy} required /> ) : null} The source graph revision, node-library version, content hash, and effective ancestor limits are retained as provenance.
); } function startPaletteDrag( event: DragEvent, type: string ) { event.dataTransfer.setData("application/x-govoplan-workflow-node", type); event.dataTransfer.effectAllowed = "copy"; } function fileName(value: string): string { return value .normalize("NFKD") .replace(/[^\w.-]+/g, "-") .replace(/^-+|-+$/g, "") .toLowerCase() || "workflow"; } function apiErrorMessage(error: unknown): string { if (!isApiError(error)) { return error instanceof Error ? error.message : "The request failed."; } try { const parsed = JSON.parse(error.body) as { detail?: string | { message?: string }; }; if (typeof parsed.detail === "string") return parsed.detail; if (parsed.detail?.message) return parsed.detail.message; } catch { // Fall through to the API error message. } return error.message; }