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
This commit is contained in:
+4
@@ -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]: (
|
||||
<StopWorkflowRunSingleRecordCommand />
|
||||
),
|
||||
[EngineComponentKey.RETRY_WORKFLOW_RUN]: (
|
||||
<RetryWorkflowRunSingleRecordCommand />
|
||||
),
|
||||
[EngineComponentKey.USE_AS_DRAFT_WORKFLOW_VERSION]: (
|
||||
<UseAsDraftWorkflowVersionSingleRecordCommand />
|
||||
),
|
||||
|
||||
+11
@@ -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 <HeadlessEngineCommandWrapperEffect execute={execute} />;
|
||||
};
|
||||
+3
-28
@@ -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 <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
|
||||
return <HeadlessEngineCommandWrapperEffect execute={execute} />;
|
||||
};
|
||||
|
||||
+34
@@ -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<unknown>,
|
||||
) => {
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user