diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ExternalEvent/ExternalEvent.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ExternalEvent/ExternalEvent.tsx index a44af5abe..974287d01 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ExternalEvent/ExternalEvent.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ExternalEvent/ExternalEvent.tsx @@ -8,7 +8,6 @@ import { NodeProps } from '..' import { Fade } from '../Fade' import { NodeDetails } from '../NodeDetails' -import { NodeRunsList } from '../../NodeRunsList' import LinkWithTenant from '@/app/(authenticated)/[tenantId]/components/LinkWithTenant' import { DiagramDataGroup } from '../DataGroupComponents/DiagramDataGroup' @@ -19,7 +18,7 @@ const Node: FC> = ({ data }) => { return ( <> - +
diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/NodeDetails.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/NodeDetails.tsx index 538fe67a3..bddee7a8a 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/NodeDetails.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/NodeDetails.tsx @@ -1,16 +1,13 @@ 'use client' -import { Separator } from '@/components/ui/separator' -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { CSSProperties, FC, PropsWithChildren, ReactNode, useEffect, useMemo, useState } from 'react' +import { NodeRun } from 'littlehorse-client/proto' +import React, { CSSProperties, FC, PropsWithChildren, useEffect, useMemo, useState } from 'react' import { internalsSymbol, useNodeId, useStore } from 'reactflow' import { DiagramDataGroup } from './DataGroupComponents/DiagramDataGroup' -import React from 'react' -import { NodeRun, TaskRun } from 'littlehorse-client/proto' +import { DiagramDataGroupIndexer } from './DataGroupComponents/DiagramDataGroupIndexer' import { Duration } from './DataGroupComponents/Duration' import { Entry } from './DataGroupComponents/Entry' import { ErrorMessage } from './DataGroupComponents/ErrorMessage' import { Status } from './DataGroupComponents/Status' -import { DiagramDataGroupIndexer } from './DataGroupComponents/DiagramDataGroupIndexer' type Props = PropsWithChildren<{ nodeRunList: NodeRun[] | undefined, nodeRunsIndex?: number, setNodeRunsIndex?: (index: number) => void }> @@ -43,7 +40,7 @@ export const NodeDetails: FC = ({ children, nodeRunList, nodeRunsIndex, s if (nodeRunsIndex !== undefined && setNodeRunsIndex !== undefined) { setNodeRunsIndex(nodeRunsIndexInternal); } - }, [nodeRunsIndexInternal, setNodeRunsIndex]); + }, [nodeRunsIndex, nodeRunsIndexInternal, setNodeRunsIndex]); const zIndex: number = Math.max(...nodes.map(node => (node[internalsSymbol]?.z || 1) + 10)) if (!selectedNode) { @@ -70,14 +67,9 @@ export const NodeDetails: FC = ({ children, nodeRunList, nodeRunsIndex, s return []; }) as React.ReactElement[]; - - if (!nodeRunList || nodeRunList.length === 0) { - return null; - } - return (
- {nodeRunList && ( + {nodeRunList && nodeRunList[nodeRunsIndexInternal] && ( 1 ? `NodeRun #${nodeRunsIndexInternal}` : "NodeRun"} > @@ -91,6 +83,8 @@ export const NodeDetails: FC = ({ children, nodeRunList, nodeRunsIndex, s )} +
+
{diagramDataGroups.map((element, i) => ( {element} diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/Task/TaskDetails.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/Task/TaskDetails.tsx index 63b8c39ec..5e4e15a26 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/Task/TaskDetails.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/Task/TaskDetails.tsx @@ -1,22 +1,22 @@ 'use client' import LinkWithTenant from '@/app/(authenticated)/[tenantId]/components/LinkWithTenant' +import { OverflowText } from '@/app/(authenticated)/[tenantId]/components/OverflowText' import { getTaskDef } from '@/app/(authenticated)/[tenantId]/taskDef/[name]/getTaskDef' -import { getVariable, getVariableValue } from '@/app/utils' +import { getVariableValue } from '@/app/utils' import { useQuery } from '@tanstack/react-query' import { NodeRun, TaskNode } from 'littlehorse-client/proto' import { ExternalLinkIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { FC, useState } from 'react' -import { NodeDetails } from '../NodeDetails' import { DiagramDataGroup } from '../DataGroupComponents/DiagramDataGroup' +import { DiagramDataGroupIndexer } from '../DataGroupComponents/DiagramDataGroupIndexer' import { Duration } from '../DataGroupComponents/Duration' import { Entry } from '../DataGroupComponents/Entry' -import { Status } from '../DataGroupComponents/Status' -import { getTaskRun } from './getTaskRun' -import { DiagramDataGroupIndexer } from '../DataGroupComponents/DiagramDataGroupIndexer' import { Result } from '../DataGroupComponents/Result' -import { OverflowText } from '@/app/(authenticated)/[tenantId]/components/OverflowText' +import { Status } from '../DataGroupComponents/Status' import { ViewVariableAssignments, ViewVariables } from '../DataGroupComponents/Variables' +import { NodeDetails } from '../NodeDetails' +import { getTaskRun } from './getTaskRun' export const TaskDetails: FC<{ taskNode?: TaskNode @@ -84,7 +84,7 @@ export const TaskDetails: FC<{ } -
+
diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ThrowEvent/ThrowEvent.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ThrowEvent/ThrowEvent.tsx index 5973e9131..58a60d1dd 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ThrowEvent/ThrowEvent.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/ThrowEvent/ThrowEvent.tsx @@ -8,7 +8,6 @@ import LinkWithTenant from '@/app/(authenticated)/[tenantId]/components/LinkWith import { Fade } from '../Fade' import { NodeDetails } from '../NodeDetails' -import { NodeRunsList } from '../../NodeRunsList' import { DiagramDataGroup } from '../DataGroupComponents/DiagramDataGroup' const Node: FC> = ({ data }) => { @@ -18,7 +17,7 @@ const Node: FC> = ({ data }) => { return ( <> - +
diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/UserTask/UserTask.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/UserTask/UserTask.tsx index 41093e997..8197d9e8e 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/UserTask/UserTask.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/UserTask/UserTask.tsx @@ -1,16 +1,14 @@ import { UserTaskDefDetails } from '@/app/(authenticated)/[tenantId]/(diagram)/components/NodeTypes/UserTask/UserTaskDefDetails' -import LinkWithTenant from '@/app/(authenticated)/[tenantId]/components/LinkWithTenant' -import { ExternalLinkIcon, EyeIcon, UserIcon } from 'lucide-react' +import { UserIcon } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' import { FC, memo } from 'react' import { Handle, Position } from 'reactflow' +import { ExternalLinkButton } from '../../ExternalLinkButton' import { NodeRunsList } from '../../NodeRunsList' +import { DiagramDataGroup } from '../DataGroupComponents/DiagramDataGroup' import { Fade } from '../Fade' import { NodeProps } from '../index' import { NodeDetails } from '../NodeDetails' -import { useParams, useRouter } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { ExternalLinkButton } from '../../ExternalLinkButton' -import { DiagramDataGroup } from '../DataGroupComponents/DiagramDataGroup' const Node: FC = ({ data, selected }) => { const router = useRouter() @@ -22,7 +20,7 @@ const Node: FC = ({ data, selected }) => { return ( <> - +
{nodeRun && ( diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/Variables.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/Variables.tsx index b0e30ffb3..006ef2055 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/Variables.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/Variables.tsx @@ -7,6 +7,7 @@ import { OverflowText } from '../../../../components/OverflowText' type VariablesProps = { variableDefs: ThreadVarDef[] variables: Variable[] + inheritedVariables: (Variable | null)[] } const accessLevels: { [key in WfRunVariableAccessLevel]: string } = { @@ -16,9 +17,11 @@ const accessLevels: { [key in WfRunVariableAccessLevel]: string } = { UNRECOGNIZED: '', } -export const Variables: FC = ({ variableDefs, variables }) => { +export const Variables: FC = ({ variableDefs, variables, inheritedVariables }) => { if (variableDefs.length === 0) return <> + const allVariables = [...variables, ...inheritedVariables] + return (

Variables

@@ -34,7 +37,7 @@ export const Variables: FC = ({ variableDefs, variables }) => { v.id?.name === variable.varDef?.name)?.value)?.toString() ?? '' + getVariableValue(allVariables.find(v => v?.id?.name === variable.varDef?.name)?.value)?.toString() ?? '' } /> diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/WfRun.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/WfRun.tsx index 4dd5aa4cd..161be67de 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/WfRun.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/components/WfRun.tsx @@ -1,28 +1,29 @@ 'use client' import { Diagram } from '@/app/(authenticated)/[tenantId]/(diagram)/components/Diagram' import { Navigation } from '@/app/(authenticated)/[tenantId]/components/Navigation' +import { useWfRun } from '@/app/hooks/useWfRun' +import { WfRunId, WfRunVariableAccessLevel } from 'littlehorse-client/proto' import { useSearchParams } from 'next/navigation' -import { FC, useCallback } from 'react' +import { FC } from 'react' import { Details } from './Details' import { Variables } from './Variables' -import { useWfRun } from '@/app/hooks/useWfRun' -import { WfRunVariableAccessLevel } from 'littlehorse-client/proto' -import { isExternal } from 'util/types' -export const WfRun: FC<{ id: string, tenantId: string }> = ({ id, tenantId }) => { +export const WfRun: FC<{ ids: string[], tenantId: string }> = ({ ids, tenantId }) => { + const wfRunId = ids.reduce((wfRunId, id, i) => (i === 0 ? { id } : { id, parentWfRunId: wfRunId }), {} as WfRunId); + const searchParams = useSearchParams() const threadRunNumber = Number(searchParams.get('threadRunNumber')) - const { wfRunData, isLoading, isError } = useWfRun({ id: id, tenantId }) - const { wfRunData: parentWfRunData } = useWfRun({ id: wfRunData?.wfRun?.id?.parentWfRunId?.id ?? '', tenantId }) + const { wfRunData } = useWfRun({ wfRunId, tenantId }) + const { wfRunData: parentWfRunData } = useWfRun({ wfRunId: wfRunData?.wfRun?.id?.parentWfRunId ?? { id: '', parentWfRunId: undefined }, tenantId }) if (!wfRunData) return null const { wfRun, wfSpec, nodeRuns, variables } = wfRunData; if (!wfRun) return null - if (!wfRun.id?.parentWfRunId || !parentWfRunData) return null const variableDefs = wfSpec.threadSpecs[wfRun.threadRuns[threadRunNumber].threadSpecName].variableDefs; const inheritedVariables = variableDefs.filter(vD => vD.accessLevel === WfRunVariableAccessLevel.INHERITED_VAR).map(vD => { + if (!wfRun.id?.parentWfRunId || !parentWfRunData) return null return parentWfRunData.variables.find(v => v.id?.name === vD.varDef?.name)! }); @@ -36,8 +37,9 @@ export const WfRun: FC<{ id: string, tenantId: string }> = ({ id, tenantId }) => v.id?.threadRunNumber == Number(searchParams.get('threadRunNumber'))).concat(inheritedVariables)} + variableDefs={variableDefs} + variables={variables.filter(v => v.id?.threadRunNumber == Number(searchParams.get('threadRunNumber')))} + inheritedVariables={inheritedVariables} />
) diff --git a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/page.tsx b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/page.tsx index 1f75d283d..466ec386f 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/page.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/(diagram)/wfRun/[...ids]/page.tsx @@ -2,14 +2,12 @@ import { Metadata } from 'next' import { notFound } from 'next/navigation' import { ClientError, Status } from 'nice-grpc-common' import { WfRun } from './components/WfRun' -import { getWfRun } from '../../../../../actions/getWfRun' type Props = { params: { ids: string[]; tenantId: string } } export default async function Page({ params: { ids, tenantId } }: Props) { - const id = ids.join('_'); try { - return + return } catch (error) { if (error instanceof ClientError && error.code === Status.NOT_FOUND) return notFound() throw error diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/OverflowText.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/OverflowText.tsx index c25e67c0a..28192f498 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/OverflowText.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/OverflowText.tsx @@ -1,11 +1,11 @@ 'use client' -import { FC, useEffect, useRef, useState } from 'react' -import { cn } from '@/components/utils' +import { tryFormatAsJson } from '@/app/utils/tryFormatAsJson' import { Button } from '@/components/ui/button' -import { ChevronRight } from 'lucide-react' import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog' +import { cn } from '@/components/utils' +import { ChevronRight } from 'lucide-react' +import { FC, useEffect, useRef, useState } from 'react' import { CopyButton } from './CopyButton' -import { tryFormatAsJson } from '@/app/utils/tryFormatAsJson' type OverflowTextProps = { text: string @@ -37,7 +37,7 @@ export const OverflowText: FC = ({ text, className, variant } className )} > - {formattedText} + {formattedText}
View @@ -51,14 +51,14 @@ export const OverflowText: FC = ({ text, className, variant } 'bg-status-failed text-red-500': variant === 'error', })} > -
{formattedText}
+
{formattedText}
) } return ( -
+
{formattedText}
) diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/Principal.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/Principal.tsx index 434bd47c7..18b02a4ef 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/Principal.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/Principal.tsx @@ -1,20 +1,22 @@ -import { useWhoAmI } from '@/contexts/WhoAmIContext' import { DropdownMenu, - DropdownMenuSeparator, - DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { useWhoAmI } from '@/contexts/WhoAmIContext' import { signOut } from 'next-auth/react' -import { FC, Fragment } from 'react' +import { FC } from 'react' function classNames(...classes: Array) { return classes.filter(Boolean).join(' ') } export const Principal: FC = () => { const { user } = useWhoAmI() + const isAuthEnabled = process.env.LHD_OAUTH_ENABLED === 'true' + return ( @@ -25,9 +27,11 @@ export const Principal: FC = () => { {user?.name} - signOut()} className="block w-full px-4 py-2 text-left text-sm"> - Sign out - + {isAuthEnabled && ( + signOut()} className="block w-full px-4 py-2 text-left text-sm"> + Sign out + + )} ) diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/Search.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/Search.tsx index 93440c3c1..188ad0fd7 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/Search.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/Search.tsx @@ -1,9 +1,9 @@ 'use client' import { SEARCH_DEFAULT_LIMIT, SEARCH_ENTITIES, SearchType } from '@/app/constants' -import useSWRInfinite from 'swr/infinite' import { RefreshCwIcon } from 'lucide-react' import { useParams, useSearchParams } from 'next/navigation' import { FC, useState } from 'react' +import useSWRInfinite from 'swr/infinite' import { SearchFooter } from './SearchFooter' import { SearchHeader } from './SearchHeader' import { SearchResponse, search } from './searchAction' diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/VersionTag.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/VersionTag.tsx new file mode 100644 index 000000000..db73143b3 --- /dev/null +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/VersionTag.tsx @@ -0,0 +1,9 @@ +import { TagIcon } from "lucide-react"; + +export default function VersionTag({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} \ No newline at end of file diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/ExternalEventDefTable.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/ExternalEventDefTable.tsx index f267f2c41..0b68db321 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/ExternalEventDefTable.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/ExternalEventDefTable.tsx @@ -1,10 +1,10 @@ import { ExternalEventDefId } from 'littlehorse-client/proto' -import LinkWithTenant from '../LinkWithTenant' import { FC, Fragment } from 'react' import { SearchResultProps } from '.' +import { SelectionLink } from '../SelectionLink' export const ExternalEventDefTable: FC = ({ pages = [] }) => { - if (pages.length === 0) { + if (pages.every(page => page.results.length === 0)) { return
No ExternalEventDefs
} @@ -13,11 +13,9 @@ export const ExternalEventDefTable: FC = ({ pages = [] }) => {pages.map((page, i) => ( {page.results.map(({ name }: ExternalEventDefId) => ( -
- - {name} - -
+ +

{name}

+
))}
))} diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/TaskDefTable.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/TaskDefTable.tsx index 69626ff19..57da5c5b6 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/TaskDefTable.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/TaskDefTable.tsx @@ -1,13 +1,13 @@ import { TaskDefId } from 'littlehorse-client/proto' +import { useParams } from 'next/navigation' import { FC, Fragment } from 'react' import { SearchResultProps } from '.' -import { useParams } from 'next/navigation' -import LinkWithTenant from '../LinkWithTenant' +import { SelectionLink } from '../SelectionLink' export const TaskDefTable: FC = ({ pages = [] }) => { const { tenantId } = useParams() - if (pages.length === 0) { + if (pages.every(page => page.results.length === 0)) { return
No TaskDefs
} @@ -16,11 +16,9 @@ export const TaskDefTable: FC = ({ pages = [] }) => { {pages.map((page, i) => ( {page.results.map(({ name }: TaskDefId) => ( -
- - {name} - -
+ +

{name}

+
))}
))} diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/UserTaskDefTable.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/UserTaskDefTable.tsx index 84c9b2eb7..7ba79ac8a 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/UserTaskDefTable.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/UserTaskDefTable.tsx @@ -1,12 +1,11 @@ -import { Separator } from '@/components/ui/separator' import { UserTaskDefId } from 'littlehorse-client/proto' -import { TagIcon } from 'lucide-react' import { FC, Fragment } from 'react' import { SearchResultProps } from '.' -import LinkWithTenant from '../LinkWithTenant' +import { SelectionLink } from '../SelectionLink' +import VersionTag from '../VersionTag' export const UserTaskDefTable: FC = ({ pages = [] }) => { - if (pages.length === 0) { + if (pages.every(page => page.results.length === 0)) { return
No UserTaskDefs
} @@ -15,16 +14,10 @@ export const UserTaskDefTable: FC = ({ pages = [] }) => { {pages.map((page, i) => ( {page.results.map(({ name, version }: UserTaskDefId) => ( - -
- - {name} - -
- v{version} -
-
-
+ +

{name}

+ +
))}
))} diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WfSpecTable.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WfSpecTable.tsx index f417546dc..db63d11ce 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WfSpecTable.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WfSpecTable.tsx @@ -1,12 +1,11 @@ import { getLatestWfSpecs } from '@/app/actions/getLatestWfSpec' -import { Separator } from '@/components/ui/separator' import { WfSpecData } from '@/types' -import { TagIcon } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { FC, Fragment, useEffect, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { SearchResultProps } from '.' import { SelectionLink } from '../SelectionLink' +import VersionTag from '../VersionTag' export const WfSpecTable: FC = ({ pages = [] }) => { const router = useRouter() const tenantId = useParams().tenantId as string @@ -17,7 +16,7 @@ export const WfSpecTable: FC = ({ pages = [] }) => { getLatestWfSpecs(tenantId, wfSpecNames).then(setWfSpecs) }, [pages, tenantId]) - if (pages.length === 0) { + if (pages.every(page => page.results.length === 0)) { return
No WfSpecs
} @@ -25,16 +24,10 @@ export const WfSpecTable: FC = ({ pages = [] }) => {
{wfSpecs.map(wfSpec => ( - - -

{wfSpec.name}

-
- - Latest: v{wfSpec.latestVersion} -
-
- -
+ +

{wfSpec.name}

+ +
))}
diff --git a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WorkflowEventDefTable.tsx b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WorkflowEventDefTable.tsx index 5e60506b2..1e4767187 100644 --- a/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WorkflowEventDefTable.tsx +++ b/dashboard/src/app/(authenticated)/[tenantId]/components/tables/WorkflowEventDefTable.tsx @@ -1,13 +1,14 @@ import { WorkflowEventDefId } from 'littlehorse-client/proto' +import { useParams } from 'next/navigation' import { FC, Fragment } from 'react' import { SearchResultProps } from '.' -import { useParams } from 'next/navigation' import LinkWithTenant from '../LinkWithTenant' +import { SelectionLink } from '../SelectionLink' export const WorkflowEventDefTable: FC = ({ pages = [] }) => { const { tenantId } = useParams() - if (pages.length === 0) { + if (pages.every(page => page.results.length === 0)) { return
No WorkflowEventDefs
} @@ -16,11 +17,9 @@ export const WorkflowEventDefTable: FC = ({ pages = [] }) => {pages.map((page, i) => ( {page.results.map(({ name }: WorkflowEventDefId) => ( -
- - {name} - -
+ +

{name}

+
))}
))} diff --git a/dashboard/src/app/(authenticated)/layout.tsx b/dashboard/src/app/(authenticated)/layout.tsx index 29484f8fe..e610db672 100644 --- a/dashboard/src/app/(authenticated)/layout.tsx +++ b/dashboard/src/app/(authenticated)/layout.tsx @@ -2,11 +2,11 @@ import { Toaster } from '@/components/ui/sonner' import { WhoAmIContext } from '@/contexts/WhoAmIContext' import type { Metadata } from 'next' import { Inter } from 'next/font/google' -import { Header } from './[tenantId]/components/Header' -import { QueryProvider } from './[tenantId]/components/QueryProvider' +import { SWRConfig } from 'swr' import getWhoAmI from '../getWhoami' import '../globals.css' -import { SWRConfig } from 'swr' +import { Header } from './[tenantId]/components/Header' +import { QueryProvider } from './[tenantId]/components/QueryProvider' const inter = Inter({ subsets: ['latin'] }) diff --git a/dashboard/src/app/actions/getWfRun.ts b/dashboard/src/app/actions/getWfRun.ts index 921900794..389eb0a13 100644 --- a/dashboard/src/app/actions/getWfRun.ts +++ b/dashboard/src/app/actions/getWfRun.ts @@ -4,7 +4,7 @@ import { lhClient } from '@/app/lhClient' import { NodeRun, ThreadRun, Variable, WfRun, WfRunId, WfSpec } from 'littlehorse-client/proto' type Props = { - ids: string[] + wfRunId: WfRunId tenantId: string } @@ -16,12 +16,9 @@ export type WfRunResponse = { nodeRuns: NodeRun[] variables: Variable[] } -export const getWfRun = async ({ ids, tenantId }: Props): Promise => { +export const getWfRun = async ({ wfRunId, tenantId }: Props): Promise => { const client = await lhClient({ tenantId }) - const wfRunId = ids - .reverse() - .reduceRight((parentWfRunId, id) => ({ id, parentWfRunId }), undefined) - const wfRun = await client.getWfRun(wfRunId!) + const wfRun = await client.getWfRun(wfRunId) const [wfSpec, { results: nodeRuns }, { results: variables }] = await Promise.all([ client.getWfSpec({ ...wfRun.wfSpecId }), client.listNodeRuns({ diff --git a/dashboard/src/app/hooks/useWfRun.tsx b/dashboard/src/app/hooks/useWfRun.tsx index 02c9aad4d..79721a12d 100644 --- a/dashboard/src/app/hooks/useWfRun.tsx +++ b/dashboard/src/app/hooks/useWfRun.tsx @@ -1,15 +1,17 @@ "use client"; +import { WfRunId } from "littlehorse-client/proto"; import useSWR from "swr"; import { getWfRun } from "../actions/getWfRun"; type Props = { - id: string + wfRunId: WfRunId tenantId: string } -export function useWfRun({ id, tenantId }: Props) { - const { data, error, isLoading } = useSWR(`wfRun/${tenantId}/${id}`, () => { - return getWfRun({ ids: [id], tenantId }) +export function useWfRun({ wfRunId, tenantId }: Props) { + const { data, error, isLoading } = useSWR(`wfRun/${tenantId}/${wfRunId.id}/${wfRunId.parentWfRunId?.id}`, async () => { + if (!wfRunId.id) return + return await getWfRun({ wfRunId, tenantId }) }) return { diff --git a/dashboard/tailwind.config.ts b/dashboard/tailwind.config.ts index a24595ffe..668bf3efd 100644 --- a/dashboard/tailwind.config.ts +++ b/dashboard/tailwind.config.ts @@ -5,91 +5,94 @@ const config = { content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'], prefix: '', theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px' - } - }, - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - status: { - running: 'theme("colors.blue.300")', - success: 'theme("colors.green.300")', - failed: 'theme("colors.red.300")', - exception: 'theme("colors.orange.300")', - halting: 'theme("colors.purple.300")' - }, - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - keyframes: { - 'accordion-down': { - from: { - height: '0' - }, - to: { - height: 'var(--radix-accordion-content-height)' - } - }, - 'accordion-up': { - from: { - height: 'var(--radix-accordion-content-height)' - }, - to: { - height: '0' - } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out' - } - } + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px', + }, + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + status: { + running: 'theme("colors.blue.300")', + success: 'theme("colors.green.300")', + failed: 'theme("colors.red.300")', + exception: 'theme("colors.orange.300")', + halting: 'theme("colors.purple.300")', + }, + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { + height: '0', + }, + to: { + height: 'var(--radix-accordion-content-height)', + }, + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)', + }, + to: { + height: '0', + }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + fontFamily: { + code: ['Fira Code', 'sans-serif'], + }, + }, }, plugins: [require('tailwindcss-animate')], } satisfies Config diff --git a/docker/dashboard/Dockerfile b/docker/dashboard/Dockerfile index 476d9a49f..487b0d21c 100644 --- a/docker/dashboard/Dockerfile +++ b/docker/dashboard/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/littlehorse-enterprises/alpine-nginx-nodejs/nginx-nodejs:main as runner +FROM ghcr.io/littlehorse-enterprises/alpine-nginx-nodejs/nginx-nodejs:v1 as runner ENV NODE_ENV production RUN apk add --no-cache uuidgen diff --git a/examples/user-tasks/README.md b/examples/user-tasks/README.md index 00941ca18..d3db59a76 100644 --- a/examples/user-tasks/README.md +++ b/examples/user-tasks/README.md @@ -102,7 +102,7 @@ Follow the prompts, entering your user-id (be sure to enter `anakin`), the item Executing UserTaskRun 89962fbd15e748358f2df1c130b34403 4579d4bd166d4156bda49042b10ad7bb Enter the userId of the person completing the task: anakin -Field: Your Requst +Field: Your Request The item you are requesting. Please enter the response for this field (STR): the rank of master diff --git a/examples/user-tasks/src/main/java/io/littlehorse/examples/ItemRequestForm.java b/examples/user-tasks/src/main/java/io/littlehorse/examples/ItemRequestForm.java index 9cf112bda..702a04257 100644 --- a/examples/user-tasks/src/main/java/io/littlehorse/examples/ItemRequestForm.java +++ b/examples/user-tasks/src/main/java/io/littlehorse/examples/ItemRequestForm.java @@ -6,7 +6,7 @@ public class ItemRequestForm { @UserTaskField( description = "The item you are requesting.", - displayName = "Your Requst" + displayName = "Your Request" ) public String requestedItem; diff --git a/lhctl/internal/user_task_run.go b/lhctl/internal/user_task_run.go index 63ca7259a..378297680 100644 --- a/lhctl/internal/user_task_run.go +++ b/lhctl/internal/user_task_run.go @@ -366,9 +366,9 @@ func promptFor(prompt string, varType lhproto.VariableType) (*lhproto.VariableVa reader := bufio.NewReader(os.Stdin) // Read the entire line of text entered by the user - // The returned line will include the newline character '\n', so we'll trim it. + // The returned line will include newline characters such as '\n', so we'll trim it. userInput, _ := reader.ReadString('\n') - return littlehorse.StrToVarVal(userInput[:len(userInput)-1], varType) + return littlehorse.StrToVarVal(strings.TrimSpace(userInput), varType) } func getUserTaskDef( diff --git a/sdk-dotnet/Examples/ExceptionsHandlerExample/Program.cs b/sdk-dotnet/Examples/ExceptionsHandlerExample/Program.cs index 9a135dd8a..e252ba21f 100644 --- a/sdk-dotnet/Examples/ExceptionsHandlerExample/Program.cs +++ b/sdk-dotnet/Examples/ExceptionsHandlerExample/Program.cs @@ -1,6 +1,7 @@ using ExceptionsHandler; using LittleHorse.Sdk; using LittleHorse.Sdk.Worker; +using LittleHorse.Sdk.Workflow.Spec; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -46,6 +47,24 @@ private static List> GetTaskWorkers(LHConfig config) return workers; } + private static Workflow GetWorkflow() + { + void MyEntryPoint(WorkflowThread wf) + { + NodeOutput node = wf.Execute("fail"); + wf.HandleError( + node, + handler => + { + handler.Execute("my-task"); + } + ); + wf.Execute("my-task"); + } + + return new Workflow("example-exception-handler", MyEntryPoint); + } + static void Main(string[] args) { SetupApplication(); @@ -59,6 +78,9 @@ static void Main(string[] args) worker.RegisterTaskDef(); } + var workflow = GetWorkflow(); + workflow.RegisterWfSpec(config.GetGrpcClientInstance()); + Thread.Sleep(300); foreach (var worker in workers) diff --git a/sdk-dotnet/Examples/ExternalEventExample/Program.cs b/sdk-dotnet/Examples/ExternalEventExample/Program.cs index a9272f2d4..bc66b4c30 100644 --- a/sdk-dotnet/Examples/ExternalEventExample/Program.cs +++ b/sdk-dotnet/Examples/ExternalEventExample/Program.cs @@ -1,5 +1,6 @@ using ExternalEventExample; using LittleHorse.Sdk; +using LittleHorse.Sdk.Common.Proto; using LittleHorse.Sdk.Worker; using LittleHorse.Sdk.Workflow.Spec; using Microsoft.Extensions.DependencyInjection; @@ -62,6 +63,7 @@ static void Main(string[] args) { var loggerFactory = _serviceProvider.GetRequiredService(); var config = GetLHConfig(args, loggerFactory); + var client = config.GetGrpcClientInstance(); var taskWorkers = GetTaskWorkers(config); foreach (var worker in taskWorkers) { @@ -69,6 +71,17 @@ static void Main(string[] args) } var workflow = GetWorkflow(); + + // Register external event if it does not exist + HashSet externalEventNames = workflow.GetRequiredExternalEventDefNames(); + + foreach (var externalEventName in externalEventNames) + { + Console.WriteLine($"Registering external event {externalEventName}"); + + client.PutExternalEventDef(new PutExternalEventDefRequest { Name = externalEventName }); + } + workflow.RegisterWfSpec(config.GetGrpcClientInstance()); Thread.Sleep(300); diff --git a/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/LHVariableAssigmentTest.cs b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/LHVariableAssigmentTest.cs index 78e32830d..be3ccd1f4 100644 --- a/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/LHVariableAssigmentTest.cs +++ b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/LHVariableAssigmentTest.cs @@ -55,7 +55,7 @@ public void VariableAssigment_WithWfRunVariableContainingJson_ShouldAssignDetail public void VariableAssigment_WithNodeOutput_ShouldAssignNodeOutputToVariable() { var nodeOutput = new NodeOutput("wait-to-collect-order-data", _parentWfThread); - nodeOutput.JsonPath = "$.order"; + nodeOutput.WithJsonPath("$.order"); var variableAssigment = _parentWfThread.AssignVariableHelper(nodeOutput); diff --git a/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadErrorsAndExceptionsTest.cs b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadErrorsAndExceptionsTest.cs new file mode 100644 index 000000000..fa0c5c242 --- /dev/null +++ b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadErrorsAndExceptionsTest.cs @@ -0,0 +1,174 @@ +using System; +using LittleHorse.Sdk.Common.Proto; +using LittleHorse.Sdk.Workflow.Spec; +using Moq; +using Xunit; + +namespace LittleHorse.Sdk.Tests.Workflow.Spec; + +public class WorkflowThreadErrorsAndExceptionsTest +{ + private readonly Action _action; + void ParentEntrypoint(WorkflowThread thread) + { + } + + public WorkflowThreadErrorsAndExceptionsTest() + { + LHLoggerFactoryProvider.Initialize(null); + _action = ParentEntrypoint; + } + + [Fact] + public void WfThread_WithoutSpecificError_ShouldCompileErrorHandling() + { + var numberOfExitNodes = 1; + var numberOfEntrypointNodes = 1; + var numberOfTasks = 2; + var workflowName = "TestWorkflow"; + var mockParentWorkflow = new Mock(workflowName, _action); + + void EntryPointAction(WorkflowThread wf) + { + NodeOutput node = wf.Execute("fail"); + wf.HandleError( + node, + handler => + { + handler.Execute("my-task"); + } + ); + wf.Execute("my-task"); + } + var workflowThread = new WorkflowThread(mockParentWorkflow.Object, EntryPointAction); + + var compiledWfThread = workflowThread.Compile(); + + var expectedSpec = new ThreadSpec(); + var entrypoint = new Node + { + Entrypoint = new EntrypointNode(), + OutgoingEdges = + { + new Edge { SinkNodeName = "1-fail-TASK" } + } + }; + + var failTask = new Node + { + Task = new TaskNode + { + TaskDefId = new TaskDefId { Name = "fail" } + }, + OutgoingEdges = { new Edge { SinkNodeName = "2-my-task-TASK" } }, + FailureHandlers = + { + new FailureHandlerDef + { + HandlerSpecName = "exn-handler-1-fail-TASK-FAILURE_TYPE_ERROR", + AnyFailureOfType = FailureHandlerDef.Types.LHFailureType.FailureTypeError + } + } + }; + + var myTask = new Node + { + Task = new TaskNode + { + TaskDefId = new TaskDefId { Name = "my-task" } + }, + OutgoingEdges = { new Edge { SinkNodeName = "3-exit-EXIT" } } + }; + + var exitNode = new Node + { + Exit = new ExitNode() + }; + + expectedSpec.Nodes.Add("0-entrypoint-ENTRYPOINT", entrypoint); + expectedSpec.Nodes.Add("1-fail-TASK", failTask); + expectedSpec.Nodes.Add("2-my-task-TASK", myTask); + expectedSpec.Nodes.Add("3-exit-EXIT", exitNode); + + var expectedNumberOfNodes = numberOfEntrypointNodes + numberOfExitNodes + numberOfTasks; + Assert.Equal(expectedNumberOfNodes, compiledWfThread.Nodes.Count); + Assert.Equal(expectedSpec, compiledWfThread); + } + + [Fact] + public void WfThread_WithSpecificError_ShouldCompileErrorHandling() + { + var numberOfExitNodes = 1; + var numberOfEntrypointNodes = 1; + var numberOfTasks = 2; + var workflowName = "TestWorkflow"; + var mockParentWorkflow = new Mock(workflowName, _action); + + void EntryPointAction(WorkflowThread wf) + { + NodeOutput node = wf.Execute("fail"); + wf.HandleError( + node, + LHErrorType.Timeout, + handler => + { + handler.Execute("my-task"); + } + ); + wf.Execute("my-task"); + } + var workflowThread = new WorkflowThread(mockParentWorkflow.Object, EntryPointAction); + + var compiledWfThread = workflowThread.Compile(); + + var expectedSpec = new ThreadSpec(); + var entrypoint = new Node + { + Entrypoint = new EntrypointNode(), + OutgoingEdges = + { + new Edge { SinkNodeName = "1-fail-TASK" } + } + }; + + var failTask = new Node + { + Task = new TaskNode + { + TaskDefId = new TaskDefId { Name = "fail" } + }, + OutgoingEdges = { new Edge { SinkNodeName = "2-my-task-TASK" } }, + FailureHandlers = + { + new FailureHandlerDef + { + HandlerSpecName = "exn-handler-1-fail-TASK-TIMEOUT", + SpecificFailure = "TIMEOUT" + } + } + }; + + var myTask = new Node + { + Task = new TaskNode + { + TaskDefId = new TaskDefId { Name = "my-task" } + }, + OutgoingEdges = { new Edge { SinkNodeName = "3-exit-EXIT" } } + }; + + var exitNode = new Node + { + Exit = new ExitNode() + }; + + expectedSpec.Nodes.Add("0-entrypoint-ENTRYPOINT", entrypoint); + expectedSpec.Nodes.Add("1-fail-TASK", failTask); + expectedSpec.Nodes.Add("2-my-task-TASK", myTask); + expectedSpec.Nodes.Add("3-exit-EXIT", exitNode); + + var expectedNumberOfNodes = numberOfEntrypointNodes + numberOfExitNodes + numberOfTasks; + Assert.Equal(expectedNumberOfNodes, compiledWfThread.Nodes.Count); + Assert.Equal(expectedSpec, compiledWfThread); + } +} \ No newline at end of file diff --git a/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTaskRetriesTest.cs b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTaskRetriesTest.cs new file mode 100644 index 000000000..3f4c6e791 --- /dev/null +++ b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTaskRetriesTest.cs @@ -0,0 +1,66 @@ +using System; +using LittleHorse.Sdk.Common.Proto; +using LittleHorse.Sdk.Workflow.Spec; +using Moq; +using Xunit; + +namespace LittleHorse.Sdk.Tests.Workflow.Spec; + +public class WorkflowThreadTaskRetriesTest +{ + private Action _action; + void ParentEntrypoint(WorkflowThread thread) + { + } + + public WorkflowThreadTaskRetriesTest() + { + LHLoggerFactoryProvider.Initialize(null); + _action = ParentEntrypoint; + } + + [Fact] + public void WfThread_WithRetriesInTaskNode_ShouldCompile() + { + var numberOfExitNodes = 1; + var numberOfEntrypointNodes = 1; + var numberOfTasks = 1; + var workflowName = "TestWorkflow"; + var mockParentWorkflow = new Mock(workflowName, _action); + void EntryPointAction(WorkflowThread wf) + { + wf.Execute("greet").WithRetries(2); + } + var workflowThread = new WorkflowThread(mockParentWorkflow.Object, EntryPointAction); + + var compiledWfThread = workflowThread.Compile(); + + var expectedSpec = new ThreadSpec(); + + var entrypoint = new Node + { + Entrypoint = new EntrypointNode(), + OutgoingEdges = { new Edge { SinkNodeName = "1-greet-TASK" } } + }; + + var greetTask = new Node + { + Task = new TaskNode + { + TaskDefId = new TaskDefId { Name = "greet" }, + Retries = 2 + }, + OutgoingEdges = { new Edge { SinkNodeName = "2-exit-EXIT" } } + }; + + var exitNode = new Node { Exit = new ExitNode() }; + + expectedSpec.Nodes.Add("0-entrypoint-ENTRYPOINT", entrypoint); + expectedSpec.Nodes.Add("1-greet-TASK", greetTask); + expectedSpec.Nodes.Add("2-exit-EXIT", exitNode); + + var expectedNumberOfNodes = numberOfEntrypointNodes + numberOfExitNodes + numberOfTasks; + Assert.Equal(expectedNumberOfNodes, compiledWfThread.Nodes.Count); + Assert.Equal(expectedSpec, compiledWfThread); + } +} \ No newline at end of file diff --git a/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTest.cs b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTest.cs index b4b133be5..048e07e53 100644 --- a/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTest.cs +++ b/sdk-dotnet/LittleHorse.Sdk.Tests/Workflow/Spec/WorkflowThreadTest.cs @@ -156,18 +156,19 @@ void EntryPointAction(WorkflowThread wf) } [Fact] - public void WfThread_WithExternalEvent_ShouldCompileItInTheWorkflowThread() + public void WfThread_WithExternalEvent_ShouldCompile() { var numberOfExitNodes = 1; var numberOfEntrypointNodes = 1; var numberOfExternalEvents = 1; var numberOfTasks = 1; var workflowName = "TestWorkflow"; + var timeoutInSeconds = 30; var mockParentWorkflow = new Mock(workflowName, _action); void EntryPointAction(WorkflowThread wf) { WfRunVariable name = wf.DeclareStr("name"); - name.Assign(wf.WaitForEvent("name-event")); + name.Assign(wf.WaitForEvent("name-event").WithTimeout(timeoutInSeconds)); wf.Execute("greet", name); } var workflowThread = new WorkflowThread(mockParentWorkflow.Object, EntryPointAction); @@ -188,7 +189,8 @@ void EntryPointAction(WorkflowThread wf) { ExternalEvent = new ExternalEventNode { - ExternalEventDefId = new ExternalEventDefId { Name = "name-event" } + ExternalEventDefId = new ExternalEventDefId { Name = "name-event" }, + TimeoutSeconds = new VariableAssignment { LiteralValue = new VariableValue { Int = timeoutInSeconds }} }, OutgoingEdges = { diff --git a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/NodeOutput.cs b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/NodeOutput.cs index c03db0b89..76fad0eb2 100644 --- a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/NodeOutput.cs +++ b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/NodeOutput.cs @@ -2,9 +2,9 @@ namespace LittleHorse.Sdk.Workflow.Spec; public class NodeOutput { - public string NodeName { get; set; } - public WorkflowThread Parent { get; set; } - public string? JsonPath { get; set; } + public string NodeName { get; private set; } + public WorkflowThread Parent { get; private set; } + public string? JsonPath { get; private set; } public NodeOutput(string nodeName, WorkflowThread parent) { @@ -23,4 +23,11 @@ public NodeOutput WithJsonPath(string path) return nodeOutput; } + + public NodeOutput WithTimeout(int timeoutSeconds) + { + Parent.AddTimeoutToExtEvt(this, timeoutSeconds); + + return this; + } } \ No newline at end of file diff --git a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/TaskNodeOutput.cs b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/TaskNodeOutput.cs new file mode 100644 index 000000000..3dea0bf0c --- /dev/null +++ b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/TaskNodeOutput.cs @@ -0,0 +1,22 @@ +using LittleHorse.Sdk.Common.Proto; + +namespace LittleHorse.Sdk.Workflow.Spec; + +public class TaskNodeOutput : NodeOutput +{ + public TaskNodeOutput(string nodeName, WorkflowThread parent) : base(nodeName, parent) + { + } + + public TaskNodeOutput WithExponentialBackoff(ExponentialBackoffRetryPolicy policy) + { + Parent.OverrideTaskExponentialBackoffPolicy(this, policy); + return this; + } + + public TaskNodeOutput WithRetries(int retries) + { + Parent.OverrideTaskRetries(this, retries); + return this; + } +} \ No newline at end of file diff --git a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/Workflow.cs b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/Workflow.cs index 7519a2634..b9ad0f66d 100644 --- a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/Workflow.cs +++ b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/Workflow.cs @@ -1,4 +1,5 @@ using LittleHorse.Sdk.Common.Proto; +using LittleHorse.Sdk.Exceptions; using LittleHorse.Sdk.Helper; using Microsoft.Extensions.Logging; @@ -51,7 +52,7 @@ public void RegisterWfSpec(LittleHorseClient client) _logger!.LogInformation(LHMappingHelper.ProtoToJson(client.PutWfSpec(Compile()))); } - private string AddSubThread(string subThreadName, Action subThreadAction) + internal string AddSubThread(string subThreadName, Action subThreadAction) { foreach (var threadPair in _threadActions) { diff --git a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/WorkflowThread.cs b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/WorkflowThread.cs index aeaa7fbc9..7a1ba576e 100644 --- a/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/WorkflowThread.cs +++ b/sdk-dotnet/LittleHorse.Sdk/Workflow/Spec/WorkflowThread.cs @@ -182,7 +182,7 @@ public WfRunVariable AddVariable(string name, Object typeOrDefaultVal) /// pass that literal value in. /// /// A NodeOutput for that TASK node. - public NodeOutput Execute(string taskName, params object[] args) + public TaskNodeOutput Execute(string taskName, params object[] args) { CheckIfWorkflowThreadIsActive(); _parent.AddTaskDefName(taskName); @@ -190,7 +190,7 @@ public NodeOutput Execute(string taskName, params object[] args) new TaskNode { TaskDefId = new TaskDefId { Name = taskName } }, args); string nodeName = AddNode(taskName, Node.NodeOneofCase.Task, taskNode); - return new NodeOutput(nodeName, this); + return new TaskNodeOutput(nodeName, this); } private VariableAssignment AssignVariable(Object variable) @@ -522,4 +522,159 @@ internal VariableAssignment AssignVariableHelper(object? value) return variableAssignment; } + + internal void AddTimeoutToExtEvt(NodeOutput node, int timeoutSeconds) + { + CheckIfWorkflowThreadIsActive(); + Node newNode = FindNode(node.NodeName); + + var timeoutValue = new VariableAssignment + { + LiteralValue = new VariableValue { Int = timeoutSeconds } + }; + + if (newNode.NodeCase == Node.NodeOneofCase.Task) + { + newNode.Task.TimeoutSeconds = timeoutSeconds; + } + else if (newNode.NodeCase == Node.NodeOneofCase.ExternalEvent) + { + newNode.ExternalEvent.TimeoutSeconds = timeoutValue; + } + else + { + throw new Exception("Timeouts are only supported on ExternalEvent and Task nodes."); + } + } + + internal void OverrideTaskExponentialBackoffPolicy(TaskNodeOutput node, ExponentialBackoffRetryPolicy policy) + { + var newNode = CheckTaskNode(node); + + newNode.Task.ExponentialBackoff = policy; + } + + internal void OverrideTaskRetries(TaskNodeOutput node, int retries) + { + var newNode = CheckTaskNode(node); + + newNode.Task.Retries = retries; + } + + private Node CheckTaskNode(TaskNodeOutput node) + { + CheckIfWorkflowThreadIsActive(); + Node newNode = FindNode(node.NodeName); + + if (newNode.NodeCase != Node.NodeOneofCase.Task) + { + throw new InvalidOperationException("Impossible to not have task node here"); + } + + return newNode; + } + + /// + /// Attaches an Error Handler to the specified NodeOutput, allowing it to manage specific types of errors + /// as defined by the 'error' parameter. If 'error' is set to null, the handler will catch all errors. + /// + /// + /// The NodeOutput instance to which the Error Handler will be attached. + /// + /// + /// The type of error that the handler will manage. + /// + /// + /// A ThreadFunction defining a ThreadSpec that specifies how to handle the error. + /// + public void HandleError(NodeOutput node, LHErrorType error, Action handler) + { + CheckIfWorkflowThreadIsActive(); + var errorFormatted = error.ToString().ToUpper(); + var handlerDef = BuildFailureHandlerDef(node, + errorFormatted, + handler); + handlerDef.SpecificFailure = errorFormatted; + AddFailureHandlerDef(handlerDef, node); + } + + /// + /// Attaches an Error Handler to the specified NodeOutput, allowing it to manage any types of errors. + /// + /// + /// + /// The NodeOutput instance to which the Error Handler will be attached. + /// + /// + /// A ThreadFunction defining a ThreadSpec that specifies how to handle the error. + /// + public void HandleError(NodeOutput node, Action handler) + { + CheckIfWorkflowThreadIsActive(); + var handlerDef = BuildFailureHandlerDef(node, + "FAILURE_TYPE_ERROR", + handler); + handlerDef.AnyFailureOfType = FailureHandlerDef.Types.LHFailureType.FailureTypeError; + AddFailureHandlerDef(handlerDef, node); + } + + /// + /// Adds an EXIT node with a Failure defined. This causes a ThreadRun to fail, and the resulting + /// Failure has the specified value, name, and human-readable message. + /// + /// + /// It is a literal value (cast to VariableValue by the Library) or a WfRunVariable. + /// The assigned value is the payload of the resulting Failure, which can be accessed by any + /// Failure Handler ThreadRuns. + /// + /// + /// It is the name of the failure to throw. + /// + /// + /// It is a human-readable message. + /// + public void Fail(object? output, string failureName, string? message) + { + CheckIfWorkflowThreadIsActive(); + var failureDef = new FailureDef(); + if (output != null) failureDef.Content = AssignVariable(output); + if (message != null) failureDef.Message = message; + failureDef.FailureName = failureName; + + ExitNode exitNode = new ExitNode { FailureDef = failureDef }; + + AddNode(failureName, Node.NodeOneofCase.Exit, exitNode); + } + + /// + /// Adds an EXIT node with a Failure defined. This causes a ThreadRun to fail, and the resulting + /// Failure has the specified name and human-readable message. + /// + /// + /// It is the name of the failure to throw. + /// + /// + /// It is a human-readable message. + /// + public void Fail(string failureName, string message) + { + Fail(null, failureName, message); + } + + private FailureHandlerDef BuildFailureHandlerDef(NodeOutput node, string error, Action handler) + { + string threadName = $"exn-handler-{node.NodeName}-{error}"; + + threadName = _parent.AddSubThread(threadName, handler); + + return new FailureHandlerDef { HandlerSpecName = threadName }; + } + + private void AddFailureHandlerDef(FailureHandlerDef handlerDef, NodeOutput node) + { + // Add the failure handler to the most recent node + Node lastNode = FindNode(node.NodeName); + + lastNode.FailureHandlers.Add(handlerDef); + } } \ No newline at end of file diff --git a/sdk-go/littlehorse/lh_variables.go b/sdk-go/littlehorse/lh_variables.go index 970411197..e6ea0696e 100644 --- a/sdk-go/littlehorse/lh_variables.go +++ b/sdk-go/littlehorse/lh_variables.go @@ -5,7 +5,6 @@ import ( "encoding/json" "reflect" "strconv" - "strings" "github.com/littlehorse-enterprises/littlehorse/sdk-go/lhproto" ) @@ -50,7 +49,7 @@ func StrToVarVal(input string, varType lhproto.VariableType) (*lhproto.VariableV case lhproto.VariableType_BOOL: var tmp bool - tmp, err = strconv.ParseBool(strings.TrimSpace(input)) + tmp, err = strconv.ParseBool(input) if err == nil { out.Value = &lhproto.VariableValue_Bool{Bool: tmp} } diff --git a/sdk-go/littlehorse/lh_variables_test.go b/sdk-go/littlehorse/lh_variables_test.go index 168fd3db8..d8fa5233c 100644 --- a/sdk-go/littlehorse/lh_variables_test.go +++ b/sdk-go/littlehorse/lh_variables_test.go @@ -1,9 +1,10 @@ package littlehorse_test import ( + "testing" + "github.com/littlehorse-enterprises/littlehorse/sdk-go/lhproto" "github.com/littlehorse-enterprises/littlehorse/sdk-go/littlehorse" - "testing" "github.com/stretchr/testify/assert" ) diff --git a/server/src/main/java/io/littlehorse/common/model/getable/core/wfrun/subnoderun/ExternalEventNodeRunModel.java b/server/src/main/java/io/littlehorse/common/model/getable/core/wfrun/subnoderun/ExternalEventNodeRunModel.java index 8d09baa75..dd5ffd85d 100644 --- a/server/src/main/java/io/littlehorse/common/model/getable/core/wfrun/subnoderun/ExternalEventNodeRunModel.java +++ b/server/src/main/java/io/littlehorse/common/model/getable/core/wfrun/subnoderun/ExternalEventNodeRunModel.java @@ -18,6 +18,7 @@ import io.littlehorse.common.model.getable.objectId.ExternalEventIdModel; import io.littlehorse.common.util.LHUtil; import io.littlehorse.sdk.common.proto.ExternalEventNodeRun; +import io.littlehorse.sdk.common.proto.LHErrorType; import io.littlehorse.sdk.common.proto.LHStatus; import io.littlehorse.sdk.common.proto.VariableType; import io.littlehorse.server.streams.topology.core.ExecutionContext; @@ -84,9 +85,14 @@ public ExternalEventNodeRun.Builder toProto() { } @Override - public boolean checkIfProcessingCompleted(ProcessorExecutionContext processorContext) { + public boolean checkIfProcessingCompleted(ProcessorExecutionContext processorContext) throws NodeFailureException { if (externalEventId != null) return true; + if (timedOut) { + FailureModel failure = new FailureModel("ExternalEvent did not arrive in time", LHErrorType.TIMEOUT.name()); + throw new NodeFailureException(failure); + } + NodeModel node = nodeRun.getNode(); ExternalEventNodeModel eNode = node.getExternalEventNode(); @@ -165,7 +171,6 @@ public void processExternalEventTimeout(ExternalEventTimeoutModel timeout) { return; } - // This is leaking the logic of the timedOut = true; } diff --git a/server/src/test/java/e2e/ExternalEventTest.java b/server/src/test/java/e2e/ExternalEventTest.java index 4438db8fc..11f039db6 100644 --- a/server/src/test/java/e2e/ExternalEventTest.java +++ b/server/src/test/java/e2e/ExternalEventTest.java @@ -5,6 +5,8 @@ import io.littlehorse.sdk.common.proto.ExternalEvent; import io.littlehorse.sdk.common.proto.ExternalEventDefId; import io.littlehorse.sdk.common.proto.ExternalEventId; +import io.littlehorse.sdk.common.proto.Failure; +import io.littlehorse.sdk.common.proto.LHErrorType; import io.littlehorse.sdk.common.proto.LHStatus; import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub; import io.littlehorse.sdk.common.proto.PutExternalEventRequest; @@ -26,6 +28,9 @@ public class ExternalEventTest { public static final String EVT_NAME = "basic-test-event"; public static final String IGNORED_EVT_NAME = "not-a-real-event-kenobi"; + @LHWorkflow("external-event-timeout") + public Workflow timeoutEvent; + @LHWorkflow("basic-external-event") public Workflow basicExternalEvent; @@ -41,6 +46,25 @@ public Workflow getBasicExternalEventWorkflow() { }); } + @LHWorkflow("external-event-timeout") + public Workflow getTimeoutWorkflow() { + return Workflow.newWorkflow("external-event-timeout", wf -> { + wf.waitForEvent(EVT_NAME).timeout(1); + }); + } + + @Test + void shouldTimeoutIfNoEvent() { + verifier.prepareRun(timeoutEvent) + .waitForStatus(LHStatus.ERROR) + .thenVerifyNodeRun(0, 1, nodeRun -> { + Failure failure = nodeRun.getFailures(0); + Assertions.assertThat(failure.getFailureName()).isEqualTo(LHErrorType.TIMEOUT.toString()); + Assertions.assertThat(failure.getMessage().toLowerCase()).contains("arrive in time"); + }) + .start(); + } + @Test void shouldCompleteIfEventIsSentAfterWfRunStarts() { WfRunId id = WfRunId.newBuilder().setId(LHUtil.generateGuid()).build();