Skip to content

Withdraw a dialog after a deadline

Answer in time approved or rejected No answer by the deadline request withdrawn
Answer in time approved or rejected No answer by the deadline request withdrawn
An answer before the deadline decides the approval; if the deadline comes first, the request is withdrawn.

Withdraw a request when nobody answers before its deadline. An answer finishes the approval; the deadline finishes it as withdrawn.

Save the two files as dialog-deadline.workflow.ts and dialog-deadline.workflow.case.ts.

After saving both files, run the test, then adapt the example to your system.

The dialog and a sleep run in a Promise.race: whichever settles first wins, and IdentityFlow cancels the other.

PROMISE.RACE fix the deadline flow.do · records approve vacation flow.dialog · PENDING approval deadline flow.sleep · SLEEPING decision first settled the slower step kept as CANCELED then settles first cancels
PROMISE.RACE fix the deadline flow.do · records approve vacation flow.dialog · PENDING approval deadline flow.sleep · SLEEPING decision first settled the slower step kept as CANCELED then settles first cancels
Whichever of the dialog and the sleep settles first decides the result; IdentityFlow cancels the other step and keeps it in the history as CANCELED.
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.

await flow.do('fix the deadline', () =>
  1. recorded, not recomputed

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.

flow.sleep('approval deadline', new Date(deadline), WITHDRAWN),
  1. the recorded date
  2. returned when it fires

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.