Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,9 @@ export class WorkflowsExecutionEnginePlugin
spaceId,
fakeRequest: request,
});

// Same idea as cancel: nudge TM so the resume task runs as soon as possible
await workflowTaskManager.forceRunIdleTasks(executionId);
};

const workflowEventLoggerService = new WorkflowEventLoggerService(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,5 +261,28 @@ describe('ResumeExecutionButton', () => {
expect(screen.getByTestId('provideActionButton')).not.toBeDisabled();
});
});

it('re-enables the button when waitingStepExecutionId changes after a successful submit', async () => {
const { rerender } = render(
<TestWrapper>
<ResumeExecutionButton {...defaultProps} waitingStepExecutionId="wait-step-exec-1" />
</TestWrapper>
);
fireEvent.click(screen.getByTestId('provideActionButton'));
await waitFor(() => expect(capturedOnSubmit).toBeDefined());
act(() => {
capturedOnSubmit!({ stepInputs: { approved: true } });
});
await waitFor(() => {
expect(screen.getByTestId('provideActionButton')).toBeDisabled();
});

rerender(
<TestWrapper>
<ResumeExecutionButton {...defaultProps} waitingStepExecutionId="wait-step-exec-2" />
</TestWrapper>
);
expect(screen.getByTestId('provideActionButton')).not.toBeDisabled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ interface ResumeExecutionButtonProps {
resumeSchema?: JsonModelSchemaType;
/** When true, opens the input modal immediately on mount */
autoOpen?: boolean;
/** Step execution document id for the active waitForInput pause; when it changes, re-enable after a prior submit */
waitingStepExecutionId?: string;
}

export const ResumeExecutionButton: React.FC<ResumeExecutionButtonProps> = ({
executionId,
resumeMessage,
resumeSchema,
autoOpen = false,
waitingStepExecutionId,
}) => {
const { notifications } = useKibana().services;
const workflowsApi = useWorkflowsApi();
Expand All @@ -49,6 +52,10 @@ export const ResumeExecutionButton: React.FC<ResumeExecutionButtonProps> = ({
if (autoOpen) setIsModalOpen(true);
}, [autoOpen]);

useEffect(() => {
setIsSubmitted(false);
}, [waitingStepExecutionId]);

const contextOverride = useMemo<ContextOverrideData | undefined>(() => {
if (!resumeSchema) return undefined;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ describe('WorkflowExecutionDetail - resume input resolution', () => {
} as const;

// Polling returns lightweight steps without input (includeInput: false).
// Resume copy is loaded via useStepExecution(pausedStepId) — not from workflowDefinition.
// Resume copy is loaded via useStepExecution(waitingStepExecutionId) — not from workflowDefinition.
// We still embed nested `if` / `foreach` shapes below so regressions in flat stepExecutions
// detection do not go unnoticed when YAML nesting matches real workflows.
const makeWaitingExecution = (options?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export const WorkflowExecutionDetail: React.FC<WorkflowExecutionDetailProps> = R
const { childExecutions, isLoading: isLoadingChildExecutions } =
useChildWorkflowExecutions(workflowExecution);

// Find the lightweight paused step (polling uses includeInput: false)
const pausedStepId = useMemo(() => {
// Step execution row id for the active waitForInput pause (polling uses includeInput: false)
const waitingStepExecutionId = useMemo(() => {
if (!workflowExecution || workflowExecution.status !== ExecutionStatus.WAITING_FOR_INPUT) {
return undefined;
}
Expand All @@ -124,7 +124,7 @@ export const WorkflowExecutionDetail: React.FC<WorkflowExecutionDetailProps> = R
// consistent with every other step types
const { data: pausedStepFullData } = useStepExecution(
executionId,
pausedStepId,
waitingStepExecutionId,
ExecutionStatus.WAITING_FOR_INPUT
);

Expand Down Expand Up @@ -287,6 +287,7 @@ export const WorkflowExecutionDetail: React.FC<WorkflowExecutionDetailProps> = R
resumeMessage={resumeMessage}
resumeSchema={resumeSchema}
shouldAutoResume={shouldAutoResume}
waitingStepExecutionId={waitingStepExecutionId}
childWorkflowExecution={selectedStepChildExecution}
parentWorkflowExecution={parentWorkflowExecution}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface WorkflowExecutionOverviewProps {
resumeMessage?: string;
resumeSchema?: JsonModelSchemaType;
shouldAutoResume?: boolean;
waitingStepExecutionId?: string;
}

const formatExecutionDate = (date: string) => {
Expand Down Expand Up @@ -60,6 +61,7 @@ export const WorkflowExecutionOverview = React.memo<WorkflowExecutionOverviewPro
resumeMessage,
resumeSchema,
shouldAutoResume = false,
waitingStepExecutionId,
}) => {
const { euiTheme } = useEuiTheme();

Expand Down Expand Up @@ -199,6 +201,7 @@ export const WorkflowExecutionOverview = React.memo<WorkflowExecutionOverviewPro
resumeMessage={resumeMessage}
resumeSchema={resumeSchema}
autoOpen={shouldAutoResume}
waitingStepExecutionId={waitingStepExecutionId}
/>
</EuiFlexItem>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface WorkflowStepExecutionDetailsProps {
resumeMessage?: string;
resumeSchema?: JsonModelSchemaType;
shouldAutoResume?: boolean;
waitingStepExecutionId?: string;
/** When the step is workflow.execute, the child workflow execution (to link to) */
childWorkflowExecution?: ChildWorkflowExecutionItem;
/** When viewing a step that belongs to a nested execution, the parent workflow execution (to link to) */
Expand All @@ -59,6 +60,7 @@ export const WorkflowStepExecutionDetails = React.memo<WorkflowStepExecutionDeta
resumeMessage,
resumeSchema,
shouldAutoResume = false,
waitingStepExecutionId,
childWorkflowExecution,
parentWorkflowExecution,
}) => {
Expand Down Expand Up @@ -187,6 +189,7 @@ export const WorkflowStepExecutionDetails = React.memo<WorkflowStepExecutionDeta
resumeMessage={resumeMessage}
resumeSchema={resumeSchema}
shouldAutoResume={shouldAutoResume}
waitingStepExecutionId={waitingStepExecutionId}
/>
);
}
Expand Down Expand Up @@ -281,6 +284,7 @@ export const WorkflowStepExecutionDetails = React.memo<WorkflowStepExecutionDeta
resumeMessage={resumeMessage}
resumeSchema={resumeSchema}
autoOpen={shouldAutoResume}
waitingStepExecutionId={stepExecution?.id}
/>
<EuiSpacer size="m" />
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,12 @@ describe('getExecutionState', () => {
],
},
stepExecutions: [
{ stepId: 'approve', status: ExecutionStatus.WAITING_FOR_INPUT, scopeStack: [] },
{
id: 'step-exec-approve',
stepId: 'approve',
status: ExecutionStatus.WAITING_FOR_INPUT,
scopeStack: [],
},
],
});

Expand All @@ -167,6 +172,7 @@ describe('getExecutionState', () => {

expect(state?.status).toBe(ExecutionStatus.WAITING_FOR_INPUT);
expect(state?.waiting_input).toEqual({
step_execution_id: 'step-exec-approve',
message: 'Please approve this request',
schema: {
type: 'object',
Expand Down Expand Up @@ -196,7 +202,12 @@ describe('getExecutionState', () => {
],
},
stepExecutions: [
{ stepId: 'ask', status: ExecutionStatus.WAITING_FOR_INPUT, scopeStack: [] },
{
id: 'step-exec-ask',
stepId: 'ask',
status: ExecutionStatus.WAITING_FOR_INPUT,
scopeStack: [],
},
],
});

Expand All @@ -207,6 +218,7 @@ describe('getExecutionState', () => {
});

expect(state?.waiting_input).toEqual({
step_execution_id: 'step-exec-ask',
message: 'Provide input',
});
});
Expand Down Expand Up @@ -235,7 +247,12 @@ describe('getExecutionState', () => {
],
},
stepExecutions: [
{ stepId: 'nested_ask', status: ExecutionStatus.WAITING_FOR_INPUT, scopeStack: [] },
{
id: 'step-exec-nested',
stepId: 'nested_ask',
status: ExecutionStatus.WAITING_FOR_INPUT,
scopeStack: [],
},
],
});

Expand All @@ -245,6 +262,51 @@ describe('getExecutionState', () => {
workflowApi,
});

expect(state?.waiting_input).toEqual({
step_execution_id: 'step-exec-nested',
message: 'Approve item',
});
});

it('uses the waiting step execution id when the same stepId has a prior completed instance (loop)', async () => {
const workflowApi = createWorkflowApi();
workflowApi.getWorkflowExecution = jest.fn().mockResolvedValue({
status: ExecutionStatus.WAITING_FOR_INPUT,
workflowId: 'wf-loop',
startedAt: '2026-01-01T00:00:00.000Z',
workflowDefinition: {
name: 'Loop ask',
steps: [
{
name: 'ask_for_input',
type: 'waitForInput',
with: { message: 'Approve item' },
},
],
},
stepExecutions: [
{
id: 'step-exec-iter-0-done',
stepId: 'ask_for_input',
status: ExecutionStatus.COMPLETED,
scopeStack: [],
},
{
id: 'step-exec-iter-1-waiting',
stepId: 'ask_for_input',
status: ExecutionStatus.WAITING_FOR_INPUT,
scopeStack: [],
},
],
});

const state = await getExecutionState({
executionId: 'exec-loop',
spaceId: 'default',
workflowApi,
});

expect(state?.waiting_input?.step_execution_id).toBe('step-exec-iter-1-waiting');
expect(state?.waiting_input?.message).toBe('Approve item');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { getWorkflowOutput } from './get_workflow_output';
type WorkflowApi = WorkflowsServerPluginSetup['management'];

export interface WaitingInputContext {
/** Step execution document id for the paused `waitForInput` instance. Compare across status polls. */
step_execution_id: string;
/** Human-readable prompt from the waitForInput step's `with.message`. */
message?: string;
/** JSON Schema describing the expected input, from the step's `with.schema`. */
Expand All @@ -30,7 +32,7 @@ export interface WorkflowExecutionState {
workflow_name?: string;
/** Present when status is FAILED; contains the workflow error message. */
error_message?: string;
/** Present when status is WAITING_FOR_INPUT; describes what input is needed to resume. */
/** Present when status is WAITING_FOR_INPUT. */
waiting_input?: WaitingInputContext;
}

Expand Down Expand Up @@ -83,12 +85,11 @@ export const getExecutionState = async ({
const stepConfig =
step?.type === 'waitForInput' ? (step as WaitForInputStep).with : undefined;
const waitContext: WaitingInputContext = {
step_execution_id: waitingStep.id,
...(stepConfig?.message && { message: stepConfig.message }),
...(stepConfig?.schema && { schema: stepConfig.schema as Record<string, unknown> }),
};
if (waitContext.message !== undefined || waitContext.schema !== undefined) {
state.waiting_input = waitContext;
}
state.waiting_input = waitContext;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ export const getWorkflowToolType = ({
- If the workflow wasn't completed, a workflow execution ID will be returned.
- The ${platformCoreTools.getWorkflowExecutionStatus} tool can be used later to check the status of the workflow execution.
- If the workflow returns with status "waiting_for_input", it is paused and requires human input to continue.
The response will include a "waiting_input" object with a "message" (what the workflow is asking for)
and an optional "schema" (JSON Schema describing the expected input fields).
The response will include a "waiting_input" object with "step_execution_id" (id of the paused step execution instance),
a "message" (what the workflow is asking for), and an optional "schema" (JSON Schema describing the expected input fields).
Use the ${platformCoreTools.resumeWorkflowExecution} tool to provide the required input and resume the workflow.

`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,14 @@ export const resumeWorkflowExecutionTool = ({

Use this tool when a workflow execution returned with status "waiting_for_input".
The execution's "waiting_input" field describes what input is expected (message and optional schema).

Provide the executionId and the required input object to resume the workflow.
After resuming, use ${platformCoreTools.getWorkflowExecutionStatus} to check whether the workflow completed or paused again (e.g. inside a loop).

**Important - read after resume:** a successful call means resume was **accepted and scheduled** in the engine.
The execution record can **lag**: you may still briefly see the same "waiting_for_input" and the same form right after this tool returns. **Do not** tell the user there is another HITL step, a loop or a second approval round unless you have **confirmed** a genuinely new pause (clear evidence such as a new step, changed context, or repeated status checks that rule out a stale snapshot).

**What you should do:** call ${platformCoreTools.getWorkflowExecutionStatus} on the same executionId. Compare **waiting_input.step_execution_id** between polls: if it **changes** while still waiting_for_input, that is evidence of a **new** HITL step; if it is the **same** id immediately after resume, treat it as likely stale until status moves on. Stop once the status clearly updates (e.g. completed, failed, running) or after **up to ~5 status polls** without any change.

**If status has not changed after those polls:** tell the user their resume was **submitted**, but you **could not confirm** the new execution state from Kibana yet - do **not** invent a second approval workflow.
`),
schema: resumeWorkflowExecutionSchema,
handler: async ({ executionId, input }, { spaceId, request }) => {
Expand Down
Loading