Skip to content

Withdraw a dialog after a deadline

A dialog waits until someone answers, however long that takes. In this recipe a request that nobody answers within a set number of hours is withdrawn. The dialog and a sleep run in a Promise.race: whichever settles first wins, and IdentityFlow cancels the other.

Save the two files as dialog-deadline.workflow.ts and dialog-deadline.workflow.case.ts. The host deploys only a workflow file’s default export, so one file holds one workflow; the test imports the same definition by name.

import * as v from '@identity-flow/sdk/valibot';
import { defineWorkflow } from '@identity-flow/sdk';
const Decision = v.object({ outcome: v.picklist(['approved', 'rejected']) });
const WITHDRAWN = { outcome: 'withdrawn' } as const;
/**
* A deadline is a dialog raced against a sleep. Whichever settles first wins;
* IdentityFlow cancels the loser and persists it as `CANCELED`, so the withdrawn
* dialog stays in the history instead of disappearing from it.
*
* The deadline is computed inside a step, so it is recorded once. Calling
* `Date.now()` in the workflow body would move the deadline forward on every
* replay and the sleep would never be due.
*/
export const deadlinedApproval = defineWorkflow(
{
name: 'deadlined-approval',
version: '1.0.0',
draft: false,
schema: v.object({ employeeId: v.string(), respondWithinHours: v.number() }),
},
async (flow) => {
const deadline = await flow.do('fix the deadline', () =>
new Date(Date.now() + flow.params.respondWithinHours * 3_600_000).toISOString(),
);
const decision = await Promise.race([
flow.dialog('approve vacation', { schema: Decision }, () => ({
params: { employeeId: flow.params.employeeId },
assignees: [{ providerId: 'acme', subject: 'role:vacation-approver' }],
})),
flow.sleep('approval deadline', new Date(deadline), WITHDRAWN),
]);
return { employeeId: flow.params.employeeId, outcome: decision.outcome };
},
);
// The host deploys only the default export, so one file holds one workflow. The
// test imports the named export; both are the same definition.
export default deadlinedApproval;

respondWithinHours is a start parameter, and the step fix the deadline turns it into a point in time. The deadline is computed inside flow.do, and that is what keeps it stable. On replay the workflow function runs again from the top. Date.now() in the workflow body would then return a later time on every run, and the deadline would keep moving. Inside the step it is computed once and read back from the event history afterwards.

To withdraw at a fixed date instead, take an ISO date as a start parameter and pass new Date(flow.params.deadline) to flow.sleep. Start parameters are stored with the instance, so that date does not change on replay and needs no extra step.

flow.sleep(name, until, value) accepts a Date and returns value when it fires. Both branches therefore have an outcome, so the result type is a union and the code after the race does not have to ask which branch it came from.

The loser of the race is persisted as CANCELED. The withdrawn dialog stays in the history with its assignees and its parameters, so it is visible later why no one answered.

import { workflowTest } from '@identity-flow/testing';
import { expect } from 'vitest';
import { deadlinedApproval } from './dialog-deadline.workflow';
const test = workflowTest({
workflow: deadlinedApproval,
accounts: {
requester: { providerId: 'acme', principals: ['user:e-2041'] },
approver: { providerId: 'acme', principals: ['role:vacation-approver'] },
},
});
test('keeps an answer that arrives in time', async ({ flow, accounts }) => {
const instance = await flow
.actAs(accounts.requester)
.start({ employeeId: 'e-2041', respondWithinHours: 24 });
const dialog = await flow.waitFor(instance).toHaveDialog('approve vacation');
await flow.actAs(accounts.approver).completeActivity(dialog, { outcome: 'approved' });
const completed = await flow.waitFor(instance).toBeCompleted();
expect(completed.data).toEqual({ employeeId: 'e-2041', outcome: 'approved' });
const observed = await flow.observe(instance);
expect(observed.steps).toContainEqual(
expect.objectContaining({ name: 'approval deadline', status: 'CANCELED' }),
);
});
test('withdraws the dialog once the deadline has passed', async ({ flow, accounts }) => {
const instance = await flow
.actAs(accounts.requester)
.start({ employeeId: 'e-2041', respondWithinHours: 0 });
const completed = await flow.waitFor(instance).toBeCompleted();
expect(completed.data).toEqual({ employeeId: 'e-2041', outcome: 'withdrawn' });
const observed = await flow.observe(instance);
expect(observed.steps).toContainEqual(
expect.objectContaining({ name: 'approve vacation', status: 'CANCELED' }),
);
});

The first test answers in time; the sleep is then CANCELED. The second passes respondWithinHours: 0, which puts the deadline at the moment the step runs. The sleep is due at once, and the dialog is CANCELED before anyone can answer it, so neither test has to wait.

Run the test with the Workflow Test distribution, as described in Before you start.

Next: Assign a dialog to a role for the dialog on its own, and Promise composition for how Promise.race and the other combinators behave in a workflow.