mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
fix(editor): Reconcile runs that parked into wait on push reconnect
Exclude `waiting` from the active-status query used to detect a stranded run on reconnect. A run that parks into wait server-side (`data.waitTill`) sends its own push, so a disconnect across that transition strands the running spinner exactly like a dropped finish. With `waiting` excluded from the query, such a run falls out of the "still running" set and is routed through `reconcileFinishedExecution`, whose existing waitTill branch clears `activeExecutionId` and renders the wait state — mirroring the normal push path. `new` and `running` remain, so genuinely executing runs are still left untouched. Adds a regression test asserting a run parked into wait while disconnected has its spinner cleared and the wait execution rendered after reconnect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b01313c0a9
commit
22ef44cf96
|
|
@ -95,6 +95,24 @@ function makeFinishedExecution(executionId: string): IExecutionResponse {
|
|||
};
|
||||
}
|
||||
|
||||
function makeWaitingExecution(executionId: string): IExecutionResponse {
|
||||
const data = createRunExecutionData({ resultData: { runData: {} } });
|
||||
// A run parked into wait server-side carries `waitTill` on its run data; the
|
||||
// reconciliation routes this through the wait branch (mirrors the push path).
|
||||
data.waitTill = new Date('2999-01-01T00:00:00.000Z');
|
||||
return {
|
||||
id: executionId,
|
||||
workflowId,
|
||||
finished: false,
|
||||
mode: 'manual',
|
||||
status: 'waiting',
|
||||
startedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
workflowData: mock<IWorkflowDb>({ id: workflowId, nodes: [], connections: {} }),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('reconcileExecutionStateOnReconnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -118,7 +136,7 @@ describe('reconcileExecutionStateOnReconnect', () => {
|
|||
|
||||
expect(getActiveExecutionsMock).toHaveBeenCalledWith(expect.anything(), {
|
||||
workflowId,
|
||||
status: ['new', 'running', 'waiting'],
|
||||
status: ['new', 'running'],
|
||||
});
|
||||
// State reconciled to server truth: spinner cleared, final run displayed.
|
||||
expect(executionStateStore.isWorkflowRunning).toBe(false);
|
||||
|
|
@ -126,6 +144,30 @@ describe('reconcileExecutionStateOnReconnect', () => {
|
|||
expect(executionStateStore.displayedExecutionId).toBe(executionId);
|
||||
});
|
||||
|
||||
it('clears the stranded spinner when the tracked run parked into wait while disconnected', async () => {
|
||||
const executionId = 'exec-parked-offline';
|
||||
const executionStateStore = startRunningExecution(executionId);
|
||||
expect(executionStateStore.isWorkflowRunning).toBe(true);
|
||||
|
||||
// `waiting` runs are excluded from the active-status query, so the server
|
||||
// does not return the parked run — its `waitTill` push was dropped.
|
||||
getActiveExecutionsMock.mockResolvedValue([]);
|
||||
vi.spyOn(useWorkflowsStore(), 'fetchExecutionDataById').mockResolvedValue(
|
||||
makeWaitingExecution(executionId),
|
||||
);
|
||||
|
||||
await reconcileExecutionStateOnReconnect(options);
|
||||
|
||||
expect(getActiveExecutionsMock).toHaveBeenCalledWith(expect.anything(), {
|
||||
workflowId,
|
||||
status: ['new', 'running'],
|
||||
});
|
||||
// Reconciled to the wait state: spinner cleared, wait execution displayed.
|
||||
expect(executionStateStore.isWorkflowRunning).toBe(false);
|
||||
expect(executionStateStore.activeExecutionId).toBeUndefined();
|
||||
expect(executionStateStore.displayedExecutionId).toBe(executionId);
|
||||
});
|
||||
|
||||
it('leaves a genuinely running execution untouched on reconnect', async () => {
|
||||
const executionId = 'exec-still-running';
|
||||
const executionStateStore = startRunningExecution(executionId);
|
||||
|
|
|
|||
|
|
@ -7,22 +7,26 @@ import { reconcileFinishedExecution } from './executionRecovered';
|
|||
import type { PushHandlerOptions } from './types';
|
||||
|
||||
/**
|
||||
* Server-side statuses that still count as an in-flight run. If the execution
|
||||
* this document is tracking is not returned under one of these, it has reached
|
||||
* a terminal state on the server.
|
||||
* Server-side statuses for which the canvas should keep rendering a live,
|
||||
* executing run. `waiting` is deliberately excluded: a run that parks into wait
|
||||
* server-side (`data.waitTill`) sends its own push, so a disconnect across that
|
||||
* transition strands the running spinner just like a dropped finish. Excluding
|
||||
* `waiting` routes such a run through reconciliation, where the re-fetched
|
||||
* execution hits the existing waitTill branch and renders the wait state.
|
||||
*/
|
||||
const ACTIVE_EXECUTION_STATUSES: ExecutionStatus[] = ['new', 'running', 'waiting'];
|
||||
const RUNNING_EXECUTION_STATUSES: ExecutionStatus[] = ['new', 'running'];
|
||||
|
||||
/**
|
||||
* Reconciles the canvas execution state against server truth after a push
|
||||
* reconnect.
|
||||
*
|
||||
* If the client disconnects across the moment a run finishes (or a single
|
||||
* `executionFinished` frame is lost), that push is never delivered and the
|
||||
* canvas is stranded showing a running spinner. On reconnect we ask the server
|
||||
* which executions are still active for this workflow; if the one this document
|
||||
* is tracking is no longer among them, it finished while we were away, so we
|
||||
* re-fetch it and render the final state — which also clears the spinner.
|
||||
* If the client disconnects across the moment a run finishes or parks into wait
|
||||
* (or a single `executionFinished` frame is lost), that push is never delivered
|
||||
* and the canvas is stranded showing a running spinner. On reconnect we ask the
|
||||
* server which executions are still actively running for this workflow; if the
|
||||
* one this document is tracking is no longer among them, it finished or parked
|
||||
* while we were away, so we re-fetch it and render the final/wait state — which
|
||||
* also clears the spinner.
|
||||
*
|
||||
* A run that is genuinely still executing server-side is left untouched, so
|
||||
* live execution rendering during a normal, uninterrupted run is unaffected.
|
||||
|
|
@ -44,7 +48,7 @@ export async function reconcileExecutionStateOnReconnect(options: PushHandlerOpt
|
|||
try {
|
||||
const activeExecutions = await getActiveExecutions(rootStore.restApiContext, {
|
||||
workflowId,
|
||||
status: ACTIVE_EXECUTION_STATUSES,
|
||||
status: RUNNING_EXECUTION_STATUSES,
|
||||
});
|
||||
isStillActive = activeExecutions.some((execution) => execution.id === activeExecutionId);
|
||||
} catch {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user