From c8ccb9343a111a833e1e3dd521b2c00de410195d Mon Sep 17 00:00:00 2001 From: James Martinez Date: Wed, 4 Feb 2026 12:04:58 -0600 Subject: [PATCH 1/4] Schedule a workflow with `availableAt` --- packages/docs/docs/workflows.mdx | 15 +++++++++++++++ packages/openworkflow/README.md | 1 + packages/openworkflow/client.test.ts | 17 +++++++++++++++++ packages/openworkflow/client.ts | 7 ++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/docs/docs/workflows.mdx b/packages/docs/docs/workflows.mdx index 556da090..f0828889 100644 --- a/packages/docs/docs/workflows.mdx +++ b/packages/docs/docs/workflows.mdx @@ -53,6 +53,21 @@ The `run()` method returns a handle immediately. The actual workflow execution happens in a worker process. This lets your application continue without waiting for the workflow to complete. +## Scheduling a Workflow + +You can schedule a workflow run for a specific time by passing `availableAt`: + +```ts +const runAt = new Date("2026-02-05T15:00:00.000Z"); +const handle = await sendWelcomeEmail.run( + { userId: "user_123" }, + { availableAt: runAt }, +); +``` + +The run stays `pending` until `availableAt` is reached, then workers can claim +it. If `availableAt` is in the past, the run is immediately available. + ## Waiting for Results If you need to wait for a workflow to complete, use `.result()` on the handle: diff --git a/packages/openworkflow/README.md b/packages/openworkflow/README.md index 7c599e23..c28447df 100644 --- a/packages/openworkflow/README.md +++ b/packages/openworkflow/README.md @@ -62,6 +62,7 @@ For more details, check out our [docs](https://openworkflow.dev/docs). - ✅ **Step memoization** - Never repeat completed work - ✅ **Automatic retries** - Built-in exponential backoff - ✅ **Long pauses** - Sleep for seconds or months +- ✅ **Scheduled runs** - Start workflows at a specific time - ✅ **Parallel execution** - Run steps concurrently - ✅ **No extra servers** - Uses your existing database - ✅ **Dashboard included** - Monitor and debug workflows diff --git a/packages/openworkflow/client.test.ts b/packages/openworkflow/client.test.ts index 08317d28..a42ba766 100644 --- a/packages/openworkflow/client.test.ts +++ b/packages/openworkflow/client.test.ts @@ -245,6 +245,23 @@ describe("OpenWorkflow", () => { expect(handle.workflowRun.deadlineAt?.getTime()).toBe(deadline.getTime()); }); + test("creates workflow run with availableAt", async () => { + const backend = await createBackend(); + const client = new OpenWorkflow({ backend }); + + const workflow = client.defineWorkflow( + { name: "available-at-test" }, + noopFn, + ); + const availableAt = new Date(Date.now() + 60_000); // in 1 minute + const handle = await workflow.run({ value: 1 }, { availableAt }); + + expect(handle.workflowRun.availableAt).not.toBeNull(); + expect(handle.workflowRun.availableAt?.getTime()).toBe( + availableAt.getTime(), + ); + }); + test("creates workflow run with version", async () => { const backend = await createBackend(); const client = new OpenWorkflow({ backend }); diff --git a/packages/openworkflow/client.ts b/packages/openworkflow/client.ts index 7a82f5d7..082c309a 100644 --- a/packages/openworkflow/client.ts +++ b/packages/openworkflow/client.ts @@ -106,7 +106,7 @@ export class OpenWorkflow { config: {}, context: null, input: parsedInput ?? null, - availableAt: null, + availableAt: options?.availableAt ?? null, deadlineAt: options?.deadlineAt ?? null, }); @@ -194,6 +194,11 @@ export class RunnableWorkflow { * `workflowDef.run()`. */ export interface WorkflowRunOptions { + /** + * Schedule the workflow run for a future time. When set, the run will stay + * pending until the timestamp is reached. + */ + availableAt?: Date; /** * Set a deadline for the workflow run. If the workflow exceeds this deadline, * it will be marked as failed. From 76a4c5aa3c75b76fd5496b3683c63df0c2c7d83a Mon Sep 17 00:00:00 2001 From: James Martinez Date: Wed, 4 Feb 2026 12:32:56 -0600 Subject: [PATCH 2/4] Support duration syntax for scheduling runs --- packages/docs/docs/workflows.mdx | 12 ++++++++- packages/openworkflow/client.test.ts | 37 ++++++++++++++++++++++++++++ packages/openworkflow/client.ts | 28 ++++++++++++++++++--- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/docs/docs/workflows.mdx b/packages/docs/docs/workflows.mdx index f0828889..b3b38c5c 100644 --- a/packages/docs/docs/workflows.mdx +++ b/packages/docs/docs/workflows.mdx @@ -53,7 +53,7 @@ The `run()` method returns a handle immediately. The actual workflow execution happens in a worker process. This lets your application continue without waiting for the workflow to complete. -## Scheduling a Workflow +### Scheduling a Workflow Run You can schedule a workflow run for a specific time by passing `availableAt`: @@ -65,6 +65,16 @@ const handle = await sendWelcomeEmail.run( ); ``` +You can also pass a duration string using the same [duration +format](/docs/sleeping#duration-formats) as `step.sleep`: + +```ts +const handle = await sendWelcomeEmail.run( + { userId: "user_123" }, + { availableAt: "5m" }, +); +``` + The run stays `pending` until `availableAt` is reached, then workers can claim it. If `availableAt` is in the past, the run is immediately available. diff --git a/packages/openworkflow/client.test.ts b/packages/openworkflow/client.test.ts index a42ba766..d2070947 100644 --- a/packages/openworkflow/client.test.ts +++ b/packages/openworkflow/client.test.ts @@ -262,6 +262,43 @@ describe("OpenWorkflow", () => { ); }); + test("creates workflow run with availableAt duration", async () => { + const backend = await createBackend(); + const client = new OpenWorkflow({ backend }); + + const workflow = client.defineWorkflow( + { name: "available-at-duration-test" }, + noopFn, + ); + + const start = Date.now(); + const handle = await workflow.run({ value: 1 }, { availableAt: "2s" }); + + expect(handle.workflowRun.availableAt).not.toBeNull(); + if (!handle.workflowRun.availableAt) { + throw new Error("availableAt should be set"); + } + + const delayMs = handle.workflowRun.availableAt.getTime() - start; + expect(delayMs).toBeGreaterThanOrEqual(1900); + expect(delayMs).toBeLessThanOrEqual(10_000); + }); + + test("throws for invalid availableAt duration", async () => { + const backend = await createBackend(); + const client = new OpenWorkflow({ backend }); + + const workflow = client.defineWorkflow( + { name: "available-at-invalid-test" }, + noopFn, + ); + + await expect( + // @ts-expect-error - invalid duration format + workflow.run({ value: 1 }, { availableAt: "not-a-duration" }), + ).rejects.toThrow('Invalid duration format: "not-a-duration"'); + }); + test("creates workflow run with version", async () => { const backend = await createBackend(); const client = new OpenWorkflow({ backend }); diff --git a/packages/openworkflow/client.ts b/packages/openworkflow/client.ts index 082c309a..d6826434 100644 --- a/packages/openworkflow/client.ts +++ b/packages/openworkflow/client.ts @@ -1,4 +1,5 @@ import type { Backend } from "./backend.js"; +import { parseDuration, type DurationString } from "./core/duration.js"; import type { StandardSchemaV1 } from "./core/schema.js"; import type { SchemaInput, @@ -106,7 +107,7 @@ export class OpenWorkflow { config: {}, context: null, input: parsedInput ?? null, - availableAt: options?.availableAt ?? null, + availableAt: resolveAvailableAt(options?.availableAt), deadlineAt: options?.deadlineAt ?? null, }); @@ -196,9 +197,10 @@ export class RunnableWorkflow { export interface WorkflowRunOptions { /** * Schedule the workflow run for a future time. When set, the run will stay - * pending until the timestamp is reached. + * pending until the timestamp is reached. Accepts an absolute Date or a + * duration string (e.g. "5m", "2 hours"). */ - availableAt?: Date; + availableAt?: Date | DurationString; /** * Set a deadline for the workflow run. If the workflow exceeds this deadline, * it will be marked as failed. @@ -206,6 +208,26 @@ export interface WorkflowRunOptions { deadlineAt?: Date; } +/** + * Resolve availableAt to an absolute Date or null. + * @param availableAt - Absolute Date or duration string + * @returns Absolute Date or null + * @throws {Error} When a duration string is invalid + */ +function resolveAvailableAt( + availableAt: Date | DurationString | undefined, +): Date | null { + if (!availableAt) return null; + if (availableAt instanceof Date) return availableAt; + + const result = parseDuration(availableAt); + if (!result.ok) { + throw result.error; + } + + return new Date(Date.now() + result.value); +} + /** * Options for WorkflowHandle. */ From 369a8a6c3e37dd264b5a6bb927b9de2a0d1df5f0 Mon Sep 17 00:00:00 2001 From: James Martinez Date: Wed, 4 Feb 2026 13:04:28 -0600 Subject: [PATCH 3/4] DRY up date + duration calculation --- packages/openworkflow/client.ts | 7 ++++--- packages/openworkflow/core/step.test.ts | 18 +++++++++--------- packages/openworkflow/core/step.ts | 6 +++--- packages/openworkflow/execution.ts | 4 ++-- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/openworkflow/client.ts b/packages/openworkflow/client.ts index d6826434..6a80e902 100644 --- a/packages/openworkflow/client.ts +++ b/packages/openworkflow/client.ts @@ -1,6 +1,7 @@ import type { Backend } from "./backend.js"; -import { parseDuration, type DurationString } from "./core/duration.js"; +import type { DurationString } from "./core/duration.js"; import type { StandardSchemaV1 } from "./core/schema.js"; +import { calculateDateFromDuration } from "./core/step.js"; import type { SchemaInput, SchemaOutput, @@ -220,12 +221,12 @@ function resolveAvailableAt( if (!availableAt) return null; if (availableAt instanceof Date) return availableAt; - const result = parseDuration(availableAt); + const result = calculateDateFromDuration(availableAt); if (!result.ok) { throw result.error; } - return new Date(Date.now() + result.value); + return result.value; } /** diff --git a/packages/openworkflow/core/step.test.ts b/packages/openworkflow/core/step.test.ts index 84a2b52d..de9d14e7 100644 --- a/packages/openworkflow/core/step.test.ts +++ b/packages/openworkflow/core/step.test.ts @@ -4,7 +4,7 @@ import { getCachedStepAttempt, addToStepAttemptCache, normalizeStepOutput, - calculateSleepResumeAt, + calculateDateFromDuration, createSleepContext, } from "./step.js"; import type { StepAttempt, StepAttemptCache } from "./step.js"; @@ -224,38 +224,38 @@ describe("normalizeStepOutput", () => { }); }); -describe("calculateSleepResumeAt", () => { +describe("calculateDateFromDuration", () => { test("calculates resume time from duration string", () => { const now = 1_000_000; - const result = calculateSleepResumeAt("5s", now); + const result = calculateDateFromDuration("5s", now); expect(result).toEqual(ok(new Date(now + 5000))); }); test("calculates resume time with milliseconds", () => { const now = 1_000_000; - const result = calculateSleepResumeAt("500ms", now); + const result = calculateDateFromDuration("500ms", now); expect(result).toEqual(ok(new Date(now + 500))); }); test("calculates resume time with minutes", () => { const now = 1_000_000; - const result = calculateSleepResumeAt("2m", now); + const result = calculateDateFromDuration("2m", now); expect(result).toEqual(ok(new Date(now + 2 * 60 * 1000))); }); test("calculates resume time with hours", () => { const now = 1_000_000; - const result = calculateSleepResumeAt("1h", now); + const result = calculateDateFromDuration("1h", now); expect(result).toEqual(ok(new Date(now + 60 * 60 * 1000))); }); test("uses Date.now() when now is not provided", () => { const before = Date.now(); - const result = calculateSleepResumeAt("1s"); + const result = calculateDateFromDuration("1s"); const after = Date.now(); expect(result.ok).toBe(true); @@ -268,7 +268,7 @@ describe("calculateSleepResumeAt", () => { test("returns error for invalid duration", () => { // @ts-expect-error testing invalid input - const result = calculateSleepResumeAt("invalid"); + const result = calculateDateFromDuration("invalid"); expect(result.ok).toBe(false); if (!result.ok) { @@ -278,7 +278,7 @@ describe("calculateSleepResumeAt", () => { test("returns error for empty duration", () => { // @ts-expect-error testing invalid input - const result = calculateSleepResumeAt(""); + const result = calculateDateFromDuration(""); expect(result.ok).toBe(false); }); diff --git a/packages/openworkflow/core/step.ts b/packages/openworkflow/core/step.ts index bf3c1bad..86329c6e 100644 --- a/packages/openworkflow/core/step.ts +++ b/packages/openworkflow/core/step.ts @@ -111,12 +111,12 @@ export function normalizeStepOutput(result: unknown): JsonValue { } /** - * Calculate the resume time for a sleep step. - * @param duration - The duration string to sleep for + * Calculate a future time from a duration string. + * @param duration - The duration string to add * @param now - The current timestamp (defaults to Date.now()) * @returns A Result containing the resume Date or an Error */ -export function calculateSleepResumeAt( +export function calculateDateFromDuration( duration: DurationString, now: number = Date.now(), ): Result { diff --git a/packages/openworkflow/execution.ts b/packages/openworkflow/execution.ts index 1aa6856b..a2277284 100644 --- a/packages/openworkflow/execution.ts +++ b/packages/openworkflow/execution.ts @@ -8,7 +8,7 @@ import { getCachedStepAttempt, addToStepAttemptCache, normalizeStepOutput, - calculateSleepResumeAt, + calculateDateFromDuration, createSleepContext, } from "./core/step.js"; import type { WorkflowRun } from "./core/workflow.js"; @@ -160,7 +160,7 @@ class StepExecutor implements StepApi { if (existingAttempt) return; // create new step attempt for the sleep - const result = calculateSleepResumeAt(duration); + const result = calculateDateFromDuration(duration); if (!result.ok) { throw result.error; } From 59102cd8c0a589bf89ac9f6013642cd0b11fec72 Mon Sep 17 00:00:00 2001 From: James Martinez Date: Wed, 4 Feb 2026 13:08:40 -0600 Subject: [PATCH 4/4] Add workflow run scheduling to changelog --- packages/openworkflow/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/openworkflow/CHANGELOG.md b/packages/openworkflow/CHANGELOG.md index 0f0772a8..9629c0f5 100644 --- a/packages/openworkflow/CHANGELOG.md +++ b/packages/openworkflow/CHANGELOG.md @@ -1,5 +1,10 @@ # openworkflow +## Unreleased + +- Added support for scheduling workflow runs with a `Date` or duration string + See https://openworkflow.dev/docs/workflows#scheduling-a-workflow-run + ## 0.6.3 - Export the full Backend interface for third-party backends