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:
Thomas Trompette
2026-06-12 17:25:53 +02:00
committed by GitHub
parent 79f7c96939
commit ba94c3b857
25 changed files with 857 additions and 95 deletions
@@ -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 />
),
@@ -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} />;
};
@@ -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} />;
};
@@ -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 };
};