22 branches 3 (#13181)

This PR does not produce any functional changes for our users. It
prepares the branches for workflows by:

- decommissioning `output` and `context` fields or `workflowRun` records
and use newly created `state` field from front-end and back-end
- use `stepStatus` computed by `back-end` in `front-end`
- add utils and types in `twenty-shared/workflow` (not completed, a
follow-up is scheduled
https://github.com/twentyhq/core-team-issues/issues/1211)
- add concurrency to `workflowQueue` message queue to avoid weird branch
execution when using forms in workflow branches
- add a WithLock decorator for better dev experience of
`CacheLockService.withLock` usage

Here is an example of such a workflow running (front branch display is
not yet done that's why it looks ugly) ->
https://discord.com/channels/1130383047699738754/1258024460238192691/1392897615171158098
This commit is contained in:
martmull
2025-07-16 11:16:04 +02:00
committed by GitHub
parent bf6330469b
commit 47386e92a3
47 changed files with 5124 additions and 6380 deletions
@@ -20,6 +20,7 @@ import {
ShouldExpandNodeInitiallyProps,
} from 'twenty-ui/json-visualizer';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { JsonValue } from 'type-fest';
export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const { t, i18n } = useLingui();
@@ -29,15 +30,15 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const step = workflowRun?.output?.flow.steps.find(
const step = workflowRun?.state?.flow.steps.find(
(step) => step.id === stepId,
);
if (
!(
isDefined(workflowRun) &&
isDefined(workflowRun.context) &&
isDefined(workflowRun.output?.flow) &&
isDefined(workflowRun.state?.stepInfos) &&
isDefined(workflowRun.state?.flow) &&
isDefined(step)
)
) {
@@ -46,7 +47,7 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const previousStepId = getWorkflowPreviousStepId({
stepId,
steps: workflowRun.output.flow.steps,
steps: workflowRun.state.flow.steps,
});
if (previousStepId === undefined) {
@@ -55,8 +56,8 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const stepDefinition = getStepDefinitionOrThrow({
stepId,
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
trigger: workflowRun.state.flow.trigger,
steps: workflowRun.state.flow.steps,
});
if (stepDefinition?.type !== 'action') {
throw new Error('The input tab must be rendered with an action step.');
@@ -76,10 +77,11 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const allVariablesUsedInStep = Array.from(variablesUsedInStep);
const stepContext = getWorkflowRunStepContext({
context: workflowRun.context,
flow: workflowRun.output.flow,
stepInfos: workflowRun.state.stepInfos,
flow: workflowRun.state.flow,
stepId,
});
if (stepContext.length === 0) {
throw new Error('The input tab must be rendered with a non-empty context.');
}
@@ -132,7 +134,7 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
elements={stepContext.map(({ id, name, context }) => ({
id,
label: name,
value: context,
value: context as JsonValue,
}))}
Icon={IconBrackets}
depth={0}
@@ -1,7 +1,10 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import {
WorkflowAction,
WorkflowRunStepStatus,
WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowEditActionAiAgent } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent';
import { WorkflowActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowActionServerlessFunction';
import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord';
@@ -21,7 +24,7 @@ type WorkflowRunStepNodeDetailProps = {
stepId: string;
trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null;
stepExecutionStatus?: WorkflowDiagramRunStatus;
stepExecutionStatus?: WorkflowRunStepStatus;
};
export const WorkflowRunStepNodeDetail = ({
@@ -172,7 +175,7 @@ export const WorkflowRunStepNodeDetail = ({
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: stepExecutionStatus !== 'running',
readonly: stepExecutionStatus !== 'PENDING',
}}
/>
);
@@ -1,6 +1,5 @@
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { WorkflowExecutorOutput } from '@/workflow/types/Workflow';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/components/WorkflowRunStepJsonContainer';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
@@ -30,18 +29,16 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
if (!isDefined(workflowRun?.output?.stepsOutput)) {
if (!isDefined(workflowRun?.state?.stepInfos)) {
return null;
}
const stepOutput = workflowRun.output.stepsOutput[stepId] as
| WorkflowExecutorOutput
| undefined;
const stepInfo = workflowRun.state.stepInfos[stepId];
const stepDefinition = getStepDefinitionOrThrow({
stepId,
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
trigger: workflowRun.state.flow.trigger,
steps: workflowRun.state.flow.steps,
});
if (
!isDefined(stepDefinition?.definition) ||
@@ -87,7 +84,7 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
<WorkflowRunStepJsonContainer>
<JsonTree
value={stepOutput ?? t`No output available`}
value={stepInfo ?? t`No output available`}
shouldExpandNodeInitially={isTwoFirstDepths}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
@@ -95,7 +92,7 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
getNodeHighlighting={
isDefined(stepOutput?.error)
isDefined(stepInfo?.error)
? setRedHighlightingForEveryNode
: undefined
}
@@ -49,18 +49,18 @@ export const useUpdateWorkflowRunStep = () => {
if (
!isDefined(cachedRecord) ||
!isDefined(cachedRecord?.output?.flow?.steps)
!isDefined(cachedRecord?.state?.flow?.steps)
) {
return;
}
const newCachedRecord = {
...cachedRecord,
output: {
...cachedRecord.output,
state: {
...cachedRecord.state,
flow: {
...cachedRecord.output.flow,
steps: cachedRecord.output.flow.steps.map((step: WorkflowAction) => {
...cachedRecord.state.flow,
steps: cachedRecord.state.flow.steps.map((step: WorkflowAction) => {
if (step.id === updatedStep.id) {
return updatedStep;
}
@@ -71,7 +71,7 @@ export const useUpdateWorkflowRunStep = () => {
};
const recordGqlFields = {
output: true,
state: true,
};
updateRecordFromCache({
objectMetadataItems,
@@ -1,6 +1,7 @@
import { WorkflowRunFlow } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getWorkflowRunStepContext } from '../getWorkflowRunStepContext';
import { StepStatus } from 'twenty-shared/workflow';
describe('getWorkflowRunStepContext', () => {
it('should return an empty array for trigger step', () => {
@@ -15,14 +16,17 @@ describe('getWorkflowRunStepContext', () => {
},
steps: [],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
};
const result = getWorkflowRunStepContext({
stepId: TRIGGER_STEP_ID,
flow,
context,
stepInfos,
});
expect(result).toEqual([]);
@@ -77,15 +81,20 @@ describe('getWorkflowRunStepContext', () => {
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { taskId: '456' },
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
step1: { result: { taskId: '456' }, status: StepStatus.SUCCESS },
step2: { result: { taskId: '456' }, status: StepStatus.SUCCESS },
};
const result = getWorkflowRunStepContext({
stepId: 'step2',
flow,
context,
stepInfos,
});
expect(result).toEqual([
@@ -149,16 +158,19 @@ describe('getWorkflowRunStepContext', () => {
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { taskId: '456' },
step2: { emailId: '789' },
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
step1: { result: { taskId: '456' }, status: StepStatus.SUCCESS },
step2: { result: { emailId: '789' }, status: StepStatus.SUCCESS },
};
const result = getWorkflowRunStepContext({
stepId: 'step1',
flow,
context,
stepInfos,
});
expect(result).toEqual([
@@ -237,17 +249,20 @@ describe('getWorkflowRunStepContext', () => {
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { noteId: '456' },
step2: { noteId: '789' },
step3: { noteId: '101' },
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
step1: { result: { noteId: '456' }, status: StepStatus.SUCCESS },
step2: { result: { noteId: '789' }, status: StepStatus.SUCCESS },
step3: { result: { noteId: '101' }, status: StepStatus.SUCCESS },
};
const result = getWorkflowRunStepContext({
stepId: 'step3',
flow,
context,
stepInfos,
});
expect(result).toEqual([
@@ -1,4 +1,5 @@
import { getWorkflowRunStepExecutionStatus } from '../getWorkflowRunStepExecutionStatus';
import { StepStatus } from 'twenty-shared/workflow';
describe('getWorkflowRunStepExecutionStatus', () => {
const stepId = '453e0084-aca2-45b9-8d1c-458a2b8ac70a';
@@ -6,16 +7,16 @@ describe('getWorkflowRunStepExecutionStatus', () => {
it('should return not-executed when the output is null', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: null,
workflowRunState: null,
stepId,
}),
).toBe('not-executed');
).toBe(StepStatus.NOT_STARTED);
});
it('should return success when step has result', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
workflowRunState: {
flow: {
steps: [
{
@@ -60,15 +61,20 @@ describe('getWorkflowRunStepExecutionStatus', () => {
},
},
},
stepsOutput: {
stepInfos: {
trigger: {
result: {},
status: StepStatus.SUCCESS,
},
[stepId]: {
result: {},
status: StepStatus.SUCCESS,
},
},
},
stepId,
}),
).toBe('success');
).toBe(StepStatus.SUCCESS);
});
it('should return failure when workflow has error', () => {
@@ -76,7 +82,7 @@ describe('getWorkflowRunStepExecutionStatus', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
workflowRunState: {
flow: {
steps: [
{
@@ -121,16 +127,21 @@ describe('getWorkflowRunStepExecutionStatus', () => {
},
},
},
error,
stepsOutput: {
workflowRunError: error,
stepInfos: {
trigger: {
result: {},
status: StepStatus.SUCCESS,
},
[stepId]: {
error,
status: StepStatus.FAILED,
},
},
},
stepId,
}),
).toBe('failure');
).toBe(StepStatus.FAILED);
});
it('should return not-executed when step has no output', () => {
@@ -138,7 +149,7 @@ describe('getWorkflowRunStepExecutionStatus', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
workflowRunState: {
flow: {
steps: [
{
@@ -217,14 +228,23 @@ describe('getWorkflowRunStepExecutionStatus', () => {
},
},
},
stepsOutput: {
stepInfos: {
trigger: {
result: {},
status: StepStatus.SUCCESS,
},
[stepId]: {
result: {},
status: StepStatus.SUCCESS,
},
[secondStepId]: {
result: {},
status: StepStatus.NOT_STARTED,
},
},
},
stepId: secondStepId,
}),
).toBe('not-executed');
).toBe(StepStatus.NOT_STARTED);
});
});
@@ -1,14 +1,18 @@
import { WorkflowRunContext, WorkflowRunFlow } from '@/workflow/types/Workflow';
import { WorkflowRunFlow } from '@/workflow/types/Workflow';
import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import {
getWorkflowRunContext,
WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
export const getWorkflowRunStepContext = ({
stepId,
flow,
context,
stepInfos,
}: {
stepId: string;
context: WorkflowRunContext;
stepInfos: WorkflowRunStepInfos;
flow: WorkflowRunFlow;
}) => {
if (stepId === TRIGGER_STEP_ID) {
@@ -17,6 +21,8 @@ export const getWorkflowRunStepContext = ({
const previousSteps = getPreviousSteps(flow.steps, stepId);
const context = getWorkflowRunContext(stepInfos);
const previousStepsContext = previousSteps.map((step) => {
return {
id: step.id,
@@ -1,37 +1,22 @@
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isNull } from '@sniptt/guards';
import {
WorkflowRunState,
WorkflowRunStepStatus,
} from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-shared/utils';
import { StepStatus } from 'twenty-shared/workflow';
export const getWorkflowRunStepExecutionStatus = ({
workflowRunOutput,
workflowRunState,
stepId,
}: {
workflowRunOutput: WorkflowRunOutput | null;
workflowRunState: WorkflowRunState | null;
stepId: string;
}): WorkflowDiagramRunStatus => {
if (isNull(workflowRunOutput)) {
return 'not-executed';
}): WorkflowRunStepStatus => {
const stepOutput = workflowRunState?.stepInfos?.[stepId];
if (isDefined(stepOutput)) {
return stepOutput.status;
}
if (stepId === TRIGGER_STEP_ID) {
return 'success';
}
const stepOutput = workflowRunOutput.stepsOutput?.[stepId];
if (isDefined(stepOutput?.error)) {
return 'failure';
}
if (isDefined(stepOutput?.pendingEvent)) {
return 'running';
}
if (isDefined(stepOutput?.result)) {
return 'success';
}
return 'not-executed';
return StepStatus.NOT_STARTED;
};