From ba94c3b8576cf125f160222b714b96af9386505e Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Fri, 12 Jun 2026 17:25:53 +0200 Subject: [PATCH] feat(workflow): idempotent stop + retry failed runs from failing step (#21458) https://github.com/user-attachments/assets/5a25396f-8959-4bd8-93cb-1187559ffe5f ## Summary Two workflow-run improvements, with all non-trivial logic isolated in pure, unit-tested utils. ### 1. Idempotent stop `stopWorkflowRun` no longer throws when a run is already in a terminal status (`COMPLETED` / `FAILED` / `STOPPED`) or already `STOPPING`; it returns the run unchanged. This fixes: - bulk stop aborting on the first non-stoppable run in a mixed/select-all selection, - the click-vs-processing race on a single run (run finishes between click and mutation). It also releases the cached not-started throttle slot when stopping a `NOT_STARTED` run (prevents counter drift), and ends runs with no `state` directly. ### 2. Retry a failed run from the failing step New `retryWorkflowRun` mutation (same guards/passthrough as `stopWorkflowRun`). It resets the failed step(s) to `NOT_STARTED`, flips the run to `RUNNING`, and enqueues a `RunWorkflowJob` with the steps to re-execute; downstream execution and status computation are unchanged. Logic lives in pure utils: - `build-retry-step-infos.util.ts` - decides per failed step what to reset; delegates iterator-specific logic to `build-retry-iterator-step-infos.util.ts` (an iterator that failed mid-loop is restored to `RUNNING` with cursor preserved, an iterator that failed itself restarts its whole loop). - `get-runnable-step-ids.util.ts` - reuses the executor's `shouldExecuteStep` to also resume branches that never started (avoids hangs), excluding loop-interior steps. The service method only orchestrates; the job's status check is a race guard (retriability is enforced in the service before enqueue). A "Retry" command menu item surfaces only for `FAILED` runs (`someEquals(selectedRecords, "status", "FAILED")`). ### 3. Keep the run diagram visible across regenerations The run diagram is regenerated on every run state change, producing fresh nodes without the dimensions Reactflow had measured. Reactflow hides unmeasured nodes until it re-measures them, so the diagram could flicker and disappear when the last regeneration before going idle left nodes unmeasured (reproducible after retrying a failed run). The regenerated nodes now carry over the previously measured dimensions (by id) so they stay rendered. ## Test plan - [x] Unit tests for both retry utils (9 cases: plain failed step, non-failed untouched, iterator mid-loop restore, iterator self-failure, frontier parent gating, entry steps, loop-interior exclusion, parallel branches) - [x] `twenty-server` + `twenty-front` typecheck - [x] `lint:diff-with-main` clean for both packages - [x] Manual: retry a failed run repeatedly and confirm the diagram stays visible - [ ] Manual: stop a COMPLETED/mixed selection (no error), retry a failed run and confirm it resumes from the failing step --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 3 +- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.ts | 14 ++ ...EngineComponentKeyHeadlessComponentMap.tsx | 4 + .../RetryWorkflowRunSingleRecordCommand.tsx | 11 ++ .../StopWorkflowRunSingleRecordCommand.tsx | 31 +-- .../hooks/useExecuteWorkflowRunBulkCommand.ts | 34 ++++ .../graphql/mutations/retryWorkflowRun.ts | 11 ++ .../workflow/hooks/useRetryWorkflowRun.ts | 40 ++++ .../WorkflowRunVisualizerEffect.tsx | 27 ++- .../resolvers/workflow-trigger.resolver.ts | 12 ++ .../enums/engine-component-key.enum.ts | 1 + .../standard-command-menu-item.constant.ts | 76 +++++--- ...get-iterator-initial-loop-step-ids.util.ts | 25 +++ .../workflow-runner/jobs/run-workflow.job.ts | 35 +++- .../types/run-workflow-job-data.type.ts | 1 + ...ild-retry-iterator-step-infos.util.spec.ts | 75 ++++++++ .../build-retry-step-infos.util.spec.ts | 118 ++++++++++++ .../get-runnable-step-ids.util.spec.ts | 81 ++++++++ .../build-retry-iterator-step-infos.util.ts | 64 +++++++ .../utils/build-retry-step-infos.util.ts | 51 +++++ .../utils/get-runnable-step-ids.util.ts | 49 +++++ .../workflow-runner.workspace-service.ts | 180 ++++++++++++++---- .../workflow-trigger.workspace-service.ts | 7 + 25 files changed, 857 insertions(+), 95 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/RetryWorkflowRunSingleRecordCommand.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/hooks/useExecuteWorkflowRunBulkCommand.ts create mode 100644 packages/twenty-front/src/modules/workflow/graphql/mutations/retryWorkflowRun.ts create mode 100644 packages/twenty-front/src/modules/workflow/hooks/useRetryWorkflowRun.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-iterator-initial-loop-step-ids.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-iterator-step-infos.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-step-infos.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/get-runnable-step-ids.util.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-iterator-step-infos.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-step-infos.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-runner/utils/get-runnable-step-ids.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 931dad172f..ec8bc488b3 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -385,6 +385,7 @@ enum EngineComponentKey { SEE_VERSION_WORKFLOW_RUN SEE_WORKFLOW_WORKFLOW_RUN STOP_WORKFLOW_RUN + RETRY_WORKFLOW_RUN SEE_RUNS_WORKFLOW_VERSION SEE_WORKFLOW_WORKFLOW_VERSION USE_AS_DRAFT_WORKFLOW_VERSION diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 7e459254e3..2d3c55affe 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -304,7 +304,7 @@ export interface CommandMenuItem { __typename: 'CommandMenuItem' } -export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS' +export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'RETRY_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS' export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK' @@ -8456,6 +8456,7 @@ export const enumEngineComponentKey = { SEE_VERSION_WORKFLOW_RUN: 'SEE_VERSION_WORKFLOW_RUN' as const, SEE_WORKFLOW_WORKFLOW_RUN: 'SEE_WORKFLOW_WORKFLOW_RUN' as const, STOP_WORKFLOW_RUN: 'STOP_WORKFLOW_RUN' as const, + RETRY_WORKFLOW_RUN: 'RETRY_WORKFLOW_RUN' as const, SEE_RUNS_WORKFLOW_VERSION: 'SEE_RUNS_WORKFLOW_VERSION' as const, SEE_WORKFLOW_WORKFLOW_VERSION: 'SEE_WORKFLOW_WORKFLOW_VERSION' as const, USE_AS_DRAFT_WORKFLOW_VERSION: 'USE_AS_DRAFT_WORKFLOW_VERSION' as const, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f1ad3a1922..f6f1a5e392 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1533,6 +1533,7 @@ export enum EngineComponentKey { RESTORE_MULTIPLE_RECORDS = 'RESTORE_MULTIPLE_RECORDS', RESTORE_RECORDS = 'RESTORE_RECORDS', RESTORE_SINGLE_RECORD = 'RESTORE_SINGLE_RECORD', + RETRY_WORKFLOW_RUN = 'RETRY_WORKFLOW_RUN', SAVE_DASHBOARD_LAYOUT = 'SAVE_DASHBOARD_LAYOUT', SEARCH_RECORDS = 'SEARCH_RECORDS', SEARCH_RECORDS_FALLBACK = 'SEARCH_RECORDS_FALLBACK', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 2ab5ab9cb0..111e7a1751 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -142,6 +142,7 @@ export type Mutation = { dismissReconnectAccountBanner: Scalars['Boolean']['output']; duplicateWorkflow: WorkflowVersionDto; duplicateWorkflowVersionStep: WorkflowVersionStepChanges; + retryWorkflowRun: WorkflowRun; runWorkflowVersion: RunWorkflowVersion; stopWorkflowRun: WorkflowRun; submitFormStep: Scalars['Boolean']['output']; @@ -207,6 +208,11 @@ export type MutationDuplicateWorkflowVersionStepArgs = { }; +export type MutationRetryWorkflowRunArgs = { + workflowRunId: Scalars['UUID']['input']; +}; + + export type MutationRunWorkflowVersionArgs = { input: RunWorkflowVersionInput; }; @@ -732,6 +738,13 @@ export type DuplicateWorkflowVersionStepMutationVariables = Exact<{ export type DuplicateWorkflowVersionStepMutation = { __typename?: 'Mutation', duplicateWorkflowVersionStep: { __typename?: 'WorkflowVersionStepChanges', triggerDiff?: any | null, stepsDiff?: any | null } }; +export type RetryWorkflowRunMutationVariables = Exact<{ + workflowRunId: Scalars['UUID']['input']; +}>; + + +export type RetryWorkflowRunMutation = { __typename?: 'Mutation', retryWorkflowRun: { __typename: 'WorkflowRun', id: any, status: WorkflowRunStatusEnum } }; + export type RunWorkflowVersionMutationVariables = Exact<{ input: RunWorkflowVersionInput; }>; @@ -812,6 +825,7 @@ export const DeleteWorkflowVersionEdgeDocument = {"kind":"Document","definitions export const DeleteWorkflowVersionStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteWorkflowVersionStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteWorkflowVersionStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteWorkflowVersionStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkflowDiffFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkflowDiffFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionStepChanges"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"triggerDiff"}},{"kind":"Field","name":{"kind":"Name","value":"stepsDiff"}}]}}]} as unknown as DocumentNode; export const DuplicateWorkflowDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DuplicateWorkflow"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DuplicateWorkflowInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplicateWorkflow"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"trigger"}},{"kind":"Field","name":{"kind":"Name","value":"steps"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"workflowId"}}]}}]}}]} as unknown as DocumentNode; export const DuplicateWorkflowVersionStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DuplicateWorkflowVersionStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DuplicateWorkflowVersionStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplicateWorkflowVersionStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkflowDiffFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkflowDiffFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkflowVersionStepChanges"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"triggerDiff"}},{"kind":"Field","name":{"kind":"Name","value":"stepsDiff"}}]}}]} as unknown as DocumentNode; +export const RetryWorkflowRunDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RetryWorkflowRun"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"retryWorkflowRun"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowRunId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}}]}}]} as unknown as DocumentNode; export const RunWorkflowVersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RunWorkflowVersion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RunWorkflowVersionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"runWorkflowVersion"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowRunId"}}]}}]}}]} as unknown as DocumentNode; export const StopWorkflowRunDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopWorkflowRun"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopWorkflowRun"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowRunId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}}]}}]} as unknown as DocumentNode; export const UpdateWorkflowRunStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowRunStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowRunStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowRunStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"nextStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx index 75444480d8..bcc42b8032 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap.tsx @@ -28,6 +28,7 @@ import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/re import { EditRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand'; import { SeeVersionWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand'; import { SeeWorkflowWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand'; +import { RetryWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/RetryWorkflowRunSingleRecordCommand'; import { StopWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand'; import { SeeRunsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand'; import { SeeVersionsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand'; @@ -87,6 +88,9 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record< [EngineComponentKey.STOP_WORKFLOW_RUN]: ( ), + [EngineComponentKey.RETRY_WORKFLOW_RUN]: ( + + ), [EngineComponentKey.USE_AS_DRAFT_WORKFLOW_VERSION]: ( ), diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/RetryWorkflowRunSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/RetryWorkflowRunSingleRecordCommand.tsx new file mode 100644 index 0000000000..1e6486da2b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/RetryWorkflowRunSingleRecordCommand.tsx @@ -0,0 +1,11 @@ +import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; +import { useExecuteWorkflowRunBulkCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/hooks/useExecuteWorkflowRunBulkCommand'; +import { useRetryWorkflowRun } from '@/workflow/hooks/useRetryWorkflowRun'; + +export const RetryWorkflowRunSingleRecordCommand = () => { + const { retryWorkflowRun } = useRetryWorkflowRun(); + + const { execute } = useExecuteWorkflowRunBulkCommand(retryWorkflowRun); + + return ; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx index 91a912d42a..bbbc137f18 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand.tsx @@ -1,36 +1,11 @@ import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect'; -import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi'; -import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; -import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { useExecuteWorkflowRunBulkCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/hooks/useExecuteWorkflowRunBulkCommand'; import { useStopWorkflowRun } from '@/workflow/hooks/useStopWorkflowRun'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; export const StopWorkflowRunSingleRecordCommand = () => { - const { targetedRecordsRule, graphqlFilter } = useHeadlessCommandContextApi(); - - const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ - objectNameSingular: CoreObjectNameSingular.WorkflowRun, - filter: isDefined(graphqlFilter) ? graphqlFilter : undefined, - limit: DEFAULT_QUERY_PAGE_SIZE, - recordGqlFields: { id: true }, - }); - const { stopWorkflowRun } = useStopWorkflowRun(); - const handleExecute = async () => { - if (targetedRecordsRule.mode === 'selection') { - for (const selectedRecordId of targetedRecordsRule.selectedRecordIds) { - await stopWorkflowRun(selectedRecordId); - } - } else { - const records = await fetchAllRecordIds(); + const { execute } = useExecuteWorkflowRunBulkCommand(stopWorkflowRun); - for (const record of records) { - await stopWorkflowRun(record.id); - } - } - }; - - return ; + return ; }; diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/hooks/useExecuteWorkflowRunBulkCommand.ts b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/hooks/useExecuteWorkflowRunBulkCommand.ts new file mode 100644 index 0000000000..b6ea267581 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow-runs/hooks/useExecuteWorkflowRunBulkCommand.ts @@ -0,0 +1,34 @@ +import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const useExecuteWorkflowRunBulkCommand = ( + action: (workflowRunId: string) => Promise, +) => { + const { targetedRecordsRule, graphqlFilter } = useHeadlessCommandContextApi(); + + const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ + objectNameSingular: CoreObjectNameSingular.WorkflowRun, + filter: isDefined(graphqlFilter) ? graphqlFilter : undefined, + limit: DEFAULT_QUERY_PAGE_SIZE, + recordGqlFields: { id: true }, + }); + + const execute = async () => { + if (targetedRecordsRule.mode === 'selection') { + for (const selectedRecordId of targetedRecordsRule.selectedRecordIds) { + await action(selectedRecordId); + } + } else { + const records = await fetchAllRecordIds(); + + for (const record of records) { + await action(record.id); + } + } + }; + + return { execute }; +}; diff --git a/packages/twenty-front/src/modules/workflow/graphql/mutations/retryWorkflowRun.ts b/packages/twenty-front/src/modules/workflow/graphql/mutations/retryWorkflowRun.ts new file mode 100644 index 0000000000..bb7987cf4a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/graphql/mutations/retryWorkflowRun.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const RETRY_WORKFLOW_RUN = gql` + mutation RetryWorkflowRun($workflowRunId: UUID!) { + retryWorkflowRun(workflowRunId: $workflowRunId) { + id + status + __typename + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/hooks/useRetryWorkflowRun.ts b/packages/twenty-front/src/modules/workflow/hooks/useRetryWorkflowRun.ts new file mode 100644 index 0000000000..fad788c780 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/hooks/useRetryWorkflowRun.ts @@ -0,0 +1,40 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent'; +import { useMutation } from '@apollo/client/react'; +import { RetryWorkflowRunDocument } from '~/generated/graphql'; + +export const useRetryWorkflowRun = () => { + const apolloCoreClient = useApolloCoreClient(); + const [mutate] = useMutation(RetryWorkflowRunDocument, { + client: apolloCoreClient, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.WorkflowRun, + }); + + const retryWorkflowRun = async (workflowRunId: string) => { + await mutate({ + variables: { + workflowRunId, + }, + }); + + dispatchObjectRecordOperationBrowserEvent({ + objectMetadataItem, + operation: { + type: 'update-one', + result: { + updateInput: { + recordId: workflowRunId, + updatedFields: [], + }, + }, + }, + }); + }; + + return { retryWorkflowRun }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect.tsx index 8ba5fb0ad5..0fb876c4da 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect.tsx @@ -123,13 +123,38 @@ export const WorkflowRunVisualizerEffect = ({ steps: workflowRunState.flow.steps, }); - const { diagram: baseWorkflowRunDiagram, stepToOpenByDefault } = + const { diagram: generatedWorkflowRunDiagram, stepToOpenByDefault } = generateWorkflowRunDiagram({ trigger: workflowRunState.flow.trigger, steps: workflowRunState.flow.steps, stepInfos: workflowRunState.stepInfos, }); + const previousNodesById = new Map( + (store.get(workflowDiagram)?.nodes ?? []).map((node) => [ + node.id, + node, + ]), + ); + + const baseWorkflowRunDiagram = { + ...generatedWorkflowRunDiagram, + nodes: generatedWorkflowRunDiagram.nodes.map((node) => { + const previousNode = previousNodesById.get(node.id); + + if (!isDefined(previousNode?.measured)) { + return node; + } + + return { + ...node, + measured: previousNode.measured, + width: previousNode.width, + height: previousNode.height, + }; + }), + }; + if (workflowDiagramStatus !== 'done') { store.set(workflowDiagramStatusState, 'computing-dimensions'); } diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts index d285bd27ca..f45d143cda 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts @@ -121,4 +121,16 @@ export class WorkflowTriggerResolver { workspace.id, ); } + + @Mutation(() => WorkflowRunDTO) + async retryWorkflowRun( + @AuthWorkspace() workspace: WorkspaceEntity, + @Args('workflowRunId', { type: () => UUIDScalarType }) + workflowRunId: string, + ) { + return this.workflowTriggerWorkspaceService.retryWorkflowRun( + workflowRunId, + workspace.id, + ); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum.ts index d1918f96d8..c9846e19ae 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum.ts @@ -36,6 +36,7 @@ export enum EngineComponentKey { SEE_VERSION_WORKFLOW_RUN = 'SEE_VERSION_WORKFLOW_RUN', SEE_WORKFLOW_WORKFLOW_RUN = 'SEE_WORKFLOW_WORKFLOW_RUN', STOP_WORKFLOW_RUN = 'STOP_WORKFLOW_RUN', + RETRY_WORKFLOW_RUN = 'RETRY_WORKFLOW_RUN', SEE_RUNS_WORKFLOW_VERSION = 'SEE_RUNS_WORKFLOW_VERSION', SEE_WORKFLOW_WORKFLOW_VERSION = 'SEE_WORKFLOW_WORKFLOW_VERSION', USE_AS_DRAFT_WORKFLOW_VERSION = 'USE_AS_DRAFT_WORKFLOW_VERSION', diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index 88f291f49c..2b32b4b7d5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -544,12 +544,28 @@ export const STANDARD_COMMAND_MENU_ITEMS = { engineComponentKey: EngineComponentKey.STOP_WORKFLOW_RUN, hotKeys: null, }, + retryWorkflowRun: { + universalIdentifier: '99b97c50-b31b-411a-a532-ec05402123c0', + label: 'Retry', + icon: 'IconRefresh', + isPinned: true, + position: 36, + shortLabel: 'Retry', + availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, + conditionalAvailabilityExpression: + 'someEquals(selectedRecords, "status", "FAILED")', + availabilityObjectMetadataUniversalIdentifier: + STANDARD_OBJECTS.workflowRun.universalIdentifier, + frontComponentUniversalIdentifier: null, + engineComponentKey: EngineComponentKey.RETRY_WORKFLOW_RUN, + hotKeys: null, + }, seeRunsWorkflowVersion: { universalIdentifier: '44e305c7-4f0a-45ec-803f-6471b56455cb', label: 'See Runs', icon: 'IconHistoryToggle', isPinned: true, - position: 36, + position: 37, shortLabel: 'See Runs', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: @@ -565,7 +581,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'See Workflow', icon: 'IconSettingsAutomation', isPinned: true, - position: 37, + position: 38, shortLabel: 'See Workflow', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: @@ -581,7 +597,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Use as Draft', icon: 'IconPencil', isPinned: true, - position: 38, + position: 39, shortLabel: 'Use as Draft', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: @@ -597,7 +613,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'See Versions History', icon: 'IconVersions', isPinned: false, - position: 39, + position: 40, shortLabel: 'See Versions', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: @@ -613,7 +629,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Search', icon: 'IconSearch', isPinned: false, - position: 40, + position: 41, shortLabel: 'Search', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, @@ -627,7 +643,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Search', icon: 'IconSearch', isPinned: false, - position: 41, + position: 42, shortLabel: 'Search', availabilityType: CommandMenuItemAvailabilityType.FALLBACK, conditionalAvailabilityExpression: null, @@ -641,7 +657,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Ask AI', icon: 'IconSparkles', isPinned: false, - position: 42, + position: 43, shortLabel: 'Ask AI', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.AI', @@ -655,7 +671,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'View Previous AI Chats', icon: 'IconHistory', isPinned: false, - position: 43, + position: 44, shortLabel: 'Previous AI Chats', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.AI', @@ -669,7 +685,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Reply', icon: 'IconArrowBackUp', isPinned: true, - position: 44, + position: 45, shortLabel: 'Reply', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1', @@ -684,7 +700,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Compose Email', icon: 'IconMail', isPinned: false, - position: 45, + position: 46, shortLabel: 'Compose', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.SEND_EMAIL_TOOL', @@ -698,7 +714,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Settings', icon: 'IconSettings', isPinned: false, - position: 46, + position: 47, shortLabel: 'Settings', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, @@ -713,7 +729,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Experience Settings', icon: 'IconColorSwatch', isPinned: false, - position: 47, + position: 48, shortLabel: 'Experience', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, @@ -728,7 +744,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Accounts Settings', icon: 'IconAt', isPinned: false, - position: 48, + position: 49, shortLabel: 'Accounts', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.CONNECTED_ACCOUNTS', @@ -743,7 +759,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Emails Settings', icon: 'IconMail', isPinned: false, - position: 49, + position: 50, shortLabel: 'Emails', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.CONNECTED_ACCOUNTS', @@ -758,7 +774,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Calendars Settings', icon: 'IconCalendarEvent', isPinned: false, - position: 50, + position: 51, shortLabel: 'Calendars', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.CONNECTED_ACCOUNTS', @@ -773,7 +789,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to General Settings', icon: 'IconSettings', isPinned: false, - position: 51, + position: 52, shortLabel: 'General', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.WORKSPACE', @@ -788,7 +804,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Data Model Settings', icon: 'IconHierarchy2', isPinned: false, - position: 52, + position: 53, shortLabel: 'Data Model', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.DATA_MODEL', @@ -803,7 +819,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Members Settings', icon: 'IconUsers', isPinned: false, - position: 53, + position: 54, shortLabel: 'Members', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.WORKSPACE_MEMBERS', @@ -818,7 +834,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Roles Settings', icon: 'IconLock', isPinned: false, - position: 54, + position: 55, shortLabel: 'Roles', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.WORKSPACE_MEMBERS', @@ -833,7 +849,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Domains Settings', icon: 'IconWorld', isPinned: false, - position: 55, + position: 56, shortLabel: 'Domains', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.WORKSPACE', @@ -848,7 +864,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Billing Settings', icon: 'IconCurrencyDollar', isPinned: false, - position: 56, + position: 57, shortLabel: 'Billing', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.WORKSPACE', @@ -863,7 +879,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to APIs & Webhooks Settings', icon: 'IconApi', isPinned: false, - position: 57, + position: 58, shortLabel: 'APIs & Webhooks', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.API_KEYS_AND_WEBHOOKS', @@ -878,7 +894,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Apps Settings', icon: 'IconPlug', isPinned: false, - position: 58, + position: 59, shortLabel: 'Apps', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.APPLICATIONS', @@ -893,7 +909,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to AI Settings', icon: 'IconSparkles', isPinned: false, - position: 59, + position: 60, shortLabel: 'AI', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.WORKSPACE', @@ -908,7 +924,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Security Settings', icon: 'IconKey', isPinned: false, - position: 60, + position: 61, shortLabel: 'Security', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'permissionFlags.SECURITY', @@ -923,7 +939,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Admin Panel Settings', icon: 'IconServer', isPinned: false, - position: 61, + position: 62, shortLabel: 'Admin Panel', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: @@ -939,7 +955,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Go to Community Settings', icon: 'IconUsers', isPinned: false, - position: 62, + position: 63, shortLabel: 'Community', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, @@ -954,7 +970,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Send Email', icon: 'IconMail', isPinned: true, - position: 63, + position: 64, shortLabel: 'Send Email', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: @@ -970,7 +986,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Send Email', icon: 'IconMail', isPinned: true, - position: 64, + position: 65, shortLabel: 'Send Email', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: @@ -986,7 +1002,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { label: 'Send Email', icon: 'IconMail', isPinned: true, - position: 65, + position: 66, shortLabel: 'Send Email', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-iterator-initial-loop-step-ids.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-iterator-initial-loop-step-ids.util.ts new file mode 100644 index 0000000000..6a9a3a4858 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-iterator-initial-loop-step-ids.util.ts @@ -0,0 +1,25 @@ +import { isString } from '@sniptt/guards'; + +import { type WorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const getIteratorInitialLoopStepIds = ( + step: WorkflowIteratorAction, +): string[] => { + const initialLoopStepIds = step.settings.input.initialLoopStepIds; + + if (isString(initialLoopStepIds)) { + try { + const parsed: unknown = JSON.parse(initialLoopStepIds); + + if (Array.isArray(parsed) && parsed.every(isString)) { + return parsed; + } + } catch { + return []; + } + + return []; + } + + return initialLoopStepIds ?? []; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts index 72287142f6..c97b53138a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/jobs/run-workflow.job.ts @@ -39,6 +39,7 @@ export class RunWorkflowJob { async handle({ workflowRunId, lastExecutedStepId, + stepIdsToRetry, workspaceId, }: RunWorkflowJobData): Promise { this.logger.log( @@ -48,7 +49,13 @@ export class RunWorkflowJob { await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { try { - if (lastExecutedStepId) { + if (isDefined(stepIdsToRetry)) { + await this.retryWorkflowExecution({ + workspaceId, + workflowRunId, + stepIdsToRetry, + }); + } else if (lastExecutedStepId) { await this.resumeWorkflowExecution({ workspaceId, workflowRunId, @@ -131,6 +138,32 @@ export class RunWorkflowJob { }); } + private async retryWorkflowExecution({ + workflowRunId, + stepIdsToRetry, + workspaceId, + }: { + workflowRunId: string; + stepIdsToRetry: string[]; + workspaceId: string; + }): Promise { + const workflowRun = + await this.workflowRunWorkspaceService.getWorkflowRunOrFail({ + workflowRunId, + workspaceId, + }); + + if (workflowRun.status !== WorkflowRunStatus.RUNNING) { + return; + } + + await this.workflowExecutorWorkspaceService.executeFromSteps({ + stepIds: stepIdsToRetry, + workflowRunId, + workspaceId, + }); + } + private async resumeWorkflowExecution({ workflowRunId, lastExecutedStepId, diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/types/run-workflow-job-data.type.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/types/run-workflow-job-data.type.ts index 38884f55e5..00df174bb3 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/types/run-workflow-job-data.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/types/run-workflow-job-data.type.ts @@ -2,4 +2,5 @@ export type RunWorkflowJobData = { workspaceId: string; workflowRunId: string; lastExecutedStepId?: string; + stepIdsToRetry?: string[]; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-iterator-step-infos.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-iterator-step-infos.util.spec.ts new file mode 100644 index 0000000000..fc3970827d --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-iterator-step-infos.util.spec.ts @@ -0,0 +1,75 @@ +import { StepStatus, type WorkflowRunStepInfo } from 'twenty-shared/workflow'; + +import { + createMockCodeStep, + createMockIteratorStep, +} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util'; +import { + type WorkflowAction, + type WorkflowIteratorAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { buildRetryIteratorStepInfos } from 'src/modules/workflow/workflow-runner/utils/build-retry-iterator-step-infos.util'; + +describe('buildRetryIteratorStepInfos', () => { + const iteratorStep = createMockIteratorStep( + 'iterator', + ['post'], + ['body'], + ) as WorkflowIteratorAction; + + const steps: WorkflowAction[] = [ + iteratorStep, + createMockCodeStep('body', ['iterator']), + createMockCodeStep('post'), + ]; + + it('restores an iterator that failed mid-loop to RUNNING with its cursor preserved', () => { + const iteratorStepInfo: WorkflowRunStepInfo = { + status: StepStatus.FAILED, + result: { + currentItemIndex: 2, + currentItem: 'item-2', + hasProcessedAllItems: false, + }, + error: 'ended before completion', + }; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryIteratorStepInfos({ + iteratorStep, + iteratorStepInfo, + steps, + }); + + expect(stepIdsToRetry).toEqual([]); + expect(stepInfosToUpdate).toEqual({ + iterator: { + status: StepStatus.RUNNING, + result: { + currentItemIndex: 2, + currentItem: 'item-2', + hasProcessedAllItems: false, + }, + error: undefined, + }, + }); + }); + + it('restarts the whole loop when the iterator itself failed', () => { + const iteratorStepInfo: WorkflowRunStepInfo = { + status: StepStatus.FAILED, + error: 'invalid items input', + }; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryIteratorStepInfos({ + iteratorStep, + iteratorStepInfo, + steps, + }); + + expect(stepIdsToRetry).toEqual(['iterator']); + expect(stepInfosToUpdate).toEqual({ + body: { status: StepStatus.NOT_STARTED }, + iterator: { status: StepStatus.NOT_STARTED }, + }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-step-infos.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-step-infos.util.spec.ts new file mode 100644 index 0000000000..d52fcd881c --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/build-retry-step-infos.util.spec.ts @@ -0,0 +1,118 @@ +import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow'; + +import { + createMockCodeStep, + createMockIteratorStep, +} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { buildRetryStepInfos } from 'src/modules/workflow/workflow-runner/utils/build-retry-step-infos.util'; + +describe('buildRetryStepInfos', () => { + it('resets a plain failed step to NOT_STARTED and marks it for retry', () => { + const steps: WorkflowAction[] = [ + createMockCodeStep('step-1', ['step-2']), + createMockCodeStep('step-2'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + 'step-1': { status: StepStatus.SUCCESS }, + 'step-2': { status: StepStatus.FAILED, error: 'boom' }, + }; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryStepInfos({ + steps, + stepInfos, + }); + + expect(stepIdsToRetry).toEqual(['step-2']); + expect(stepInfosToUpdate).toEqual({ + 'step-2': { status: StepStatus.NOT_STARTED }, + }); + }); + + it('leaves non-failed steps untouched', () => { + const steps: WorkflowAction[] = [ + createMockCodeStep('success'), + createMockCodeStep('failed-safely'), + createMockCodeStep('skipped'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + success: { status: StepStatus.SUCCESS }, + 'failed-safely': { status: StepStatus.FAILED_SAFELY }, + skipped: { status: StepStatus.SKIPPED }, + }; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryStepInfos({ + steps, + stepInfos, + }); + + expect(stepIdsToRetry).toEqual([]); + expect(stepInfosToUpdate).toEqual({}); + }); + + it('restores an iterator that failed mid-loop to RUNNING and resets the failed loop body step', () => { + const steps: WorkflowAction[] = [ + createMockIteratorStep('iterator', ['post'], ['body']), + createMockCodeStep('body', ['iterator']), + createMockCodeStep('post'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + iterator: { + status: StepStatus.FAILED, + result: { + currentItemIndex: 2, + currentItem: 'item-2', + hasProcessedAllItems: false, + }, + error: 'ended before completion', + }, + body: { status: StepStatus.FAILED, error: 'boom' }, + post: { status: StepStatus.NOT_STARTED }, + }; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryStepInfos({ + steps, + stepInfos, + }); + + expect(stepIdsToRetry).toEqual(['body']); + expect(stepInfosToUpdate.iterator).toEqual({ + status: StepStatus.RUNNING, + result: { + currentItemIndex: 2, + currentItem: 'item-2', + hasProcessedAllItems: false, + }, + error: undefined, + }); + expect(stepInfosToUpdate.body).toEqual({ status: StepStatus.NOT_STARTED }); + }); + + it('resets the whole loop when the iterator itself failed', () => { + const steps: WorkflowAction[] = [ + createMockIteratorStep('iterator', ['post'], ['body']), + createMockCodeStep('body', ['iterator']), + createMockCodeStep('post'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + iterator: { status: StepStatus.FAILED, error: 'invalid items input' }, + body: { status: StepStatus.NOT_STARTED }, + post: { status: StepStatus.NOT_STARTED }, + }; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryStepInfos({ + steps, + stepInfos, + }); + + expect(stepIdsToRetry).toEqual(['iterator']); + expect(stepInfosToUpdate.iterator).toEqual({ + status: StepStatus.NOT_STARTED, + }); + expect(stepInfosToUpdate.body).toEqual({ status: StepStatus.NOT_STARTED }); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/get-runnable-step-ids.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/get-runnable-step-ids.util.spec.ts new file mode 100644 index 0000000000..183bd70bc9 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/__tests__/get-runnable-step-ids.util.spec.ts @@ -0,0 +1,81 @@ +import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow'; + +import { + createMockCodeStep, + createMockIteratorStep, +} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { getRunnableStepIds } from 'src/modules/workflow/workflow-runner/utils/get-runnable-step-ids.util'; + +describe('getRunnableStepIds', () => { + it('returns NOT_STARTED steps whose parents are satisfied', () => { + const steps: WorkflowAction[] = [ + createMockCodeStep('step-1', ['step-2']), + createMockCodeStep('step-2', ['step-3']), + createMockCodeStep('step-3'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + 'step-1': { status: StepStatus.SUCCESS }, + 'step-2': { status: StepStatus.NOT_STARTED }, + 'step-3': { status: StepStatus.NOT_STARTED }, + }; + + expect(getRunnableStepIds({ steps, stepInfos })).toEqual(['step-2']); + }); + + it('includes entry steps with no parents', () => { + const steps: WorkflowAction[] = [createMockCodeStep('entry')]; + + const stepInfos: WorkflowRunStepInfos = { + entry: { status: StepStatus.NOT_STARTED }, + }; + + expect(getRunnableStepIds({ steps, stepInfos })).toEqual(['entry']); + }); + + it('excludes steps that have already started', () => { + const steps: WorkflowAction[] = [createMockCodeStep('done')]; + + const stepInfos: WorkflowRunStepInfos = { + done: { status: StepStatus.SUCCESS }, + }; + + expect(getRunnableStepIds({ steps, stepInfos })).toEqual([]); + }); + + it('excludes loop-interior steps and keeps the iterator itself', () => { + const steps: WorkflowAction[] = [ + createMockIteratorStep('iterator', ['post'], ['body']), + createMockCodeStep('body', ['iterator']), + createMockCodeStep('post'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + iterator: { status: StepStatus.NOT_STARTED }, + body: { status: StepStatus.NOT_STARTED }, + post: { status: StepStatus.NOT_STARTED }, + }; + + expect(getRunnableStepIds({ steps, stepInfos })).toEqual(['iterator']); + }); + + it('includes a parallel branch that never started while a sibling failed and was reset', () => { + const steps: WorkflowAction[] = [ + createMockCodeStep('root', ['branch-a', 'branch-b']), + createMockCodeStep('branch-a'), + createMockCodeStep('branch-b'), + ]; + + const stepInfos: WorkflowRunStepInfos = { + root: { status: StepStatus.SUCCESS }, + 'branch-a': { status: StepStatus.NOT_STARTED }, + 'branch-b': { status: StepStatus.NOT_STARTED }, + }; + + expect(getRunnableStepIds({ steps, stepInfos })).toEqual([ + 'branch-a', + 'branch-b', + ]); + }); +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-iterator-step-infos.util.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-iterator-step-infos.util.ts new file mode 100644 index 0000000000..6046902245 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-iterator-step-infos.util.ts @@ -0,0 +1,64 @@ +import { isDefined } from 'twenty-shared/utils'; +import { StepStatus, type WorkflowRunStepInfo } from 'twenty-shared/workflow'; + +import { type WorkflowIteratorResult } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-result.type'; +import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util'; +import { getIteratorInitialLoopStepIds } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-iterator-initial-loop-step-ids.util'; +import { + type WorkflowAction, + type WorkflowIteratorAction, +} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +type BuildRetryIteratorStepInfosResult = { + stepInfosToUpdate: Record; + stepIdsToRetry: string[]; +}; + +export const buildRetryIteratorStepInfos = ({ + iteratorStep, + iteratorStepInfo, + steps, +}: { + iteratorStep: WorkflowIteratorAction; + iteratorStepInfo: WorkflowRunStepInfo; + steps: WorkflowAction[]; +}): BuildRetryIteratorStepInfosResult => { + const iteratorResult = iteratorStepInfo.result as + | WorkflowIteratorResult + | undefined; + + const failedMidLoop = + isDefined(iteratorResult) && iteratorResult.hasProcessedAllItems === false; + + if (failedMidLoop) { + return { + stepInfosToUpdate: { + [iteratorStep.id]: { + ...iteratorStepInfo, + status: StepStatus.RUNNING, + error: undefined, + }, + }, + stepIdsToRetry: [], + }; + } + + const loopStepIds = getAllStepIdsInLoop({ + iteratorStepId: iteratorStep.id, + initialLoopStepIds: getIteratorInitialLoopStepIds(iteratorStep), + steps, + }); + + const stepInfosToUpdate: Record = {}; + + for (const loopStepId of loopStepIds) { + stepInfosToUpdate[loopStepId] = { status: StepStatus.NOT_STARTED }; + } + + stepInfosToUpdate[iteratorStep.id] = { status: StepStatus.NOT_STARTED }; + + return { + stepInfosToUpdate, + stepIdsToRetry: [iteratorStep.id], + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-step-infos.util.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-step-infos.util.ts new file mode 100644 index 0000000000..9dc40777af --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/build-retry-step-infos.util.ts @@ -0,0 +1,51 @@ +import { + StepStatus, + type WorkflowRunStepInfo, + type WorkflowRunStepInfos, +} from 'twenty-shared/workflow'; + +import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { buildRetryIteratorStepInfos } from 'src/modules/workflow/workflow-runner/utils/build-retry-iterator-step-infos.util'; + +type BuildRetryStepInfosResult = { + stepInfosToUpdate: Record; + stepIdsToRetry: string[]; +}; + +export const buildRetryStepInfos = ({ + steps, + stepInfos, +}: { + steps: WorkflowAction[]; + stepInfos: WorkflowRunStepInfos; +}): BuildRetryStepInfosResult => { + const stepInfosToUpdate: Record = {}; + const stepIdsToRetry: string[] = []; + + for (const step of steps) { + const stepInfo = stepInfos[step.id]; + + if (stepInfo?.status !== StepStatus.FAILED) { + continue; + } + + if (isWorkflowIteratorAction(step)) { + const iteratorRetry = buildRetryIteratorStepInfos({ + iteratorStep: step, + iteratorStepInfo: stepInfo, + steps, + }); + + Object.assign(stepInfosToUpdate, iteratorRetry.stepInfosToUpdate); + stepIdsToRetry.push(...iteratorRetry.stepIdsToRetry); + + continue; + } + + stepInfosToUpdate[step.id] = { status: StepStatus.NOT_STARTED }; + stepIdsToRetry.push(step.id); + } + + return { stepInfosToUpdate, stepIdsToRetry }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/utils/get-runnable-step-ids.util.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/get-runnable-step-ids.util.ts new file mode 100644 index 0000000000..c6e9b4e9b6 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/utils/get-runnable-step-ids.util.ts @@ -0,0 +1,49 @@ +import { type WorkflowRunStepInfos } from 'twenty-shared/workflow'; + +import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; +import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util'; +import { isWorkflowIteratorAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/guards/is-workflow-iterator-action.guard'; +import { getAllStepIdsInLoop } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-all-step-ids-in-loop.util'; +import { getIteratorInitialLoopStepIds } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/utils/get-iterator-initial-loop-step-ids.util'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; + +export const getRunnableStepIds = ({ + steps, + stepInfos, +}: { + steps: WorkflowAction[]; + stepInfos: WorkflowRunStepInfos; +}): string[] => { + const loopInteriorStepIds = new Set(); + + for (const step of steps) { + if (!isWorkflowIteratorAction(step)) { + continue; + } + + const loopStepIds = getAllStepIdsInLoop({ + iteratorStepId: step.id, + initialLoopStepIds: getIteratorInitialLoopStepIds(step), + steps, + }); + + for (const loopStepId of loopStepIds) { + loopInteriorStepIds.add(loopStepId); + } + } + + return steps + .filter((step) => { + if (loopInteriorStepIds.has(step.id)) { + return false; + } + + return shouldExecuteStep({ + step, + steps, + stepInfos, + workflowRunStatus: WorkflowRunStatus.RUNNING, + }); + }) + .map((step) => step.id); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts index 270de7307e..962322c2ed 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts @@ -27,6 +27,8 @@ import { } from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception'; import { RunWorkflowJob } from 'src/modules/workflow/workflow-runner/jobs/run-workflow.job'; import { type RunWorkflowJobData } from 'src/modules/workflow/workflow-runner/types/run-workflow-job-data.type'; +import { buildRetryStepInfos } from 'src/modules/workflow/workflow-runner/utils/build-retry-step-infos.util'; +import { getRunnableStepIds } from 'src/modules/workflow/workflow-runner/utils/get-runnable-step-ids.util'; import { WorkflowRunEnqueueJob, type WorkflowRunEnqueueJobData, @@ -209,50 +211,66 @@ export class WorkflowRunnerWorkspaceService { ]; if (!stoppableStatuses.includes(workflowRun.status)) { - throw new WorkflowRunException( - 'Workflow run cannot be stopped', - WorkflowRunExceptionCode.INVALID_OPERATION, - { - userFriendlyMessage: msg`Workflow run cannot be stopped in its current status`, - }, - ); + return { + id: workflowRun.id, + status: workflowRun.status, + }; } + const wasNotStarted = workflowRun.status === WorkflowRunStatus.NOT_STARTED; + let newStatus: WorkflowRunStatus; - const stepInfos = workflowRun.state.stepInfos; - const steps = workflowRun.state.flow.steps; - - if (workflowHasRunningSteps({ stepInfos, steps })) { - const stoppedIteratorStepInfos = setAllIteratorsStepInfosAsStopped({ - stepInfos, - steps, - }); - - const mergedStepInfos = { - ...stepInfos, - ...stoppedIteratorStepInfos, - }; - - await this.workflowRunWorkspaceService.updateWorkflowRun({ - workflowRunId, - workspaceId, - partialUpdate: { - status: WorkflowRunStatus.STOPPING, - state: { - ...workflowRun.state, - stepInfos: mergedStepInfos, - }, - }, - }); - newStatus = WorkflowRunStatus.STOPPING; - } else { + if (!isDefined(workflowRun.state)) { await this.workflowRunWorkspaceService.endWorkflowRun({ workflowRunId, workspaceId, status: WorkflowRunStatus.STOPPED, }); newStatus = WorkflowRunStatus.STOPPED; + } else { + const stepInfos = workflowRun.state.stepInfos; + const steps = workflowRun.state.flow.steps; + + if (workflowHasRunningSteps({ stepInfos, steps })) { + const stoppedIteratorStepInfos = setAllIteratorsStepInfosAsStopped({ + stepInfos, + steps, + }); + + const mergedStepInfos = { + ...stepInfos, + ...stoppedIteratorStepInfos, + }; + + await this.workflowRunWorkspaceService.updateWorkflowRun({ + workflowRunId, + workspaceId, + partialUpdate: { + status: WorkflowRunStatus.STOPPING, + state: { + ...workflowRun.state, + stepInfos: mergedStepInfos, + }, + }, + }); + newStatus = WorkflowRunStatus.STOPPING; + } else { + await this.workflowRunWorkspaceService.endWorkflowRun({ + workflowRunId, + workspaceId, + status: WorkflowRunStatus.STOPPED, + }); + newStatus = WorkflowRunStatus.STOPPED; + } + } + + // Release the cached not-started slot only after the stop has been + // persisted, so a persistence failure can't desync the throttle counter. + if (wasNotStarted) { + await this.workflowThrottlingWorkspaceService.decreaseWorkflowRunNotStartedCount( + workspaceId, + ); } return { @@ -261,6 +279,100 @@ export class WorkflowRunnerWorkspaceService { }; } + async retryWorkflowRun(workspaceId: string, workflowRunId: string) { + const workflowRun = + await this.workflowRunWorkspaceService.getWorkflowRunOrFail({ + workflowRunId, + workspaceId, + }); + + if (workflowRun.status !== WorkflowRunStatus.FAILED) { + return { + id: workflowRun.id, + status: workflowRun.status, + }; + } + + if (!isDefined(workflowRun.state)) { + throw new WorkflowRunException( + 'Cannot retry a workflow run without state', + WorkflowRunExceptionCode.WORKFLOW_RUN_INVALID, + ); + } + + const steps = workflowRun.state.flow.steps; + + const { stepInfosToUpdate, stepIdsToRetry } = buildRetryStepInfos({ + steps, + stepInfos: workflowRun.state.stepInfos, + }); + + const mergedStepInfos = { + ...workflowRun.state.stepInfos, + ...stepInfosToUpdate, + }; + + const runnableStepIds = getRunnableStepIds({ + steps, + stepInfos: mergedStepInfos, + }); + + const stepIdsToRun = Array.from( + new Set([...stepIdsToRetry, ...runnableStepIds]), + ); + + if (stepIdsToRun.length === 0) { + return { + id: workflowRun.id, + status: workflowRun.status, + }; + } + + await this.workflowRunWorkspaceService.updateWorkflowRun({ + workflowRunId, + workspaceId, + partialUpdate: { + status: WorkflowRunStatus.RUNNING, + endedAt: null, + state: { + ...workflowRun.state, + stepInfos: mergedStepInfos, + workflowRunError: undefined, + }, + }, + }); + + try { + await this.messageQueueService.add( + RunWorkflowJob.name, + { + workspaceId, + workflowRunId, + stepIdsToRetry: stepIdsToRun, + }, + ); + } catch (error) { + // The job couldn't be enqueued: revert to the previous failed state so + // the run isn't left stuck as RUNNING without a worker job. + await this.workflowRunWorkspaceService.updateWorkflowRun({ + workflowRunId, + workspaceId, + partialUpdate: { + status: WorkflowRunStatus.FAILED, + endedAt: workflowRun.endedAt, + state: workflowRun.state, + }, + }); + + throw error; + } + + return { + id: workflowRun.id, + status: WorkflowRunStatus.RUNNING, + }; + } + private async checkHardThrottleLimit(workspaceId: string): Promise { try { await this.workflowThrottlingWorkspaceService.throttleOrThrowIfHardLimitReached( diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts index 6203079f1d..8b5550cf16 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts @@ -189,6 +189,13 @@ export class WorkflowTriggerWorkspaceService { ); } + async retryWorkflowRun(workflowRunId: string, workspaceId: string) { + return this.workflowRunnerWorkspaceService.retryWorkflowRun( + workspaceId, + workflowRunId, + ); + } + private async performActivationSteps( workflow: WorkflowWorkspaceEntity, workflowVersion: WorkflowVersionWorkspaceEntity,