├── README.md ├── CHANGELOG.md ├── .gitignore ├── packages ├── backend-sqlite │ ├── index.ts │ ├── tsconfig.json │ ├── CHANGELOG.md │ ├── package.json │ ├── backend.test.ts │ ├── sqlite.test.ts │ └── sqlite.ts ├── backend-postgres │ ├── index.ts │ ├── .squawk.toml │ ├── scripts │ │ ├── pghero.sh │ │ ├── db-migrate.ts │ │ ├── db-reset.ts │ │ └── squawk.ts │ ├── tsconfig.json │ ├── CHANGELOG.md │ ├── compose.yaml │ ├── vitest.global-setup.ts │ ├── backend.test.ts │ ├── package.json │ ├── postgres.test.ts │ └── postgres.ts ├── backend-test │ ├── index.ts │ ├── tsconfig.json │ └── package.json ├── cli │ ├── README.md │ ├── tsconfig.json │ ├── package.json │ ├── index.ts │ ├── errors.ts │ └── templates.ts └── openworkflow │ ├── core │ ├── json.ts │ ├── retry.ts │ ├── result.test.ts │ ├── result.ts │ ├── error.ts │ ├── error.test.ts │ ├── schema.ts │ ├── duration.ts │ ├── workflow.ts │ ├── workflow.test.ts │ ├── step.ts │ └── step.test.ts │ ├── tsconfig.json │ ├── internal.ts │ ├── index.ts │ ├── package.json │ ├── CHANGELOG.md │ ├── registry.ts │ ├── bin │ └── openworkflow.ts │ ├── config.ts │ ├── workflow.test.ts │ ├── config.test.ts │ ├── workflow.ts │ ├── chaos.test.ts │ ├── backend.ts │ ├── registry.test.ts │ ├── worker.ts │ ├── execution.ts │ └── client.ts ├── .changeset ├── config.json └── README.md ├── .github ├── pull_request_template.md ├── issue_template.md ├── dependabot.yaml ├── copilot-instructions.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yaml ├── examples ├── workflow-discovery │ ├── tsconfig.json │ ├── openworkflow.config.js │ ├── package.json │ ├── openworkflow │ │ ├── greeting-default.ts │ │ ├── greeting.ts │ │ └── math.ts │ └── index.ts ├── basic │ ├── tsconfig.json │ ├── package.json │ └── index.ts ├── declare-workflow │ ├── tsconfig.json │ ├── package.json │ └── index.ts └── with-schema-validation │ ├── tsconfig.json │ ├── package.json │ ├── arktype.ts │ ├── zod.ts │ ├── valibot.ts │ └── yup.ts ├── benchmarks └── basic │ ├── tsconfig.json │ ├── package.json │ └── index.ts ├── turbo.json ├── prettier.config.js ├── knip.json ├── tsconfig.json ├── vitest.config.ts ├── CONTRIBUTING.md ├── package.json ├── eslint.config.js └── LICENSE.md /README.md: -------------------------------------------------------------------------------- 1 | packages/openworkflow/README.md -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ./packages/openworkflow/CHANGELOG.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .turbo 2 | coverage 3 | dist 4 | node_modules 5 | -------------------------------------------------------------------------------- /packages/backend-sqlite/index.ts: -------------------------------------------------------------------------------- 1 | export { BackendSqlite } from "./backend.js"; 2 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "access": "public", 3 | "baseBranch": "main" 4 | } 5 | -------------------------------------------------------------------------------- /packages/backend-postgres/index.ts: -------------------------------------------------------------------------------- 1 | export { BackendPostgres } from "./backend.js"; 2 | -------------------------------------------------------------------------------- /packages/backend-test/index.ts: -------------------------------------------------------------------------------- 1 | export { testBackend } from "./backend.testsuite.js"; 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Closes # 2 | 3 | ### Summary 4 | 5 | ### Changes 6 | 7 | - 8 | - 9 | - 10 | -------------------------------------------------------------------------------- /packages/backend-postgres/.squawk.toml: -------------------------------------------------------------------------------- 1 | excluded_rules = [ 2 | "prefer-bigint-over-smallint", 3 | "require-timeout-settings", 4 | ] 5 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Expected Behavior 2 | 3 | ### Actual Behavior 4 | 5 | ### Steps to Reproduce 6 | 7 | 1. 8 | 1. 9 | 1. 10 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # @openworkflow/cli 2 | 3 | Development: 4 | 5 | ```sh 6 | # from repo root 7 | npx tsx packages/cli/index.ts 8 | ``` 9 | -------------------------------------------------------------------------------- /examples/workflow-discovery/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/backend-postgres/scripts/pghero.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker run -ti -e DATABASE_URL='postgres://postgres:postgres@host.docker.internal:5432/postgres' -p 8080:8080 ankane/pghero 3 | -------------------------------------------------------------------------------- /benchmarks/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/declare-workflow/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend-postgres/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend-sqlite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/openworkflow/core/json.ts: -------------------------------------------------------------------------------- 1 | export type JsonPrimitive = string | number | boolean | null; 2 | export type JsonValue = 3 | | JsonPrimitive 4 | | JsonValue[] 5 | | { [key: string]: JsonValue }; 6 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/turbo/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/with-schema-validation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend-sqlite/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openworkflow/backend-sqlite 2 | 3 | See the [openworkflow CHANGELOG](https://github.com/openworkflowdev/openworkflow/blob/main/packages/openworkflow/CHANGELOG.md) for details. 4 | -------------------------------------------------------------------------------- /packages/backend-postgres/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openworkflow/backend-postgres 2 | 3 | See the [openworkflow CHANGELOG](https://github.com/openworkflowdev/openworkflow/blob/main/packages/openworkflow/CHANGELOG.md) for details. 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | const config = { 3 | plugins: [ 4 | "@trivago/prettier-plugin-sort-imports", 5 | "prettier-plugin-packagejson", 6 | ], 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/openworkflow/core/retry.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_RETRY_POLICY = { 2 | initialIntervalMs: 1000, // 1s 3 | backoffCoefficient: 2, 4 | maximumIntervalMs: 100 * 1000, // 100s 5 | maximumAttempts: Infinity, // unlimited 6 | } as const; 7 | -------------------------------------------------------------------------------- /packages/openworkflow/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tsconfig.json"], 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "dist" 6 | }, 7 | "include": ["**/*.ts"], 8 | "exclude": ["**/*.test.ts", "dist"] 9 | } 10 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "ignoreExportsUsedInFile": { 4 | "interface": true, 5 | "type": true 6 | }, 7 | "tags": ["-lintignore"], 8 | "workspaces": { 9 | ".": {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: npm 8 | versioning-strategy: increase 9 | directories: ["**/*"] 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /packages/backend-postgres/scripts/db-migrate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_POSTGRES_URL, 3 | DEFAULT_SCHEMA, 4 | newPostgresMaxOne, 5 | migrate, 6 | } from "../postgres.js"; 7 | 8 | const pg = newPostgresMaxOne(DEFAULT_POSTGRES_URL); 9 | await migrate(pg, DEFAULT_SCHEMA); 10 | await pg.end(); 11 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | This file contains instructions for Copilot code review. 2 | 3 | - Check changes against ARCHITECTURE.md & README.md and flag any inconsistencies 4 | - Ensure new code follows the existing coding standards and style used elsewhere 5 | - Verify that all new code has appropriate tests 6 | -------------------------------------------------------------------------------- /packages/backend-postgres/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres 4 | environment: 5 | POSTGRES_PASSWORD: postgres 6 | ports: 7 | - 5432:5432 8 | command: > 9 | postgres 10 | -c shared_preload_libraries=pg_stat_statements 11 | -c pg_stat_statements.track=all 12 | -------------------------------------------------------------------------------- /packages/backend-postgres/scripts/db-reset.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_POSTGRES_URL, 3 | DEFAULT_SCHEMA, 4 | newPostgresMaxOne, 5 | dropSchema, 6 | migrate, 7 | } from "../postgres.js"; 8 | 9 | const pg = newPostgresMaxOne(DEFAULT_POSTGRES_URL); 10 | await dropSchema(pg, DEFAULT_SCHEMA); 11 | await migrate(pg, DEFAULT_SCHEMA); 12 | await pg.end(); 13 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-basic", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "start": "tsx index.ts" 7 | }, 8 | "dependencies": { 9 | "@openworkflow/backend-postgres": "file:../../packages/backend-postgres", 10 | "openworkflow": "file:../../packages/openworkflow" 11 | }, 12 | "devDependencies": { 13 | "tsx": "^4.21.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /benchmarks/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benchmark-basic", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "start": "tsx index.ts" 7 | }, 8 | "dependencies": { 9 | "@openworkflow/backend-postgres": "file:../../packages/backend-postgres", 10 | "openworkflow": "file:../../packages/openworkflow" 11 | }, 12 | "devDependencies": { 13 | "tsx": "^4.21.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/backend-postgres/vitest.global-setup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | migrate, 3 | newPostgresMaxOne, 4 | DEFAULT_SCHEMA, 5 | DEFAULT_POSTGRES_URL, 6 | } from "./postgres.js"; 7 | 8 | /** Run database migrations once before Postgres backend tests. */ 9 | export async function setup() { 10 | const pg = newPostgresMaxOne(DEFAULT_POSTGRES_URL); 11 | await migrate(pg, DEFAULT_SCHEMA); 12 | await pg.end(); 13 | } 14 | -------------------------------------------------------------------------------- /examples/declare-workflow/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-declare-workflow", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "start": "tsx index.ts" 7 | }, 8 | "dependencies": { 9 | "@openworkflow/backend-postgres": "file:../../packages/backend-postgres", 10 | "openworkflow": "file:../../packages/openworkflow" 11 | }, 12 | "devDependencies": { 13 | "tsx": "^4.21.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/workflow-discovery/openworkflow.config.js: -------------------------------------------------------------------------------- 1 | import { BackendSqlite } from "@openworkflow/backend-sqlite"; 2 | import { defineConfig } from "openworkflow"; 3 | 4 | // eslint-disable-next-line sonarjs/publicly-writable-directories 5 | const sqliteFileName = "/tmp/openworkflow_example_workflow_discovery.db"; 6 | 7 | export default defineConfig({ 8 | backend: BackendSqlite.connect(sqliteFileName), 9 | dirs: "./openworkflow", 10 | }); 11 | -------------------------------------------------------------------------------- /packages/openworkflow/core/result.test.ts: -------------------------------------------------------------------------------- 1 | import { ok, err } from "./result.js"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | describe("Result helpers", () => { 5 | test("ok creates success result", () => { 6 | expect(ok(123)).toEqual({ ok: true, value: 123 }); 7 | }); 8 | 9 | test("err creates error result", () => { 10 | const error = new Error("oops"); 11 | expect(err(error)).toEqual({ ok: false, error }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | 1. `npx @changesets/cli` to create a changeset file 2 | 2. `npx @changesets/cli version` to bump the versions according to 3 | changeset-specified versions 4 | 3. `npm i` to update the package-lock.json 5 | 4. Make sure the README is updated to reflect any changes, since updates after 6 | publishing will not be shown on npm. 7 | 5. Commit the version bump 8 | 6. `npx @changesets/cli publish` to publish the new version to npm 9 | 7. `git push --tags` 10 | -------------------------------------------------------------------------------- /examples/workflow-discovery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-workflow-discovery", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "start": "tsx index.ts", 7 | "worker": "tsx ../../packages/cli/index.ts worker start" 8 | }, 9 | "dependencies": { 10 | "@openworkflow/backend-sqlite": "file:../../packages/backend-sqlite", 11 | "openworkflow": "file:../../packages/openworkflow" 12 | }, 13 | "devDependencies": { 14 | "tsx": "^4.21.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/openworkflow/internal.ts: -------------------------------------------------------------------------------- 1 | // config 2 | export { loadConfig } from "./config.js"; 3 | 4 | // workflow 5 | export type { Workflow } from "./workflow.js"; 6 | export { isWorkflow } from "./workflow.js"; 7 | 8 | // backend 9 | export * from "./backend.js"; 10 | 11 | // core 12 | export type { JsonValue } from "./core/json.js"; 13 | export type { WorkflowRun } from "./core/workflow.js"; 14 | export type { StepAttempt } from "./core/step.js"; 15 | export { DEFAULT_RETRY_POLICY } from "./core/retry.js"; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tsconfig/strictest/tsconfig.json", // https://www.npmjs.com/package/@tsconfig/strictest 4 | "@tsconfig/node22/tsconfig.json" // https://www.npmjs.com/package/@tsconfig/node22 5 | ], 6 | "compilerOptions": { 7 | "rootDir": ".", 8 | "outDir": "dist", 9 | "customConditions": ["development"], 10 | 11 | "composite": true, 12 | "declaration": true, 13 | "sourceMap": true, 14 | "declarationMap": true 15 | }, 16 | "include": ["**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/backend-postgres/scripts/squawk.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_SCHEMA, migrations } from "../postgres.js"; 2 | import { execSync } from "node:child_process"; 3 | import { unlinkSync, writeFileSync } from "node:fs"; 4 | 5 | const sql = migrations(DEFAULT_SCHEMA).join("\n\n"); 6 | writeFileSync("squawk.sql", sql); 7 | 8 | try { 9 | // eslint-disable-next-line sonarjs/no-os-command-from-path 10 | execSync("npx squawk squawk.sql", { stdio: "inherit" }); 11 | } catch { 12 | // ignore - squawk will produce its own error output 13 | } finally { 14 | unlinkSync("squawk.sql"); 15 | console.log(""); 16 | } 17 | -------------------------------------------------------------------------------- /packages/backend-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openworkflow/backend-test", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "development": "./index.ts", 9 | "default": "./dist/index.js" 10 | } 11 | }, 12 | "files": [ 13 | "dist" 14 | ], 15 | "scripts": { 16 | "build": "tsc", 17 | "prepublishOnly": "npm run build" 18 | }, 19 | "devDependencies": { 20 | "openworkflow": "file:../openworkflow", 21 | "vitest": "^4.0.15" 22 | }, 23 | "peerDependencies": { 24 | "openworkflow": "^0.4.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/openworkflow/core/result.ts: -------------------------------------------------------------------------------- 1 | export type Result = Ok | Err; 2 | 3 | export interface Ok { 4 | ok: true; 5 | value: T; 6 | } 7 | 8 | export interface Err { 9 | ok: false; 10 | error: Error; 11 | } 12 | 13 | /** 14 | * Create an Ok result. 15 | * @param value - Result value 16 | * @returns Ok result 17 | */ 18 | export function ok(value: T): Ok { 19 | return { ok: true, value }; 20 | } 21 | 22 | /** 23 | * Create an Err result. 24 | * @param error - Result error 25 | * @returns Err result 26 | */ 27 | export function err(error: Readonly): Err { 28 | return { ok: false, error }; 29 | } 30 | -------------------------------------------------------------------------------- /examples/with-schema-validation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-with-zod-schema", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "arktype": "tsx arktype.ts", 7 | "valibot": "tsx valibot.ts", 8 | "yup": "tsx yup.ts", 9 | "zod": "tsx zod.ts" 10 | }, 11 | "dependencies": { 12 | "@openworkflow/backend-postgres": "file:../../packages/backend-postgres", 13 | "arktype": "^2.1.27", 14 | "openworkflow": "file:../../packages/openworkflow", 15 | "valibot": "^1.2.0", 16 | "yup": "^1.7.1", 17 | "zod": "^4.1.13" 18 | }, 19 | "devDependencies": { 20 | "tsx": "^4.21.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/openworkflow/index.ts: -------------------------------------------------------------------------------- 1 | // config 2 | export type { OpenWorkflowConfig, WorkerConfig } from "./config.js"; 3 | export { defineConfig } from "./config.js"; 4 | 5 | // client 6 | export type { OpenWorkflowOptions } from "./client.js"; 7 | export { OpenWorkflow, createClient } from "./client.js"; 8 | 9 | // worker 10 | export type { WorkerOptions } from "./worker.js"; 11 | export { Worker } from "./worker.js"; 12 | 13 | // workflow 14 | export type { Workflow } from "./workflow.js"; 15 | export { 16 | defineWorkflowSpec, 17 | defineWorkflow, 18 | declareWorkflow, // eslint-disable-line @typescript-eslint/no-deprecated 19 | } from "./workflow.js"; 20 | -------------------------------------------------------------------------------- /examples/workflow-discovery/openworkflow/greeting-default.ts: -------------------------------------------------------------------------------- 1 | import { GreetingInput, GreetingOutput } from "./greeting.js"; 2 | import { defineWorkflow } from "openworkflow"; 3 | 4 | // A workflow with a default export 5 | export default defineWorkflow( 6 | { name: "greeting-default", version: "1.0.0" }, 7 | async ({ input, step }) => { 8 | const greeting = await step.run({ name: "generate-greeting" }, () => { 9 | return `Hello, ${input.name}!`; 10 | }); 11 | 12 | const message = await step.run({ name: "format-message" }, () => { 13 | return `${greeting} Welcome to OpenWorkflow.`; 14 | }); 15 | 16 | return { message }; 17 | }, 18 | ); 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globalSetup: ["packages/backend-postgres/vitest.global-setup.ts"], 6 | exclude: ["**/dist", "benchmarks", "coverage", "examples", "node_modules"], 7 | coverage: { 8 | include: ["packages/**/*.ts"], 9 | exclude: [ 10 | "**/dist/**", 11 | "**/scripts/*.ts", 12 | "vitest.global-setup.ts", 13 | "packages/cli/**", 14 | "packages/openworkflow/bin/**", 15 | ], 16 | thresholds: { 17 | statements: 90, 18 | branches: 80, 19 | functions: 90, 20 | lines: 90, 21 | }, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/backend-postgres/backend.test.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "./backend.js"; 2 | import { DEFAULT_POSTGRES_URL } from "./postgres.js"; 3 | import { testBackend } from "@openworkflow/backend-test"; 4 | import assert from "node:assert"; 5 | import { randomUUID } from "node:crypto"; 6 | import { test } from "vitest"; 7 | 8 | test("it is a test file (workaround for sonarjs/no-empty-test-file linter)", () => { 9 | assert.ok(true); 10 | }); 11 | 12 | testBackend({ 13 | setup: async () => { 14 | return await BackendPostgres.connect(DEFAULT_POSTGRES_URL, { 15 | namespaceId: randomUUID(), 16 | }); 17 | }, 18 | teardown: async (backend) => { 19 | await backend.stop(); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/backend-sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openworkflow/backend-sqlite", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "development": "./index.ts", 9 | "default": "./dist/index.js" 10 | } 11 | }, 12 | "files": [ 13 | "dist" 14 | ], 15 | "scripts": { 16 | "build": "tsc", 17 | "prepublishOnly": "npm run build" 18 | }, 19 | "dependencies": { 20 | "openworkflow": "^0.4.1" 21 | }, 22 | "devDependencies": { 23 | "@openworkflow/backend-test": "file:../backend-test", 24 | "vitest": "^4.0.15" 25 | }, 26 | "peerDependencies": { 27 | "openworkflow": "^0.4.1" 28 | }, 29 | "engines": { 30 | "node": ">=22.5.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/openworkflow/core/error.ts: -------------------------------------------------------------------------------- 1 | import type { JsonValue } from "./json.js"; 2 | 3 | export interface SerializedError { 4 | name?: string; 5 | message: string; 6 | stack?: string; 7 | [key: string]: JsonValue; 8 | } 9 | 10 | /** 11 | * Serialize an error to a JSON-compatible format. 12 | * @param error - The error to serialize (can be Error instance or any value) 13 | * @returns A JSON-serializable error object 14 | */ 15 | export function serializeError(error: unknown): SerializedError { 16 | if (error instanceof Error) { 17 | const { name, message, stack } = error; 18 | 19 | if (stack) { 20 | return { name, message, stack }; 21 | } 22 | 23 | return { name, message }; 24 | } 25 | 26 | return { 27 | message: String(error), 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /examples/workflow-discovery/openworkflow/greeting.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkflow } from "openworkflow"; 2 | 3 | export interface GreetingInput { 4 | name: string; 5 | } 6 | 7 | export interface GreetingOutput { 8 | message: string; 9 | } 10 | 11 | // A workflow with a named export (greetingWorkflow) 12 | export const greetingWorkflow = defineWorkflow( 13 | { name: "greeting", version: "1.0.0" }, 14 | async ({ input, step }) => { 15 | const greeting = await step.run({ name: "generate-greeting" }, () => { 16 | return `Hello, ${input.name}!`; 17 | }); 18 | 19 | const message = await step.run({ name: "format-message" }, () => { 20 | return `${greeting} Welcome to OpenWorkflow.`; 21 | }); 22 | 23 | return { message }; 24 | }, 25 | ); 26 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openworkflow/cli", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "development": "./index.ts", 9 | "default": "./dist/index.js" 10 | } 11 | }, 12 | "bin": { 13 | "openworkflow": "./dist/index.js", 14 | "ow": "./dist/index.js" 15 | }, 16 | "files": [ 17 | "dist" 18 | ], 19 | "scripts": { 20 | "build": "tsc", 21 | "prepublishOnly": "npm run build" 22 | }, 23 | "dependencies": { 24 | "@clack/prompts": "^0.11.0", 25 | "commander": "^14.0.2", 26 | "consola": "^3.4.2", 27 | "dotenv": "^17.2.3", 28 | "jiti": "^2.6.1", 29 | "nypm": "^0.6.2" 30 | }, 31 | "devDependencies": { 32 | "openworkflow": "file:../openworkflow", 33 | "vitest": "^4.0.16" 34 | }, 35 | "peerDependencies": { 36 | "openworkflow": "^0.4.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/backend-postgres/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openworkflow/backend-postgres", 3 | "version": "0.4.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "types": "./dist/index.d.ts", 8 | "development": "./index.ts", 9 | "default": "./dist/index.js" 10 | } 11 | }, 12 | "files": [ 13 | "dist" 14 | ], 15 | "scripts": { 16 | "build": "tsc", 17 | "db:migrate": "tsx ./scripts/db-migrate.ts", 18 | "db:reset": "tsx ./scripts/db-reset.ts", 19 | "pghero": "sh ./scripts/pghero.sh", 20 | "prepublishOnly": "npm run build", 21 | "squawk": "tsx ./scripts/squawk.ts" 22 | }, 23 | "dependencies": { 24 | "openworkflow": "^0.4.1", 25 | "postgres": "^3.4.7" 26 | }, 27 | "devDependencies": { 28 | "@openworkflow/backend-test": "file:../backend-test", 29 | "squawk-cli": "^2.33.2", 30 | "vitest": "^4.0.15" 31 | }, 32 | "peerDependencies": { 33 | "openworkflow": "^0.4.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | services: 15 | postgres: 16 | image: postgres 17 | env: 18 | POSTGRES_PASSWORD: postgres 19 | ports: 20 | - 5432:5432 21 | options: >- 22 | --health-cmd pg_isready 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | steps: 27 | - uses: actions/checkout@v6 28 | - uses: actions/setup-node@v6 29 | with: 30 | node-version: 22 # previous LTS (24 becomes previous on Oct 20, 2026 - https://endoflife.date/nodejs) 31 | - run: npm ci 32 | - run: npm run format 33 | - run: npm run lint 34 | - run: npm run typecheck 35 | - run: npm run build 36 | - run: npm run test:coverage 37 | - uses: codecov/codecov-action@v5 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to OpenWorkflow 2 | 3 | Contributions are very welcome! 4 | 5 | Anything that changes the workflow API, step API, core scheduler, or storage 6 | model need a quick design convo first so we can keep things high-quality. Just 7 | [open an issue](https://github.com/openworkflowdev/openworkflow/issues/new) 8 | describing what you'd like to do. [This is a great 9 | example](https://github.com/openworkflowdev/openworkflow/issues/16). 10 | 11 | If you are unsure whether a change would be accepted, feel free to ask in an 12 | issue or check for labels like `help wanted`, `good first issue`, or `bug` on 13 | the repo. 14 | 15 | If you want to take an existing issue, leave a comment so it can be assigned 16 | unless it is already in progress by another contributor. 17 | 18 | For new functionality or anything that adds major behavior, start with a short 19 | design conversation in an issue. Share the problem, your proposed approach, and 20 | why it belongs in OpenWorkflow. The core team will help confirm direction before 21 | any PRs start. 22 | 23 | PRs that skip these steps may be closed. 24 | -------------------------------------------------------------------------------- /examples/workflow-discovery/openworkflow/math.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkflow } from "openworkflow"; 2 | 3 | interface AddNumbersInput { 4 | a: number; 5 | b: number; 6 | } 7 | 8 | interface AddNumbersOutput { 9 | result: number; 10 | } 11 | 12 | // One of two exported workflows 13 | export const addWorkflow = defineWorkflow( 14 | { name: "add-numbers", version: "1.0.0" }, 15 | async ({ input, step }) => { 16 | const result = await step.run({ name: "add" }, () => { 17 | return input.a + input.b; 18 | }); 19 | 20 | return { result }; 21 | }, 22 | ); 23 | 24 | interface MultiplyInput { 25 | a: number; 26 | b: number; 27 | } 28 | 29 | interface MultiplyOutput { 30 | result: number; 31 | } 32 | 33 | // The second of two exported workflows 34 | export const multiplyWorkflow = defineWorkflow( 35 | { name: "multiply-numbers", version: "1.0.0" }, 36 | async ({ input, step }) => { 37 | const result = await step.run({ name: "multiply" }, () => { 38 | return input.a * input.b; 39 | }); 40 | 41 | return { result }; 42 | }, 43 | ); 44 | -------------------------------------------------------------------------------- /packages/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* v8 ignore file -- @preserve */ 3 | import { doctor, getVersion, init, workerStart } from "./commands.js"; 4 | import { withErrorHandling } from "./errors.js"; 5 | import { Command } from "commander"; 6 | 7 | // openworkflow | ow 8 | const program = new Command(); 9 | program 10 | .name("openworkflow") 11 | .alias("ow") 12 | .description("OpenWorkflow CLI - learn more at https://openworkflow.dev") 13 | .version(getVersion()); 14 | 15 | // init 16 | program 17 | .command("init") 18 | .description("initialize OpenWorkflow") 19 | .action(withErrorHandling(init)); 20 | 21 | // doctor 22 | program 23 | .command("doctor") 24 | .description("check configuration and list available workflows") 25 | .action(withErrorHandling(doctor)); 26 | 27 | // worker 28 | const workerCmd = program.command("worker").description("manage workers"); 29 | 30 | // worker start 31 | workerCmd 32 | .command("start") 33 | .description("start a worker to process workflows") 34 | .option( 35 | "-c, --concurrency ", 36 | "number of concurrent workflows to process", 37 | Number.parseInt, 38 | ) 39 | .action(withErrorHandling(workerStart)); 40 | 41 | await program.parseAsync(process.argv); 42 | -------------------------------------------------------------------------------- /packages/cli/errors.ts: -------------------------------------------------------------------------------- 1 | /* v8 ignore file -- @preserve */ 2 | import { consola } from "consola"; 3 | 4 | /** 5 | * User-facing CLI error. 6 | */ 7 | export class CLIError extends Error { 8 | readonly detail: string | undefined; 9 | 10 | constructor(message: string, detail?: string) { 11 | super(message); 12 | this.name = "CLIError"; 13 | this.detail = detail; 14 | } 15 | } 16 | 17 | /** 18 | * Wraps a CLI action / handler function with error handling that catches 19 | * errors, prints them to the console, then exits. 20 | * @param fn - Action handler 21 | * @returns Wrapped handler 22 | */ 23 | export function withErrorHandling( 24 | fn: (...args: T) => void | Promise, 25 | ): (...args: T) => Promise { 26 | return async (...args: T) => { 27 | try { 28 | await fn(...args); 29 | } catch (error) { 30 | if (error instanceof CLIError) { 31 | consola.error(error.message); 32 | if (error.detail) consola.info(error.detail); 33 | // eslint-disable-next-line unicorn/no-process-exit 34 | process.exit(1); 35 | } 36 | const message = error instanceof Error ? error.message : String(error); 37 | consola.error(`Unexpected error: ${message}`); 38 | if (error instanceof Error && error.stack) { 39 | consola.debug(error.stack); 40 | } 41 | // eslint-disable-next-line unicorn/no-process-exit 42 | process.exit(1); 43 | } 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /examples/workflow-discovery/index.ts: -------------------------------------------------------------------------------- 1 | import greetingDefaultWorkflow from "./openworkflow/greeting-default.js"; 2 | import { greetingWorkflow } from "./openworkflow/greeting.js"; 3 | import { addWorkflow, multiplyWorkflow } from "./openworkflow/math.js"; 4 | import { createClient } from "openworkflow"; 5 | 6 | const ow = await createClient(); 7 | 8 | // Greeting Workflow 9 | console.log("Running greeting workflow..."); 10 | const greetingHandle = await ow.runWorkflow(greetingWorkflow.spec, { 11 | name: "Alice", 12 | }); 13 | const greetingResult = await greetingHandle.result(); 14 | console.log("Greeting result:", greetingResult); 15 | 16 | // Greeting Default Workflow 17 | console.log("\nRunning greeting default workflow..."); 18 | const greetingDefaultHandle = await ow.runWorkflow( 19 | greetingDefaultWorkflow.spec, 20 | { 21 | name: "Alice", 22 | }, 23 | ); 24 | const greetingDefaultResult = await greetingDefaultHandle.result(); 25 | console.log("Greeting default result:", greetingDefaultResult); 26 | 27 | // Math Workflows 28 | console.log("\nRunning add workflow..."); 29 | const addHandle = await ow.runWorkflow(addWorkflow.spec, { a: 5, b: 3 }); 30 | const addResult = await addHandle.result(); 31 | console.log("Add result:", addResult); 32 | 33 | console.log("\nRunning multiply workflow..."); 34 | const multiplyHandle = await ow.runWorkflow(multiplyWorkflow.spec, { 35 | a: 4, 36 | b: 7, 37 | }); 38 | const multiplyResult = await multiplyHandle.result(); 39 | console.log("Multiply result:", multiplyResult); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openworkflow-project", 3 | "private": true, 4 | "type": "module", 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "build": "turbo build", 10 | "format": "prettier --check . --ignore-path .gitignore", 11 | "format:fix": "prettier --write . --ignore-path .gitignore", 12 | "knip": "knip", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint . --fix", 15 | "ow": "tsx ./packages/cli/index.ts", 16 | "test": "vitest run", 17 | "test:coverage": "vitest run --coverage", 18 | "test:coverage:browse": "open ./coverage/index.html", 19 | "test:watch": "vitest", 20 | "typecheck": "tsc --noEmit" 21 | }, 22 | "devDependencies": { 23 | "@cspell/eslint-plugin": "^9.4.0", 24 | "@eslint/js": "^9.39.2", 25 | "@trivago/prettier-plugin-sort-imports": "^6.0.0", 26 | "@tsconfig/node22": "^22", 27 | "@tsconfig/strictest": "^2.0.8", 28 | "@types/node": "^25.0.3", 29 | "@vitest/coverage-v8": "^4.0.16", 30 | "eslint": "^9.39.2", 31 | "eslint-config-prettier": "^10.1.8", 32 | "eslint-import-resolver-typescript": "^4.4.4", 33 | "eslint-plugin-functional": "^9.0.2", 34 | "eslint-plugin-import": "^2.32.0", 35 | "eslint-plugin-jsdoc": "^61.5.0", 36 | "eslint-plugin-sonarjs": "^3.0.5", 37 | "eslint-plugin-unicorn": "^62.0.0", 38 | "knip": "^5.76.3", 39 | "prettier": "^3.7.4", 40 | "prettier-plugin-packagejson": "^2.5.20", 41 | "tsx": "^4.21.0", 42 | "turbo": "^2.7.1", 43 | "typescript": "^5.9.3", 44 | "typescript-eslint": "^8.50.1", 45 | "vitest": "^4.0.7" 46 | }, 47 | "packageManager": "npm@11.6.2" 48 | } 49 | -------------------------------------------------------------------------------- /packages/openworkflow/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openworkflow", 3 | "version": "0.4.1", 4 | "description": "Open-source TypeScript framework for building durable, resumable workflows", 5 | "keywords": [ 6 | "durable execution", 7 | "durable function", 8 | "durable workflow", 9 | "job processing", 10 | "postgresql", 11 | "resumable", 12 | "task orchestration", 13 | "task scheduler", 14 | "typescript", 15 | "workflow", 16 | "workflow orchestration" 17 | ], 18 | "homepage": "https://openworkflow.dev", 19 | "bugs": "https://github.com/openworkflowdev/openworkflow/issues", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/openworkflowdev/openworkflow.git", 23 | "directory": "packages/openworkflow" 24 | }, 25 | "license": "Apache-2.0", 26 | "author": "OpenWorkflow", 27 | "type": "module", 28 | "exports": { 29 | ".": { 30 | "types": "./dist/index.d.ts", 31 | "development": "./index.ts", 32 | "default": "./dist/index.js" 33 | }, 34 | "./internal": { 35 | "types": "./dist/internal.d.ts", 36 | "development": "./internal.ts", 37 | "default": "./dist/internal.js" 38 | } 39 | }, 40 | "bin": { 41 | "openworkflow": "./dist/bin/openworkflow.js", 42 | "ow": "./dist/bin/openworkflow.js" 43 | }, 44 | "files": [ 45 | "dist" 46 | ], 47 | "scripts": { 48 | "build": "tsc", 49 | "prepublishOnly": "npm run build" 50 | }, 51 | "devDependencies": { 52 | "arktype": "^2.1.29", 53 | "valibot": "^1.2.0", 54 | "vitest": "^4.0.16", 55 | "yup": "^1.7.1", 56 | "zod": "^4.2.1" 57 | }, 58 | "engines": { 59 | "node": ">=20" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/backend-sqlite/backend.test.ts: -------------------------------------------------------------------------------- 1 | import { BackendSqlite } from "./backend.js"; 2 | import { testBackend } from "@openworkflow/backend-test"; 3 | import assert from "node:assert"; 4 | import { randomUUID } from "node:crypto"; 5 | import { unlinkSync, existsSync } from "node:fs"; 6 | import { tmpdir } from "node:os"; 7 | import path from "node:path"; 8 | import { test, describe, afterAll } from "vitest"; 9 | 10 | test("it is a test file (workaround for sonarjs/no-empty-test-file linter)", () => { 11 | assert.ok(true); 12 | }); 13 | 14 | describe("BackendSqlite (in-memory)", () => { 15 | testBackend({ 16 | setup: () => { 17 | return Promise.resolve( 18 | BackendSqlite.connect(":memory:", { 19 | namespaceId: randomUUID(), 20 | }), 21 | ); 22 | }, 23 | teardown: async (backend) => { 24 | await backend.stop(); 25 | }, 26 | }); 27 | }); 28 | 29 | describe("BackendSqlite (file-based)", () => { 30 | const testDbPath = path.join( 31 | tmpdir(), 32 | `openworkflow-test-${randomUUID()}.db`, 33 | ); 34 | 35 | afterAll(() => { 36 | const walPath = `${testDbPath}-wal`; 37 | const shmPath = `${testDbPath}-shm`; 38 | // clean up the test database, WAL, and SHM files if they exist 39 | if (existsSync(testDbPath)) { 40 | unlinkSync(testDbPath); 41 | } 42 | if (existsSync(walPath)) { 43 | unlinkSync(walPath); 44 | } 45 | if (existsSync(shmPath)) { 46 | unlinkSync(shmPath); 47 | } 48 | }); 49 | 50 | testBackend({ 51 | setup: () => { 52 | return Promise.resolve( 53 | BackendSqlite.connect(testDbPath, { 54 | namespaceId: randomUUID(), 55 | }), 56 | ); 57 | }, 58 | teardown: async (backend) => { 59 | await backend.stop(); 60 | }, 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/cli/templates.ts: -------------------------------------------------------------------------------- 1 | export const SQLITE_CONFIG = `import { BackendSqlite } from "@openworkflow/backend-sqlite"; 2 | import { defineConfig } from "openworkflow"; 3 | 4 | export default defineConfig({ 5 | // Use SQLite as the backend 6 | backend: BackendSqlite.connect(".openworkflow/backend.db"), 7 | 8 | // The directories where your workflows are defined 9 | dirs: "./openworkflow", 10 | }); 11 | `; 12 | 13 | export const POSTGRES_CONFIG = `import { BackendPostgres } from "@openworkflow/backend-postgres"; 14 | import { defineConfig } from "openworkflow"; 15 | 16 | export default defineConfig({ 17 | // Use Postgres as the backend 18 | backend: await BackendPostgres.connect(process.env["OPENWORKFLOW_POSTGRES_URL"]), 19 | 20 | // The directories where your workflows are defined 21 | dirs: "./openworkflow", 22 | }); 23 | `; 24 | 25 | export const POSTGRES_PROD_SQLITE_DEV_CONFIG = `import { BackendPostgres } from "@openworkflow/backend-postgres"; 26 | import { BackendSqlite } from "@openworkflow/backend-sqlite"; 27 | import { defineConfig } from "openworkflow"; 28 | 29 | export default defineConfig({ 30 | // Use Postgres as the backend in production, otherwise use SQLite 31 | backend: 32 | process.env["NODE_ENV"] === "production" 33 | ? await BackendPostgres.connect(process.env["OPENWORKFLOW_POSTGRES_URL"]) 34 | : BackendSqlite.connect(".openworkflow/backend.db"), 35 | 36 | // The directories where your workflows are defined 37 | dirs: "./openworkflow", 38 | }); 39 | `; 40 | 41 | export const HELLO_WORLD_WORKFLOW = `import { defineWorkflow } from "openworkflow"; 42 | 43 | export const helloWorld = defineWorkflow( 44 | { name: "hello-world" }, 45 | async ({ step }) => { 46 | const greeting = await step.run({ name: "greet" }, () => { 47 | return "Hello, World!"; 48 | }); 49 | 50 | return { greeting }; 51 | }, 52 | ); 53 | `; 54 | -------------------------------------------------------------------------------- /packages/backend-postgres/postgres.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_POSTGRES_URL, 3 | DEFAULT_SCHEMA, 4 | Postgres, 5 | newPostgresMaxOne, 6 | migrations, 7 | migrate, 8 | dropSchema, 9 | } from "./postgres.js"; 10 | import { afterAll, beforeAll, describe, expect, test } from "vitest"; 11 | 12 | describe("postgres", () => { 13 | let pg: Postgres; 14 | 15 | beforeAll(() => { 16 | // maxOne since we use SQL-based transactions instead of the postgres 17 | // driver's built-in transactions 18 | pg = newPostgresMaxOne(DEFAULT_POSTGRES_URL); 19 | }); 20 | 21 | afterAll(async () => { 22 | await pg.end(); 23 | }); 24 | 25 | describe("migrations()", () => { 26 | test("returns migrations in 'openworkflow' schema when no schema is specified", () => { 27 | const migs = migrations(DEFAULT_SCHEMA); 28 | for (const mig of migs) { 29 | expect(mig).toContain(`"openworkflow"`); 30 | } 31 | }); 32 | 33 | test("returns migration in the specified schema when one is specified", () => { 34 | const schema = "test_custom_schema"; 35 | const migs = migrations(schema); 36 | for (const mig of migs) { 37 | expect(mig).toContain(`"${schema}"`); 38 | expect(mig).not.toContain(`"openworkflow"`); 39 | } 40 | }); 41 | }); 42 | 43 | describe("migrate()", () => { 44 | test("runs database migrations idempotently", async () => { 45 | const schema = "test_migrate_idempotent"; 46 | await dropSchema(pg, schema); 47 | await migrate(pg, schema); 48 | await migrate(pg, schema); 49 | }); 50 | }); 51 | 52 | describe("dropSchema()", () => { 53 | test("drops the schema idempotently", async () => { 54 | const testSchema = "test_drop_schema_idempotent"; 55 | await migrate(pg, testSchema); 56 | await dropSchema(pg, testSchema); 57 | await dropSchema(pg, testSchema); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/openworkflow/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # openworkflow 2 | 3 | #### Upcoming / unreleased 4 | 5 | - Add CLI for easy project initialization (`ow init`) and worker management 6 | (`ow worker start`) 7 | 8 | ## 0.4.1 9 | 10 | - Add SQLite backend (`@openworkflow/backend-sqlite`) using `node:sqlite` 11 | (requires Node.js 22+). This is now the recommended backend for non-production 12 | environments (@nathancahill) 13 | - Add `declareWorkflow` and `implementWorkflow` APIs to separate workflow 14 | definitions from their implementation logic for tree-shaking 15 | - Fix execution logic when running multiple versions of the same workflow on a 16 | single worker 17 | - A reusable test suite (`@openworkflow/backend-test`) is now available for 18 | contributors building custom backend adapters. See the Postgres and SQLite 19 | backends for example usage. 20 | 21 | ## 0.4.0 22 | 23 | - Add schema validation, compatible with over a dozen validators like Zod, 24 | Valibot, ArkType, and more. [Supported 25 | validators](https://standardschema.dev/#what-schema-libraries-implement-the-spec). 26 | (@mariusflorescu) 27 | - Improve performance when replaying workflows with over 200 steps 28 | - Deprecate `succeeded` status in favor of `completed` (backward compatible) 29 | 30 | And for custom backend implementations: 31 | 32 | - Add pagination to `listStepAttempts` 33 | - Rename `Backend` methods to be verb-first (e.g. `markWorkflowRunFailed` → 34 | `failWorkflowRun`) and add `listWorkflowRuns` 35 | 36 | ## 0.3.0 37 | 38 | - Added workflow versioning to help evolve workflows safely over time. 39 | - Added workflow cancellation so running workflows can now be cancelled safely. 40 | - Improved duration handling and TypeScript type-safety for duration strings. 41 | - Fix for edge case where finished workflow runs could be slept. 42 | 43 | ## 0.2.0 44 | 45 | - Sleep workflows with `step.sleep(name, duration)` 46 | 47 | ## 0.1.0 48 | 49 | - Initial release 50 | -------------------------------------------------------------------------------- /packages/openworkflow/registry.ts: -------------------------------------------------------------------------------- 1 | import type { Workflow } from "./workflow.js"; 2 | 3 | /** 4 | * A registry for storing and retrieving workflows by name and version. 5 | * Provides a centralized way to manage workflow registrations. 6 | */ 7 | export class WorkflowRegistry { 8 | private readonly workflows = new Map< 9 | string, 10 | Workflow 11 | >(); 12 | 13 | /** 14 | * Register a workflow in the registry. 15 | * @param workflow - The workflow to register 16 | * @throws {Error} If a workflow with the same name and version is already registered 17 | */ 18 | register(workflow: Workflow): void { 19 | const name = workflow.spec.name; 20 | const version = workflow.spec.version ?? null; 21 | const key = registryKey(name, version); 22 | if (this.workflows.has(key)) { 23 | const versionStr = version ? ` (version: ${version})` : ""; 24 | throw new Error(`Workflow "${name}"${versionStr} is already registered`); 25 | } 26 | this.workflows.set(key, workflow); 27 | } 28 | 29 | /** 30 | * Get a workflow from the registry by name and version. 31 | * @param name - The workflow name 32 | * @param version - The workflow version (null for unversioned) 33 | * @returns The workflow if found, undefined otherwise 34 | */ 35 | get( 36 | name: string, 37 | version: string | null, 38 | ): Workflow | undefined { 39 | const key = registryKey(name, version); 40 | return this.workflows.get(key); 41 | } 42 | 43 | /** 44 | * Get all registered workflows. 45 | * @returns Array of all registered workflows 46 | */ 47 | getAll(): Workflow[] { 48 | return [...this.workflows.values()]; 49 | } 50 | } 51 | 52 | /** 53 | * Build a registry key from name and version. 54 | * @param name - Workflow name 55 | * @param version - Workflow version (or null) 56 | * @returns Registry key 57 | */ 58 | function registryKey(name: string, version: string | null): string { 59 | return version ? `${name}@${version}` : name; 60 | } 61 | -------------------------------------------------------------------------------- /packages/openworkflow/bin/openworkflow.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * This script is a zero-dependency entrypoint for the OpenWorkflow CLI. It 4 | * allows users to run `npx openworkflow ` without requiring the core 5 | * 'openworkflow' package to have any production dependencies (like commander, 6 | * consola, etc). 7 | * 8 | * Note: This file is transpiled to `./dist/bin/openworkflow.js`. Relative paths 9 | * below are calculated based on that runtime location. 10 | */ 11 | import { spawnSync } from "node:child_process"; 12 | import { existsSync, readFileSync } from "node:fs"; 13 | import path from "node:path"; 14 | import { fileURLToPath } from "node:url"; 15 | 16 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 17 | 18 | // get the version of the current package to ensure we call the matching CLI 19 | const packageJsonPath = path.join(__dirname, "../../package.json"); 20 | const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { 21 | version: string; 22 | }; 23 | const version = packageJson.version; 24 | 25 | // capture all args passed to npx openworkflow (e.g., "init", "worker start") 26 | const args = process.argv.slice(2); 27 | 28 | // build the command, using tsx to run local TypeScript CLI if in dev and 29 | // otherwise using npx to run the published CLI package 30 | const cliScriptFilePath = path.resolve(__dirname, "../../../cli/index.ts"); 31 | const isMonorepo = existsSync(cliScriptFilePath); 32 | 33 | if (isMonorepo) { 34 | console.log( 35 | "⚠️ Running OpenWorkflow CLI from local source (monorepo development mode)\n", 36 | ); 37 | } 38 | 39 | const command = isMonorepo 40 | ? // `npx tsx ../../../cli/index.ts ...args` 41 | ["tsx", cliScriptFilePath, ...args] 42 | : // `npx -y @openworkflow/cli@ ...args` 43 | // uses -y to skip the "Need to install @openworkflow/cli" prompt 44 | ["-y", `@openworkflow/cli@${version}`, ...args]; 45 | 46 | // spawn the CLI the command to run the actual CLI package 47 | const result = spawnSync( 48 | // eslint-disable-next-line sonarjs/no-os-command-from-path 49 | "npx", 50 | command, 51 | { 52 | stdio: "inherit", 53 | shell: true, 54 | }, 55 | ); 56 | 57 | // exit with the same status code as the CLI 58 | process.exit(result.status ?? 0); 59 | -------------------------------------------------------------------------------- /packages/openworkflow/core/error.test.ts: -------------------------------------------------------------------------------- 1 | import { serializeError } from "./error.js"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | describe("serializeError", () => { 5 | test("serializes Error instance with name, message, and stack", () => { 6 | const error = new Error("Something went wrong"); 7 | const result = serializeError(error); 8 | 9 | expect(result.name).toBe("Error"); 10 | expect(result.message).toBe("Something went wrong"); 11 | expect(result.stack).toBeDefined(); 12 | expect(typeof result.stack).toBe("string"); 13 | }); 14 | 15 | test("serializes TypeError with correct name", () => { 16 | const error = new TypeError("Invalid type"); 17 | const result = serializeError(error); 18 | 19 | expect(result.name).toBe("TypeError"); 20 | expect(result.message).toBe("Invalid type"); 21 | }); 22 | 23 | test("serializes custom Error subclass", () => { 24 | class CustomError extends Error { 25 | constructor(message: string) { 26 | super(message); 27 | this.name = "CustomError"; 28 | } 29 | } 30 | const error = new CustomError("Custom error message"); 31 | const result = serializeError(error); 32 | 33 | expect(result.name).toBe("CustomError"); 34 | expect(result.message).toBe("Custom error message"); 35 | }); 36 | 37 | test("serializes Error without stack as undefined", () => { 38 | const error = new Error("No stack"); 39 | // @ts-expect-error testing edge case 40 | error.stack = undefined; 41 | const result = serializeError(error); 42 | 43 | expect(result.stack).toBeUndefined(); 44 | }); 45 | 46 | test("serializes string to message", () => { 47 | const result = serializeError("string error"); 48 | 49 | expect(result.message).toBe("string error"); 50 | expect(result.name).toBeUndefined(); 51 | expect(result.stack).toBeUndefined(); 52 | }); 53 | 54 | test("serializes number to message", () => { 55 | const result = serializeError(42); 56 | 57 | expect(result.message).toBe("42"); 58 | }); 59 | 60 | test("serializes null to message", () => { 61 | const result = serializeError(null); 62 | 63 | expect(result.message).toBe("null"); 64 | }); 65 | 66 | test("serializes undefined to message", () => { 67 | // eslint-disable-next-line unicorn/no-useless-undefined 68 | const result = serializeError(undefined); 69 | 70 | expect(result.message).toBe("undefined"); 71 | }); 72 | 73 | test("serializes object to message using String()", () => { 74 | const result = serializeError({ foo: "bar" }); 75 | 76 | expect(result.message).toBe("[object Object]"); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /packages/openworkflow/config.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from "./backend.js"; 2 | import { WorkerOptions } from "./worker.js"; 3 | import { existsSync } from "node:fs"; 4 | import path from "node:path"; 5 | import { pathToFileURL } from "node:url"; 6 | 7 | export interface OpenWorkflowConfig { 8 | backend: Backend; 9 | worker?: WorkerConfig; 10 | /** 11 | * Directory or directories to scan for workflow files. All `.ts`, `.js`, 12 | * `.mjs`, and `.cjs` files in these directories (recursively) will be loaded. 13 | * Workflow files should export workflows created with `defineWorkflow()`. 14 | * @example "./openworkflow" 15 | * @example ["./openworkflow", "./src/openworkflow", "./workflows"] 16 | */ 17 | dirs?: string | string[]; 18 | } 19 | 20 | export type WorkerConfig = Pick; 21 | 22 | /** 23 | * Create a typed OpenWorkflow configuration. 24 | * @param config - the config 25 | * @returns the config 26 | */ 27 | export function defineConfig(config: OpenWorkflowConfig): OpenWorkflowConfig { 28 | return config; 29 | } 30 | 31 | interface LoadedConfig { 32 | config: OpenWorkflowConfig; 33 | configFile: string | undefined; 34 | } 35 | 36 | const CONFIG_NAME = "openworkflow.config"; 37 | const CONFIG_EXTENSIONS = ["js", "mjs", "cjs"] as const; 38 | 39 | /** 40 | * Load the OpenWorkflow config at openworkflow.config.{js,mjs,cjs}. 41 | * @param rootDir - Optional root directory to search from (defaults to 42 | * process.cwd()) 43 | * @returns The loaded configuration and metadata 44 | */ 45 | export async function loadConfig(rootDir?: string): Promise { 46 | const cwd = rootDir ?? process.cwd(); 47 | 48 | for (const ext of CONFIG_EXTENSIONS) { 49 | const fileName = `${CONFIG_NAME}.${ext}`; 50 | const filePath = path.join(cwd, fileName); 51 | 52 | if (existsSync(filePath)) { 53 | try { 54 | const fileUrl = pathToFileURL(filePath).href; 55 | 56 | const mod = (await import(fileUrl)) as 57 | | { default?: OpenWorkflowConfig } 58 | | OpenWorkflowConfig; 59 | const config = 60 | (mod as { default?: OpenWorkflowConfig }).default ?? 61 | (mod as OpenWorkflowConfig); 62 | 63 | return { 64 | config, 65 | configFile: filePath, 66 | }; 67 | } catch (error: unknown) { 68 | throw new Error( 69 | `Failed to load config file ${filePath}: ${String(error)}`, 70 | ); 71 | } 72 | } 73 | } 74 | 75 | return { 76 | // not great, but meant to match the c12 api since that is what was used in 77 | // the initial implementation of loadConfig 78 | // this can be easily refactored later 79 | config: {} as unknown as OpenWorkflowConfig, 80 | configFile: undefined, // no config found 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /packages/openworkflow/core/schema.ts: -------------------------------------------------------------------------------- 1 | /** The Standard Schema interface. https://standardschema.dev */ 2 | export interface StandardSchemaV1 { 3 | /** The Standard Schema properties. */ 4 | readonly "~standard": StandardSchemaV1.Props; 5 | } 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-namespace 8 | export declare namespace StandardSchemaV1 { 9 | /** The Standard Schema properties interface. */ 10 | // eslint-disable-next-line functional/no-mixed-types 11 | export interface Props { 12 | /** The version number of the standard. */ 13 | readonly version: 1; 14 | /** The vendor name of the schema library. */ 15 | readonly vendor: string; 16 | /** Validates unknown input values. */ 17 | readonly validate: ( 18 | value: unknown, 19 | ) => Result | Promise>; 20 | /** Inferred types associated with the schema. */ 21 | readonly types?: Types | undefined; 22 | } 23 | 24 | /** The result interface of the validate function. */ 25 | export type Result = SuccessResult | FailureResult; 26 | 27 | /** The result interface if validation succeeds. */ 28 | export interface SuccessResult { 29 | /** The typed output value. */ 30 | readonly value: Output; 31 | /** The non-existent issues. */ 32 | readonly issues?: undefined; 33 | } 34 | 35 | /** The result interface if validation fails. */ 36 | export interface FailureResult { 37 | /** The issues of failed validation. */ 38 | readonly issues: readonly Issue[]; 39 | } 40 | 41 | /** The issue interface of the failure output. */ 42 | export interface Issue { 43 | /** The error message of the issue. */ 44 | readonly message: string; 45 | /** The path of the issue, if any. */ 46 | readonly path?: readonly (PropertyKey | PathSegment)[] | undefined; 47 | } 48 | 49 | /** The path segment interface of the issue. */ 50 | export interface PathSegment { 51 | /** The key representing a path segment. */ 52 | readonly key: PropertyKey; 53 | } 54 | 55 | /** The Standard Schema types interface. */ 56 | export interface Types { 57 | /** The input type of the schema. */ 58 | readonly input: Input; 59 | /** The output type of the schema. */ 60 | readonly output: Output; 61 | } 62 | 63 | /** Infers the input type of a Standard Schema. */ 64 | export type InferInput = NonNullable< 65 | Schema["~standard"]["types"] 66 | >["input"]; 67 | 68 | /** Infers the output type of a Standard Schema. */ 69 | export type InferOutput = NonNullable< 70 | Schema["~standard"]["types"] 71 | >["output"]; 72 | 73 | // biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace 74 | // eslint-disable-next-line unicorn/require-module-specifiers 75 | export {}; 76 | } 77 | -------------------------------------------------------------------------------- /packages/openworkflow/core/duration.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "./result.js"; 2 | import { ok, err } from "./result.js"; 3 | 4 | type Years = "years" | "year" | "yrs" | "yr" | "y"; 5 | type Months = "months" | "month" | "mo"; 6 | type Weeks = "weeks" | "week" | "w"; 7 | type Days = "days" | "day" | "d"; 8 | type Hours = "hours" | "hour" | "hrs" | "hr" | "h"; 9 | type Minutes = "minutes" | "minute" | "mins" | "min" | "m"; 10 | type Seconds = "seconds" | "second" | "secs" | "sec" | "s"; 11 | type Milliseconds = "milliseconds" | "millisecond" | "msecs" | "msec" | "ms"; 12 | type Unit = 13 | | Years 14 | | Months 15 | | Weeks 16 | | Days 17 | | Hours 18 | | Minutes 19 | | Seconds 20 | | Milliseconds; 21 | type UnitAnyCase = Capitalize | Uppercase | Lowercase; 22 | export type DurationString = 23 | | `${number}` 24 | | `${number}${UnitAnyCase}` 25 | | `${number} ${UnitAnyCase}`; 26 | 27 | /** 28 | * Parse a duration string into milliseconds. Examples: 29 | * - short units: "1ms", "5s", "30m", "2h", "7d", "3w", "1y" 30 | * - long units: "1 millisecond", "5 seconds", "30 minutes", "2 hours", "7 days", "3 weeks", "1 year" 31 | * @param str - Duration string 32 | * @returns Milliseconds 33 | */ 34 | export function parseDuration(str: DurationString): Result { 35 | if (typeof str !== "string") { 36 | return err( 37 | new TypeError( 38 | "Invalid duration format: expected a string but received " + typeof str, 39 | ), 40 | ); 41 | } 42 | 43 | if (str.length === 0) { 44 | return err(new Error('Invalid duration format: ""')); 45 | } 46 | 47 | const match = /^(-?\.?\d+(?:\.\d+)?)\s*([a-z]+)?$/i.exec(str); 48 | 49 | if (!match?.[1]) { 50 | return err(new Error(`Invalid duration format: "${str}"`)); 51 | } 52 | 53 | const numValue = Number.parseFloat(match[1]); 54 | const unit = match[2]?.toLowerCase() ?? "ms"; // default to ms if not provided 55 | 56 | const multipliers: Record = { 57 | millisecond: 1, 58 | milliseconds: 1, 59 | msec: 1, 60 | msecs: 1, 61 | ms: 1, 62 | second: 1000, 63 | seconds: 1000, 64 | sec: 1000, 65 | secs: 1000, 66 | s: 1000, 67 | minute: 60 * 1000, 68 | minutes: 60 * 1000, 69 | min: 60 * 1000, 70 | mins: 60 * 1000, 71 | m: 60 * 1000, 72 | hour: 60 * 60 * 1000, 73 | hours: 60 * 60 * 1000, 74 | hr: 60 * 60 * 1000, 75 | hrs: 60 * 60 * 1000, 76 | h: 60 * 60 * 1000, 77 | day: 24 * 60 * 60 * 1000, 78 | days: 24 * 60 * 60 * 1000, 79 | d: 24 * 60 * 60 * 1000, 80 | week: 7 * 24 * 60 * 60 * 1000, 81 | weeks: 7 * 24 * 60 * 60 * 1000, 82 | w: 7 * 24 * 60 * 60 * 1000, 83 | month: 2_629_800_000, 84 | months: 2_629_800_000, 85 | mo: 2_629_800_000, 86 | year: 31_557_600_000, 87 | years: 31_557_600_000, 88 | yr: 31_557_600_000, 89 | yrs: 31_557_600_000, 90 | y: 31_557_600_000, 91 | }; 92 | 93 | const multiplier = multipliers[unit]; 94 | if (multiplier === undefined) { 95 | return err(new Error(`Invalid duration format: "${str}"`)); 96 | } 97 | 98 | return ok(numValue * multiplier); 99 | } 100 | -------------------------------------------------------------------------------- /packages/openworkflow/core/workflow.ts: -------------------------------------------------------------------------------- 1 | import type { SerializedError } from "./error.js"; 2 | import { JsonValue } from "./json.js"; 3 | import type { StandardSchemaV1 } from "./schema.js"; 4 | 5 | /** 6 | * Status of a workflow run through its lifecycle. 7 | */ 8 | export type WorkflowRunStatus = 9 | | "pending" 10 | | "running" 11 | | "sleeping" 12 | | "succeeded" // deprecated in favor of 'completed' 13 | | "completed" 14 | | "failed" 15 | | "canceled"; 16 | 17 | /** 18 | * WorkflowRun represents a single execution instance of a workflow. 19 | */ 20 | export interface WorkflowRun { 21 | namespaceId: string; 22 | id: string; 23 | workflowName: string; 24 | version: string | null; 25 | status: WorkflowRunStatus; 26 | idempotencyKey: string | null; 27 | config: JsonValue; // user-defined config 28 | context: JsonValue | null; // runtime execution metadata 29 | input: JsonValue | null; 30 | output: JsonValue | null; 31 | error: SerializedError | null; 32 | attempts: number; 33 | parentStepAttemptNamespaceId: string | null; 34 | parentStepAttemptId: string | null; 35 | workerId: string | null; 36 | availableAt: Date | null; 37 | deadlineAt: Date | null; 38 | startedAt: Date | null; 39 | finishedAt: Date | null; 40 | createdAt: Date; 41 | updatedAt: Date; 42 | } 43 | 44 | /** 45 | * Infers the input type from a Standard Schema. 46 | */ 47 | export type SchemaInput = TSchema extends StandardSchemaV1 48 | ? StandardSchemaV1.InferInput 49 | : Fallback; 50 | 51 | /** 52 | * Infers the output type from a Standard Schema. 53 | */ 54 | export type SchemaOutput = TSchema extends StandardSchemaV1 55 | ? StandardSchemaV1.InferOutput 56 | : Fallback; 57 | 58 | /** 59 | * Result of input validation - either success with a value or failure with an 60 | * error message. 61 | */ 62 | export type ValidationResult = 63 | | { success: true; value: T } 64 | | { success: false; error: string }; 65 | 66 | /** 67 | * Validate input against a Standard Schema. Pure async function that validates 68 | * input and returns a ValidationResult. 69 | * @param schema - The Standard Schema to validate against (or null/undefined 70 | * for no validation) 71 | * @param input - The input value to validate 72 | * @returns A ValidationResult containing either the validated value or an error 73 | * message 74 | */ 75 | export async function validateInput( 76 | schema: StandardSchemaV1 | null | undefined, 77 | input: RunInput | undefined, 78 | ): Promise> { 79 | // No schema means no validation - pass through as-is 80 | if (!schema) { 81 | return { 82 | success: true, 83 | value: input as unknown as Input, 84 | }; 85 | } 86 | 87 | // Validate using Standard Schema v1 protocol https://standardschema.dev 88 | const result = schema["~standard"].validate(input); 89 | const resolved = await Promise.resolve(result); 90 | 91 | if (resolved.issues) { 92 | const messages = 93 | resolved.issues.length > 0 94 | ? resolved.issues.map((issue) => issue.message).join("; ") 95 | : "Validation failed"; 96 | return { 97 | success: false, 98 | error: messages, 99 | }; 100 | } 101 | 102 | return { 103 | success: true, 104 | value: resolved.value, 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /packages/openworkflow/workflow.test.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkflow, defineWorkflowSpec, isWorkflow } from "./workflow.js"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | describe("defineWorkflowSpec", () => { 5 | test("returns spec (passthrough)", () => { 6 | const spec = { name: "test-workflow" }; 7 | const definedSpec = defineWorkflowSpec(spec); 8 | 9 | expect(definedSpec).toStrictEqual(spec); 10 | }); 11 | }); 12 | 13 | describe("defineWorkflow", () => { 14 | test("returns workflow with spec and fn", () => { 15 | // eslint-disable-next-line unicorn/consistent-function-scoping 16 | function fn() { 17 | return { result: "done" }; 18 | } 19 | 20 | const spec = { name: "test-workflow" }; 21 | const workflow = defineWorkflow(spec, fn); 22 | 23 | expect(workflow).toStrictEqual({ 24 | spec, 25 | fn, 26 | }); 27 | }); 28 | }); 29 | 30 | describe("isWorkflow", () => { 31 | test("returns true for valid workflow objects", () => { 32 | const workflow = defineWorkflow({ name: "test" }, () => "done"); 33 | expect(isWorkflow(workflow)).toBe(true); 34 | }); 35 | 36 | test("returns false for null", () => { 37 | expect(isWorkflow(null)).toBe(false); 38 | }); 39 | 40 | test("returns false for undefined", () => { 41 | // eslint-disable-next-line unicorn/no-useless-undefined 42 | expect(isWorkflow(undefined)).toBe(false); 43 | }); 44 | 45 | test("returns false for primitives", () => { 46 | expect(isWorkflow("string")).toBe(false); 47 | expect(isWorkflow(123)).toBe(false); 48 | expect(isWorkflow(true)).toBe(false); 49 | }); 50 | 51 | test("returns false for objects without spec", () => { 52 | expect(isWorkflow({ fn: () => "result" })).toBe(false); 53 | }); 54 | 55 | test("returns false for objects without fn", () => { 56 | expect(isWorkflow({ spec: { name: "test" } })).toBe(false); 57 | }); 58 | 59 | test("returns false for objects with invalid spec", () => { 60 | expect(isWorkflow({ spec: null, fn: () => "result" })).toBe(false); 61 | expect(isWorkflow({ spec: "invalid", fn: () => "result" })).toBe(false); 62 | }); 63 | 64 | test("returns false for objects with invalid fn", () => { 65 | expect(isWorkflow({ spec: { name: "test" }, fn: "not-a-function" })).toBe( 66 | false, 67 | ); 68 | }); 69 | }); 70 | 71 | // --- type checks below ------------------------------------------------------- 72 | // they're unused but useful to ensure that the types work as expected for both 73 | // defineWorkflowSpec and defineWorkflow 74 | 75 | const inferredTypesSpec = defineWorkflowSpec({ 76 | name: "inferred-types", 77 | }); 78 | defineWorkflow(inferredTypesSpec, async ({ step }) => { 79 | await step.run({ name: "step-1" }, () => { 80 | return "success"; 81 | }); 82 | 83 | return { result: "done" }; 84 | }); 85 | 86 | const explicitInputTypeSpec = defineWorkflowSpec<{ name: string }>({ 87 | name: "explicit-input-type", 88 | }); 89 | defineWorkflow(explicitInputTypeSpec, async ({ step }) => { 90 | await step.run({ name: "step-1" }, () => { 91 | return "success"; 92 | }); 93 | 94 | return { result: "done" }; 95 | }); 96 | 97 | const explicitInputAndOutputTypesSpec = defineWorkflowSpec< 98 | { name: string }, 99 | { result: string } 100 | >({ 101 | name: "explicit-input-and-output-types", 102 | }); 103 | defineWorkflow(explicitInputAndOutputTypesSpec, async ({ step }) => { 104 | await step.run({ name: "step-1" }, () => { 105 | return "success"; 106 | }); 107 | 108 | return { result: "done" }; 109 | }); 110 | -------------------------------------------------------------------------------- /packages/openworkflow/config.test.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "../backend-postgres/backend.js"; 2 | import { DEFAULT_POSTGRES_URL } from "../backend-postgres/postgres.js"; 3 | import { defineConfig, loadConfig } from "./config.js"; 4 | import { randomUUID } from "node:crypto"; 5 | import fs from "node:fs"; 6 | import os from "node:os"; 7 | import path from "node:path"; 8 | import { beforeEach, afterEach, describe, expect, test } from "vitest"; 9 | 10 | describe("defineConfig", async () => { 11 | const backend = await BackendPostgres.connect(DEFAULT_POSTGRES_URL, { 12 | namespaceId: randomUUID(), 13 | }); 14 | 15 | test("returns the same config", () => { 16 | const config = { backend }; 17 | const result = defineConfig(config); 18 | expect(result).toBe(config); 19 | }); 20 | }); 21 | 22 | interface TestConfig { 23 | name: string; 24 | } 25 | 26 | describe("loadConfig", () => { 27 | let tmpDir: string; 28 | 29 | beforeEach(() => { 30 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ow-")); 31 | }); 32 | 33 | afterEach(() => { 34 | fs.rmSync(tmpDir, { recursive: true, force: true }); 35 | }); 36 | 37 | test.each([ 38 | [ 39 | "cjs", 40 | `module.exports = { name: "cjs" };`, 41 | "openworkflow.config.cjs", 42 | "cjs", 43 | ], 44 | [ 45 | "js", 46 | `export default { name: "js-default" }`, 47 | "openworkflow.config.js", 48 | "js-default", 49 | ], 50 | [ 51 | "mjs", 52 | `export const name = "mjs-named";`, 53 | "openworkflow.config.mjs", 54 | "mjs-named", 55 | ], 56 | ])("loads %s config", async (_ext, content, filename, expectedName) => { 57 | const filePath = path.join(tmpDir, filename); 58 | fs.writeFileSync(filePath, content); 59 | 60 | const { config, configFile } = await loadConfig(tmpDir); 61 | const cfg = config as unknown as TestConfig; // this'll work until we validate 62 | expect(cfg.name).toBe(expectedName); 63 | expect(configFile).toContain(filename); 64 | }); 65 | 66 | test("throws if importing the config file fails", async () => { 67 | const filePath = path.join(tmpDir, "openworkflow.config.js"); 68 | // simulate failure, throw when imported 69 | fs.writeFileSync(filePath, `throw new Error("boom")`); 70 | 71 | await expect(loadConfig(tmpDir)).rejects.toThrow( 72 | /Failed to load config file/, 73 | ); 74 | }); 75 | 76 | test("returns empty config object when no config file is found", async () => { 77 | const { config, configFile } = await loadConfig(tmpDir); 78 | expect(config).toEqual({}); 79 | expect(configFile).toBeUndefined(); 80 | }); 81 | 82 | test("falls back to module when default export is undefined", async () => { 83 | const filePath = path.join(tmpDir, "openworkflow.config.js"); 84 | fs.writeFileSync( 85 | filePath, 86 | `export default undefined; export const name = "fallback";`, 87 | ); 88 | 89 | const { config } = await loadConfig(tmpDir); 90 | const cfg = config as unknown as TestConfig; 91 | expect(cfg.name).toBe("fallback"); 92 | }); 93 | 94 | test("uses process.cwd when rootDir is not provided", async () => { 95 | const originalCwd = process.cwd(); 96 | try { 97 | const filePath = path.join(tmpDir, "openworkflow.config.js"); 98 | fs.writeFileSync(filePath, `export default { name: "cwd" };`); 99 | 100 | process.chdir(tmpDir); 101 | const { config, configFile } = await loadConfig(); 102 | const cfg = config as unknown as TestConfig; 103 | expect(cfg.name).toBe("cwd"); 104 | expect(configFile).toContain("openworkflow.config.js"); 105 | } finally { 106 | process.chdir(originalCwd); 107 | } 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /benchmarks/basic/index.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { randomUUID } from "node:crypto"; 3 | import { OpenWorkflow } from "openworkflow"; 4 | 5 | const WORKFLOW_RUN_COUNT = 1000; 6 | const WORKER_CONCURRENCY = 100; 7 | 8 | async function main() { 9 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 10 | const backend = await BackendPostgres.connect(databaseUrl, { 11 | namespaceId: randomUUID(), 12 | }); 13 | const client = new OpenWorkflow({ 14 | backend, 15 | }); 16 | 17 | const workflow = client.defineWorkflow( 18 | { name: "benchmark-workflow" }, 19 | async ({ step }) => { 20 | await step.run({ name: "step-1" }, () => { 21 | return; 22 | }); 23 | await step.run({ name: "step-2" }, () => { 24 | return; 25 | }); 26 | await step.run({ name: "step-3" }, () => { 27 | return; 28 | }); 29 | await step.run({ name: "step-4" }, () => { 30 | return; 31 | }); 32 | return { completed: true }; 33 | }, 34 | ); 35 | 36 | const worker = client.newWorker({ concurrency: WORKER_CONCURRENCY }); 37 | 38 | console.log("Starting benchmark..."); 39 | console.log("Configuration:"); 40 | console.log(` - Workflow count: ${WORKFLOW_RUN_COUNT.toString()}`); 41 | console.log(` - Concurrency: ${WORKER_CONCURRENCY.toString()}`); 42 | console.log(" - Steps per workflow: 4"); 43 | console.log(""); 44 | 45 | console.log("Phase 1: Enqueuing workflows..."); 46 | const enqueueStart = Date.now(); 47 | 48 | const handles = await Promise.all( 49 | Array.from({ length: WORKFLOW_RUN_COUNT }, () => workflow.run()), 50 | ); 51 | 52 | const enqueueTime = Date.now() - enqueueStart; 53 | const enqueuePerSec = (WORKFLOW_RUN_COUNT / (enqueueTime / 1000)).toFixed(2); 54 | 55 | console.log( 56 | `Enqueued ${WORKFLOW_RUN_COUNT.toString()} workflows in ${enqueueTime.toString()}ms`, 57 | ); 58 | console.log(` (${enqueuePerSec} workflows/sec)`); 59 | console.log(""); 60 | 61 | console.log("Phase 2: Processing workflows..."); 62 | const processStart = Date.now(); 63 | 64 | await worker.start(); 65 | 66 | // wait for all workflows to complete 67 | await Promise.all(handles.map((h) => h.result())); 68 | 69 | const processTime = Date.now() - processStart; 70 | const totalTime = enqueueTime + processTime; 71 | 72 | await worker.stop(); 73 | 74 | const workflowsPerSecond = ( 75 | WORKFLOW_RUN_COUNT / 76 | (processTime / 1000) 77 | ).toFixed(2); 78 | const stepsPerSecond = ( 79 | (WORKFLOW_RUN_COUNT * 4) / 80 | (processTime / 1000) 81 | ).toFixed(2); 82 | const avgLatencyMs = (processTime / WORKFLOW_RUN_COUNT).toFixed(2); 83 | 84 | console.log( 85 | `Processed ${WORKFLOW_RUN_COUNT.toString()} workflows in ${processTime.toString()}ms`, 86 | ); 87 | console.log(""); 88 | console.log("Results:"); 89 | console.log(""); 90 | console.log(`Enqueue Time: ${enqueueTime.toString()}ms`); 91 | console.log(`Process Time: ${processTime.toString()}ms`); 92 | console.log(`Total Time: ${totalTime.toString()}ms`); 93 | console.log(""); 94 | console.log(`Workflows Completed: ${WORKFLOW_RUN_COUNT.toString()}`); 95 | console.log( 96 | `Steps Executed: ${(WORKFLOW_RUN_COUNT * 4).toString()}`, 97 | ); 98 | console.log(""); 99 | console.log(`Workflows/sec: ${workflowsPerSecond}`); 100 | console.log(`Steps/sec: ${stepsPerSecond}`); 101 | console.log(`Avg Latency: ${avgLatencyMs}ms`); 102 | 103 | await backend.stop(); 104 | } 105 | 106 | await main().catch((error: unknown) => { 107 | console.error("Benchmark failed:", error); 108 | throw error; 109 | }); 110 | -------------------------------------------------------------------------------- /packages/openworkflow/workflow.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "./core/schema.js"; 2 | import { WorkflowFunction } from "./execution.js"; 3 | 4 | /** 5 | * A workflow spec. 6 | */ 7 | export interface WorkflowSpec { 8 | /** The name of the workflow. */ 9 | readonly name: string; 10 | /** The version of the workflow. */ 11 | readonly version?: string; 12 | /** The schema used to validate inputs. */ 13 | readonly schema?: StandardSchemaV1; 14 | /** Phantom type carrier - won't exist at runtime. */ 15 | readonly __types?: { 16 | output: Output; 17 | }; 18 | } 19 | 20 | /** 21 | * Define a workflow spec. 22 | * @param spec - The workflow spec 23 | * @returns The workflow spec 24 | */ 25 | export function defineWorkflowSpec( 26 | spec: WorkflowSpec, 27 | ): WorkflowSpec { 28 | return spec; 29 | } 30 | 31 | /** 32 | * Define a workflow spec. 33 | * @param spec - The workflow spec 34 | * @returns The workflow spec 35 | * @deprecated use `defineWorkflowSpec` instead 36 | */ 37 | export const declareWorkflow = defineWorkflowSpec; 38 | 39 | /** 40 | * A workflow spec and implementation. 41 | */ 42 | export interface Workflow { 43 | /** The workflow spec. */ 44 | readonly spec: WorkflowSpec; 45 | /** The workflow implementation function. */ 46 | readonly fn: WorkflowFunction; 47 | } 48 | 49 | /** 50 | * Define a workflow. 51 | * @param spec - The workflow spec 52 | * @param fn - The workflow implementation function 53 | * @returns The workflow 54 | */ 55 | // Handles: 56 | // - `defineWorkflow(spec, impl)` (0 generics) 57 | // - `defineWorkflow(spec, impl)` (2 generics) 58 | export function defineWorkflow( 59 | spec: WorkflowSpec, 60 | fn: WorkflowFunction, 61 | ): Workflow; 62 | 63 | /** 64 | * Define a workflow. 65 | * @param spec - The workflow spec 66 | * @param fn - The workflow implementation function 67 | * @returns The workflow 68 | */ 69 | // Handles: 70 | // - `defineWorkflow(spec, impl)` (1 generic) 71 | export function defineWorkflow< 72 | Input, 73 | WorkflowFn extends WorkflowFunction = WorkflowFunction< 74 | Input, 75 | unknown 76 | >, 77 | RawInput = Input, 78 | >( 79 | spec: WorkflowSpec>, RawInput>, 80 | fn: WorkflowFn, 81 | ): Workflow>, RawInput>; 82 | 83 | /** 84 | * Define a workflow. 85 | * @internal 86 | * @param spec - The workflow spec 87 | * @param fn - The workflow implementation function 88 | * @returns The workflow 89 | */ 90 | export function defineWorkflow( 91 | spec: WorkflowSpec, 92 | fn: WorkflowFunction, 93 | ): Workflow { 94 | return { 95 | spec, 96 | fn, 97 | }; 98 | } 99 | 100 | /** 101 | * Type guard to check if a value is a Workflow object. 102 | * @param value - The value to check 103 | * @returns True if the value is a Workflow 104 | */ 105 | export function isWorkflow(value: unknown) { 106 | if (typeof value !== "object" || value === null) { 107 | return false; 108 | } 109 | 110 | const maybeWorkflow = value as Record; 111 | if (!("spec" in maybeWorkflow) || !("fn" in maybeWorkflow)) { 112 | return false; 113 | } 114 | 115 | const { spec, fn } = maybeWorkflow; 116 | return ( 117 | typeof spec === "object" && 118 | spec !== null && 119 | "name" in spec && 120 | typeof spec.name === "string" && 121 | typeof fn === "function" 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import cspell from "@cspell/eslint-plugin/configs"; 3 | import eslint from "@eslint/js"; 4 | import prettier from "eslint-config-prettier"; 5 | import functional from "eslint-plugin-functional"; 6 | import importPlugin from "eslint-plugin-import"; 7 | import jsdoc from "eslint-plugin-jsdoc"; 8 | import sonarjs from "eslint-plugin-sonarjs"; 9 | import unicorn from "eslint-plugin-unicorn"; 10 | import { defineConfig } from "eslint/config"; 11 | import tseslint from "typescript-eslint"; 12 | 13 | export default defineConfig( 14 | eslint.configs.recommended, 15 | tseslint.configs.strictTypeChecked, 16 | tseslint.configs.stylisticTypeChecked, 17 | importPlugin.flatConfigs.recommended, 18 | importPlugin.flatConfigs.typescript, 19 | sonarjs.configs.recommended, 20 | unicorn.configs.recommended, 21 | jsdoc.configs["flat/recommended-typescript-error"], 22 | cspell.recommended, 23 | prettier, 24 | { 25 | ignores: [ 26 | "**/dist", 27 | "coverage", 28 | "eslint.config.js", 29 | "prettier.config.js", 30 | "examples/workflow-discovery/openworkflow.config.js", 31 | ], 32 | }, 33 | { 34 | languageOptions: { 35 | parserOptions: { 36 | projectService: true, 37 | tsconfigRootDir: import.meta.dirname, 38 | }, 39 | }, 40 | }, 41 | // --------------------------------------------------------------------------- 42 | { 43 | settings: { 44 | "import/resolver": { 45 | typescript: { 46 | alwaysTryTypes: true, 47 | }, 48 | }, 49 | }, 50 | }, 51 | // --------------------------------------------------------------------------- 52 | { 53 | rules: { 54 | "@cspell/spellchecker": [ 55 | "error", 56 | { 57 | cspell: { 58 | flagWords: ["cancellation", "cancelled"], // prefer en-US spelling for consistency 59 | ignoreWords: [ 60 | "arktype", 61 | "heartbeating", 62 | "idempotently", 63 | "openworkflow", 64 | "sonarjs", 65 | "timestamptz", 66 | ], 67 | }, 68 | }, 69 | ], 70 | "@typescript-eslint/unified-signatures": "off", // Buggy rule, to be enabled later 71 | "func-style": ["error", "declaration"], 72 | // "import/no-cycle": "error", // doubles eslint time, enable occasionally to check for cycles 73 | "import/no-extraneous-dependencies": "error", 74 | "import/no-relative-parent-imports": "error", 75 | "import/no-useless-path-segments": "error", 76 | "jsdoc/check-indentation": "error", 77 | "jsdoc/require-throws": "error", 78 | "jsdoc/sort-tags": "error", 79 | "unicorn/no-null": "off", 80 | "unicorn/prevent-abbreviations": "off", 81 | }, 82 | }, 83 | { 84 | files: ["**/*.test.ts", "benchmarks/**/*.ts", "examples/**/*.ts"], 85 | rules: { 86 | "jsdoc/require-jsdoc": "off", 87 | }, 88 | }, 89 | { 90 | files: ["**/*.test.ts", "packages/backend-postgres/scripts/**/*.ts"], 91 | rules: { 92 | "import/no-relative-parent-imports": "off", 93 | }, 94 | }, 95 | { 96 | files: ["**/*.test.ts", "**/*.testsuite.ts"], 97 | rules: { 98 | "sonarjs/no-nested-functions": "off", 99 | }, 100 | }, 101 | { 102 | files: ["packages/cli/templates/**/*.ts"], 103 | rules: { 104 | "import/no-extraneous-dependencies": "off", 105 | }, 106 | }, 107 | { 108 | files: ["packages/openworkflow/core/**/*.ts"], 109 | ignores: ["**/*.test.ts", "**/*.testsuite.ts"], 110 | plugins: { 111 | // @ts-expect-error - eslint-plugin-functional types don't align with eslint's Plugin type 112 | functional, 113 | }, 114 | rules: { 115 | ...functional.configs.externalTypeScriptRecommended.rules, 116 | ...functional.configs.recommended.rules, 117 | ...functional.configs.stylistic.rules, 118 | "functional/prefer-property-signatures": "off", 119 | }, 120 | }, 121 | ); 122 | -------------------------------------------------------------------------------- /packages/openworkflow/chaos.test.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "../backend-postgres/backend.js"; 2 | import { DEFAULT_POSTGRES_URL } from "../backend-postgres/postgres.js"; 3 | import { OpenWorkflow } from "./client.js"; 4 | import { Worker } from "./worker.js"; 5 | import { randomInt, randomUUID } from "node:crypto"; 6 | import { describe, expect, test } from "vitest"; 7 | 8 | const TOTAL_STEPS = 50; 9 | const WORKER_COUNT = 3; 10 | const WORKER_CONCURRENCY = 2; 11 | const STEP_DURATION_MS = 25; 12 | const CHAOS_DURATION_MS = 5000; 13 | const CHAOS_INTERVAL_MS = 200; 14 | const TEST_TIMEOUT_MS = 30_000; 15 | 16 | describe("chaos test", () => { 17 | test( 18 | "workflow completes despite random worker deaths", 19 | async () => { 20 | const backend = await createBackend(); 21 | const client = new OpenWorkflow({ backend }); 22 | 23 | const workflow = client.defineWorkflow( 24 | { name: "chaos-workflow" }, 25 | async ({ step }) => { 26 | const results: number[] = []; 27 | for (let i = 0; i < TOTAL_STEPS; i++) { 28 | const stepName = `step-${i.toString()}`; 29 | const result = await step.run({ name: stepName }, async () => { 30 | await sleep(STEP_DURATION_MS); // fake work 31 | return i; 32 | }); 33 | results.push(result); 34 | } 35 | return results; 36 | }, 37 | ); 38 | 39 | const workers = await Promise.all( 40 | Array.from({ length: WORKER_COUNT }, () => 41 | createAndStartWorker(client), 42 | ), 43 | ); 44 | 45 | const handle = await workflow.run(); 46 | let workflowCompleted = false; 47 | let chaosTask: Promise | null = null; 48 | 49 | try { 50 | chaosTask = runChaosTest({ 51 | client, 52 | workers, 53 | durationMs: CHAOS_DURATION_MS, 54 | intervalMs: CHAOS_INTERVAL_MS, 55 | shouldStop: () => workflowCompleted, 56 | }); 57 | 58 | const result = await handle.result(); 59 | workflowCompleted = true; 60 | const restarts = await chaosTask; 61 | 62 | expect(result).toHaveLength(TOTAL_STEPS); 63 | expect(result[TOTAL_STEPS - 1]).toBe(TOTAL_STEPS - 1); 64 | expect(restarts).toBeGreaterThan(0); 65 | } finally { 66 | workflowCompleted = true; 67 | if (chaosTask) await chaosTask; 68 | await Promise.all(workers.map((worker) => worker.stop())); 69 | await backend.stop(); 70 | } 71 | }, 72 | TEST_TIMEOUT_MS, 73 | ); 74 | }); 75 | 76 | async function runChaosTest(params: { 77 | client: OpenWorkflow; 78 | workers: Worker[]; 79 | durationMs: number; 80 | intervalMs: number; 81 | shouldStop: () => boolean; 82 | }): Promise { 83 | const { client, workers, durationMs, intervalMs, shouldStop } = params; 84 | const chaosEndsAt = Date.now() + durationMs; 85 | let restartCount = 0; 86 | 87 | while (Date.now() < chaosEndsAt && !shouldStop()) { 88 | await sleep(intervalMs); 89 | if (workers.length === 0) { 90 | workers.push(await createAndStartWorker(client)); 91 | continue; 92 | } 93 | 94 | const index = randomInt(workers.length); 95 | const victim = workers.splice(index, 1)[0]; 96 | await victim?.stop(); 97 | 98 | const replacement = await createAndStartWorker(client); 99 | workers.push(replacement); 100 | restartCount++; 101 | } 102 | 103 | return restartCount; 104 | } 105 | 106 | async function createBackend(): Promise { 107 | return await BackendPostgres.connect(DEFAULT_POSTGRES_URL, { 108 | namespaceId: randomUUID(), 109 | }); 110 | } 111 | 112 | async function createAndStartWorker(client: OpenWorkflow): Promise { 113 | const worker = client.newWorker({ concurrency: WORKER_CONCURRENCY }); 114 | await worker.start(); 115 | return worker; 116 | } 117 | 118 | function sleep(ms: number): Promise { 119 | return new Promise((resolve) => setTimeout(resolve, ms)); 120 | } 121 | -------------------------------------------------------------------------------- /packages/openworkflow/backend.ts: -------------------------------------------------------------------------------- 1 | import type { SerializedError } from "./core/error.js"; 2 | import { JsonValue } from "./core/json.js"; 3 | import type { StepAttempt, StepAttemptContext, StepKind } from "./core/step.js"; 4 | import type { WorkflowRun } from "./core/workflow.js"; 5 | 6 | export const DEFAULT_NAMESPACE_ID = "default"; 7 | 8 | /** 9 | * Backend is the interface for backend providers to implement. 10 | */ 11 | export interface Backend { 12 | // Workflow Runs 13 | createWorkflowRun( 14 | params: Readonly, 15 | ): Promise; 16 | getWorkflowRun( 17 | params: Readonly, 18 | ): Promise; 19 | listWorkflowRuns( 20 | params: Readonly, 21 | ): Promise>; 22 | claimWorkflowRun( 23 | params: Readonly, 24 | ): Promise; 25 | extendWorkflowRunLease( 26 | params: Readonly, 27 | ): Promise; 28 | sleepWorkflowRun( 29 | params: Readonly, 30 | ): Promise; 31 | completeWorkflowRun( 32 | params: Readonly, 33 | ): Promise; 34 | failWorkflowRun( 35 | params: Readonly, 36 | ): Promise; 37 | cancelWorkflowRun( 38 | params: Readonly, 39 | ): Promise; 40 | 41 | // Step Attempts 42 | createStepAttempt( 43 | params: Readonly, 44 | ): Promise; 45 | getStepAttempt( 46 | params: Readonly, 47 | ): Promise; 48 | listStepAttempts( 49 | params: Readonly, 50 | ): Promise>; 51 | completeStepAttempt( 52 | params: Readonly, 53 | ): Promise; 54 | failStepAttempt( 55 | params: Readonly, 56 | ): Promise; 57 | 58 | // Lifecycle 59 | stop(): Promise; 60 | } 61 | 62 | export interface CreateWorkflowRunParams { 63 | workflowName: string; 64 | version: string | null; 65 | idempotencyKey: string | null; 66 | config: JsonValue; 67 | context: JsonValue | null; 68 | input: JsonValue | null; 69 | availableAt: Date | null; // null = immediately 70 | deadlineAt: Date | null; // null = no deadline 71 | } 72 | 73 | export interface GetWorkflowRunParams { 74 | workflowRunId: string; 75 | } 76 | 77 | export type ListWorkflowRunsParams = PaginationOptions; 78 | 79 | export interface ClaimWorkflowRunParams { 80 | workerId: string; 81 | leaseDurationMs: number; 82 | } 83 | 84 | export interface ExtendWorkflowRunLeaseParams { 85 | workflowRunId: string; 86 | workerId: string; 87 | leaseDurationMs: number; 88 | } 89 | 90 | export interface SleepWorkflowRunParams { 91 | workflowRunId: string; 92 | workerId: string; 93 | availableAt: Date; 94 | } 95 | 96 | export interface CompleteWorkflowRunParams { 97 | workflowRunId: string; 98 | workerId: string; 99 | output: JsonValue | null; 100 | } 101 | 102 | export interface FailWorkflowRunParams { 103 | workflowRunId: string; 104 | workerId: string; 105 | error: SerializedError; 106 | } 107 | 108 | export interface CancelWorkflowRunParams { 109 | workflowRunId: string; 110 | } 111 | 112 | export interface CreateStepAttemptParams { 113 | workflowRunId: string; 114 | workerId: string; 115 | stepName: string; 116 | kind: StepKind; 117 | config: JsonValue; 118 | context: StepAttemptContext | null; 119 | } 120 | 121 | export interface GetStepAttemptParams { 122 | stepAttemptId: string; 123 | } 124 | 125 | export interface ListStepAttemptsParams extends PaginationOptions { 126 | workflowRunId: string; 127 | } 128 | 129 | export interface CompleteStepAttemptParams { 130 | workflowRunId: string; 131 | stepAttemptId: string; 132 | workerId: string; 133 | output: JsonValue | null; 134 | } 135 | 136 | export interface FailStepAttemptParams { 137 | workflowRunId: string; 138 | stepAttemptId: string; 139 | workerId: string; 140 | error: SerializedError; 141 | } 142 | 143 | export interface PaginationOptions { 144 | limit?: number; 145 | after?: string; 146 | before?: string; 147 | } 148 | 149 | export interface PaginatedResponse { 150 | data: T[]; 151 | pagination: { 152 | next: string | null; 153 | prev: string | null; 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /packages/openworkflow/core/workflow.test.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "./schema.js"; 2 | import { validateInput } from "./workflow.js"; 3 | import { describe, expect, test } from "vitest"; 4 | 5 | describe("validateInput", () => { 6 | test("returns success with input when no schema provided (null)", async () => { 7 | const input = { name: "test", value: 42 }; 8 | const result = await validateInput(null, input); 9 | 10 | expect(result.success).toBe(true); 11 | if (result.success) { 12 | expect(result.value).toBe(input); 13 | } 14 | }); 15 | 16 | test("returns success with input when no schema provided (undefined)", async () => { 17 | const input = "string input"; 18 | const result = await validateInput(undefined, input); 19 | 20 | expect(result.success).toBe(true); 21 | if (result.success) { 22 | expect(result.value).toBe(input); 23 | } 24 | }); 25 | 26 | test("validates input successfully against schema", async () => { 27 | const schema = createMockSchema<{ name: string }>({ 28 | validate: (input) => ({ value: input as { name: string } }), 29 | }); 30 | const input = { name: "test" }; 31 | 32 | const result = await validateInput(schema, input); 33 | 34 | expect(result.success).toBe(true); 35 | if (result.success) { 36 | expect(result.value).toEqual({ name: "test" }); 37 | } 38 | }); 39 | 40 | test("transforms input using schema", async () => { 41 | const schema = createMockSchema({ 42 | validate: (input) => ({ value: Number.parseInt(input as string, 10) }), 43 | }); 44 | 45 | const result = await validateInput(schema, "42"); 46 | 47 | expect(result.success).toBe(true); 48 | if (result.success) { 49 | expect(result.value).toBe(42); 50 | } 51 | }); 52 | 53 | test("returns failure with error message when validation fails", async () => { 54 | const schema = createMockSchema({ 55 | validate: () => ({ 56 | issues: [{ message: "Invalid input" }], 57 | }), 58 | }); 59 | 60 | const result = await validateInput(schema, "bad input"); 61 | 62 | expect(result.success).toBe(false); 63 | if (!result.success) { 64 | expect(result.error).toBe("Invalid input"); 65 | } 66 | }); 67 | 68 | test("combines multiple validation error messages", async () => { 69 | const schema = createMockSchema<{ email: string; age: number }>({ 70 | validate: () => ({ 71 | issues: [ 72 | { message: "Invalid email format" }, 73 | { message: "Age must be positive" }, 74 | ], 75 | }), 76 | }); 77 | 78 | const result = await validateInput(schema, { 79 | email: "invalid", 80 | age: -5, 81 | }); 82 | 83 | expect(result.success).toBe(false); 84 | if (!result.success) { 85 | expect(result.error).toBe("Invalid email format; Age must be positive"); 86 | } 87 | }); 88 | 89 | test("returns generic message when issues array is empty", async () => { 90 | const schema = createMockSchema({ 91 | validate: () => ({ 92 | issues: [], 93 | }), 94 | }); 95 | 96 | const result = await validateInput(schema, "test"); 97 | 98 | expect(result.success).toBe(false); 99 | if (!result.success) { 100 | expect(result.error).toBe("Validation failed"); 101 | } 102 | }); 103 | 104 | test("handles async schema validation", async () => { 105 | const schema = createMockSchema({ 106 | validate: async (input) => { 107 | await new Promise((resolve) => setTimeout(resolve, 1)); 108 | return { value: (input as string).toUpperCase() }; 109 | }, 110 | }); 111 | 112 | const result = await validateInput(schema, "hello"); 113 | 114 | expect(result.success).toBe(true); 115 | if (result.success) { 116 | expect(result.value).toBe("HELLO"); 117 | } 118 | }); 119 | 120 | test("handles undefined input when no schema", async () => { 121 | // eslint-disable-next-line unicorn/no-useless-undefined 122 | const result = await validateInput(null, undefined); 123 | 124 | expect(result.success).toBe(true); 125 | if (result.success) { 126 | expect(result.value).toBeUndefined(); 127 | } 128 | }); 129 | }); 130 | 131 | function createMockSchema(options: { 132 | validate: ( 133 | input: unknown, 134 | ) => StandardSchemaV1.Result | Promise>; 135 | }): StandardSchemaV1 { 136 | return { 137 | "~standard": { 138 | version: 1, 139 | vendor: "test", 140 | validate: options.validate, 141 | }, 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /examples/with-schema-validation/arktype.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { type as arkType } from "arktype"; 3 | import { randomUUID } from "node:crypto"; 4 | import { OpenWorkflow } from "openworkflow"; 5 | 6 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 7 | const backend = await BackendPostgres.connect(databaseUrl, { 8 | namespaceId: randomUUID(), 9 | }); 10 | const ow = new OpenWorkflow({ backend }); 11 | 12 | const schema = arkType({ 13 | docUrl: "string", 14 | num: "string", 15 | }); 16 | 17 | /** 18 | * An example workflow that extracts, cleans, summarizes, and saves a document 19 | * from a URL. It uses an ArkType schema to validate the input. 20 | */ 21 | const summarizeDoc = ow.defineWorkflow( 22 | { name: "summarize-doc-ark", schema }, 23 | async ({ input, step }) => { 24 | const extracted = await step.run({ name: "extract-text" }, () => { 25 | console.log(`[${input.num}] Extracting text from ${input.docUrl}`); 26 | return "extracted-text"; 27 | }); 28 | 29 | const cleaned = await step.run({ name: "clean-text" }, () => { 30 | console.log( 31 | `[${input.num}] Cleaning ${String(extracted.length)} characters`, 32 | ); 33 | return "cleaned-text"; 34 | }); 35 | 36 | const summarized = await step.run({ name: "summarize-text" }, async () => { 37 | console.log(`[${input.num}] Summarizing: ${cleaned.slice(0, 10)}...`); 38 | 39 | // sleep a bit to simulate async work 40 | await randomSleep(); 41 | 42 | // fail 50% of the time to demonstrate retries 43 | // eslint-disable-next-line sonarjs/pseudo-random 44 | if (Math.random() < 0.5) { 45 | console.log(`[${input.num}] ⚠️ Simulated failure during summarization`); 46 | throw new Error("Simulated summarization error"); 47 | } 48 | 49 | return "summary"; 50 | }); 51 | 52 | const summaryId = await step.run({ name: "save-summary" }, async () => { 53 | console.log( 54 | `[${input.num}] Saving summary (${summarized}) to the database`, 55 | ); 56 | 57 | // sleep a bit to simulate async work 58 | await randomSleep(); 59 | 60 | return randomUUID(); 61 | }); 62 | 63 | return { 64 | summaryId, 65 | summarized, 66 | }; 67 | }, 68 | ); 69 | 70 | /** 71 | * Start a worker with 4 concurrency slots. Then create and run four workflows 72 | * concurrently with injected logging. 73 | * 74 | * This `main` function is much more complex and messy than a typical example. 75 | * You can find a more typical example in the README. 76 | */ 77 | async function main() { 78 | const n = 4; 79 | 80 | console.log("Starting worker..."); 81 | const worker = ow.newWorker({ concurrency: n }); 82 | await worker.start(); 83 | 84 | console.log(`Running ${String(n)} workflows...`); 85 | const runCreatePromises = [] as Promise[]; 86 | for (let i = 0; i < n; i++) { 87 | runCreatePromises.push( 88 | summarizeDoc.run({ 89 | docUrl: "https://example.com/mydoc.pdf", 90 | num: String(i + 1), 91 | }), 92 | ); 93 | console.log(`Workflow run ${String(i + 1)} enqueued`); 94 | } 95 | 96 | // wait for all run handles to be created 97 | const runHandles = (await Promise.all(runCreatePromises)) as { 98 | result: () => ReturnType; 99 | }[]; 100 | 101 | // collect result promises, attach logging to each 102 | const resultPromises = runHandles.map((h, idx) => 103 | h 104 | .result() 105 | .then((output) => { 106 | console.log( 107 | `✅ Workflow run ${String(idx + 1)} succeeded: ${JSON.stringify(output)}`, 108 | ); 109 | return { status: "fulfilled" as const, value: output }; 110 | }) 111 | .catch((error: unknown) => { 112 | console.error(`❌ Workflow run ${String(idx + 1)} failed:`, error); 113 | return { status: "rejected" as const, reason: error } as unknown; 114 | }), 115 | ); 116 | 117 | // run all 118 | await Promise.all(resultPromises); 119 | 120 | console.log("Stopping worker..."); 121 | await worker.stop(); 122 | 123 | console.log("Closing backend..."); 124 | await backend.stop(); 125 | 126 | console.log("Done."); 127 | } 128 | 129 | await main().catch((error: unknown) => { 130 | console.error(error); 131 | process.exitCode = 1; 132 | }); 133 | 134 | function randomSleep() { 135 | // eslint-disable-next-line sonarjs/pseudo-random 136 | const sleepDurationMs = Math.floor(Math.random() * 1000) * 5; 137 | return new Promise((resolve) => setTimeout(resolve, sleepDurationMs)); 138 | } 139 | -------------------------------------------------------------------------------- /examples/with-schema-validation/zod.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { randomUUID } from "node:crypto"; 3 | import { OpenWorkflow } from "openworkflow"; 4 | import { z } from "zod"; 5 | 6 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 7 | const backend = await BackendPostgres.connect(databaseUrl, { 8 | namespaceId: randomUUID(), 9 | }); 10 | const ow = new OpenWorkflow({ backend }); 11 | 12 | const summarizeZodSchema = z.object({ 13 | docUrl: z.url(), 14 | num: z.string(), 15 | }); 16 | 17 | /** 18 | * An example workflow that extracts, cleans, summarizes, and saves a document 19 | * from a URL. It uses a Zod schema to validate the input. 20 | */ 21 | const summarizeDoc = ow.defineWorkflow( 22 | { name: "summarize-doc", schema: summarizeZodSchema }, 23 | async ({ input, step }) => { 24 | const extracted = await step.run({ name: "extract-text" }, () => { 25 | console.log(`[${input.num}] Extracting text from ${input.docUrl}`); 26 | return "extracted-text"; 27 | }); 28 | 29 | const cleaned = await step.run({ name: "clean-text" }, () => { 30 | console.log( 31 | `[${input.num}] Cleaning ${String(extracted.length)} characters`, 32 | ); 33 | return "cleaned-text"; 34 | }); 35 | 36 | const summarized = await step.run({ name: "summarize-text" }, async () => { 37 | console.log(`[${input.num}] Summarizing: ${cleaned.slice(0, 10)}...`); 38 | 39 | // sleep a bit to simulate async work 40 | await randomSleep(); 41 | 42 | // fail 50% of the time to demonstrate retries 43 | // eslint-disable-next-line sonarjs/pseudo-random 44 | if (Math.random() < 0.5) { 45 | console.log(`[${input.num}] ⚠️ Simulated failure during summarization`); 46 | throw new Error("Simulated summarization error"); 47 | } 48 | 49 | return "summary"; 50 | }); 51 | 52 | const summaryId = await step.run({ name: "save-summary" }, async () => { 53 | console.log( 54 | `[${input.num}] Saving summary (${summarized}) to the database`, 55 | ); 56 | 57 | // sleep a bit to simulate async work 58 | await randomSleep(); 59 | 60 | return randomUUID(); 61 | }); 62 | 63 | return { 64 | summaryId, 65 | summarized, 66 | }; 67 | }, 68 | ); 69 | 70 | /** 71 | * Start a worker with 4 concurrency slots. Then create and run four workflows 72 | * concurrently with injected logging. 73 | * 74 | * This `main` function is much more complex and messy than a typical example. 75 | * You can find a more typical example in the README. 76 | */ 77 | async function main() { 78 | const n = 4; 79 | 80 | console.log("Starting worker..."); 81 | const worker = ow.newWorker({ concurrency: n }); 82 | await worker.start(); 83 | 84 | console.log(`Running ${String(n)} workflows...`); 85 | const runCreatePromises = [] as Promise[]; 86 | for (let i = 0; i < n; i++) { 87 | runCreatePromises.push( 88 | summarizeDoc.run({ 89 | docUrl: "https://example.com/mydoc.pdf", 90 | num: String(i + 1), 91 | }), 92 | ); 93 | console.log(`Workflow run ${String(i + 1)} enqueued`); 94 | } 95 | 96 | // wait for all run handles to be created 97 | const runHandles = (await Promise.all(runCreatePromises)) as { 98 | result: () => ReturnType; 99 | }[]; 100 | 101 | // collect result promises, attach logging to each 102 | const resultPromises = runHandles.map((h, idx) => 103 | h 104 | .result() 105 | .then((output) => { 106 | console.log( 107 | `✅ Workflow run ${String(idx + 1)} succeeded: ${JSON.stringify(output)}`, 108 | ); 109 | return { status: "fulfilled" as const, value: output }; 110 | }) 111 | .catch((error: unknown) => { 112 | console.error(`❌ Workflow run ${String(idx + 1)} failed:`, error); 113 | return { status: "rejected" as const, reason: error } as unknown; 114 | }), 115 | ); 116 | 117 | // run all 118 | await Promise.all(resultPromises); 119 | 120 | console.log("Stopping worker..."); 121 | await worker.stop(); 122 | 123 | console.log("Closing backend..."); 124 | await backend.stop(); 125 | 126 | console.log("Done."); 127 | } 128 | 129 | await main().catch((error: unknown) => { 130 | console.error(error); 131 | process.exitCode = 1; 132 | }); 133 | 134 | function randomSleep() { 135 | // eslint-disable-next-line sonarjs/pseudo-random 136 | const sleepDurationMs = Math.floor(Math.random() * 1000) * 5; 137 | return new Promise((resolve) => setTimeout(resolve, sleepDurationMs)); 138 | } 139 | -------------------------------------------------------------------------------- /packages/openworkflow/core/step.ts: -------------------------------------------------------------------------------- 1 | import type { DurationString } from "./duration.js"; 2 | import { parseDuration } from "./duration.js"; 3 | import type { JsonValue } from "./json.js"; 4 | import type { Result } from "./result.js"; 5 | import { err, ok } from "./result.js"; 6 | 7 | /** 8 | * The kind of step in a workflow. 9 | */ 10 | export type StepKind = "function" | "sleep"; 11 | 12 | /** 13 | * Status of a step attempt through its lifecycle. 14 | */ 15 | export type StepAttemptStatus = 16 | | "running" 17 | | "succeeded" // deprecated in favor of 'completed' 18 | | "completed" 19 | | "failed"; 20 | 21 | /** 22 | * Context for a step attempt (currently only used for sleep steps). 23 | */ 24 | export interface StepAttemptContext { 25 | kind: "sleep"; 26 | resumeAt: string; 27 | } 28 | 29 | /** 30 | * StepAttempt represents a single attempt of a step within a workflow. 31 | */ 32 | export interface StepAttempt { 33 | namespaceId: string; 34 | id: string; 35 | workflowRunId: string; 36 | stepName: string; 37 | kind: StepKind; 38 | status: StepAttemptStatus; 39 | config: JsonValue; // user-defined config 40 | context: StepAttemptContext | null; // runtime execution metadata 41 | output: JsonValue | null; 42 | error: JsonValue | null; 43 | childWorkflowRunNamespaceId: string | null; 44 | childWorkflowRunId: string | null; 45 | startedAt: Date | null; 46 | finishedAt: Date | null; 47 | createdAt: Date; 48 | updatedAt: Date; 49 | } 50 | 51 | /** 52 | * Immutable cache for step attempts, keyed by step name. 53 | */ 54 | export type StepAttemptCache = ReadonlyMap; 55 | 56 | /** 57 | * Create a step attempt cache from an array of attempts. Only includes 58 | * successful attempts (completed or succeeded status). 59 | * @param attempts - Array of step attempts to cache 60 | * @returns An immutable map of step name to successful attempt 61 | */ 62 | export function createStepAttemptCacheFromAttempts( 63 | attempts: readonly StepAttempt[], 64 | ): StepAttemptCache { 65 | // 'succeeded' status is deprecated in favor of 'completed' 66 | const successfulAttempts = attempts.filter( 67 | (attempt) => 68 | attempt.status === "succeeded" || attempt.status === "completed", 69 | ); 70 | 71 | return new Map( 72 | successfulAttempts.map((attempt) => [attempt.stepName, attempt]), 73 | ); 74 | } 75 | 76 | /** 77 | * Get a cached step attempt by name. 78 | * @param cache - The step attempt cache 79 | * @param stepName - The name of the step to look up 80 | * @returns The cached attempt or undefined if not found 81 | */ 82 | export function getCachedStepAttempt( 83 | cache: StepAttemptCache, 84 | stepName: string, 85 | ): StepAttempt | undefined { 86 | return cache.get(stepName); 87 | } 88 | 89 | /** 90 | * Add a step attempt to the cache (returns new cache, original unchanged). This 91 | * is an immutable operation. 92 | * @param cache - The existing step attempt cache 93 | * @param attempt - The attempt to add 94 | * @returns A new cache with the attempt added 95 | */ 96 | export function addToStepAttemptCache( 97 | cache: StepAttemptCache, 98 | attempt: Readonly, 99 | ): StepAttemptCache { 100 | return new Map([...cache, [attempt.stepName, attempt]]); 101 | } 102 | 103 | /** 104 | * Convert a step function result to a JSON-compatible value. Undefined values 105 | * are converted to null for JSON serialization. 106 | * @param result - The result from a step function 107 | * @returns A JSON-serializable value 108 | */ 109 | export function normalizeStepOutput(result: unknown): JsonValue { 110 | return (result ?? null) as JsonValue; 111 | } 112 | 113 | /** 114 | * Calculate the resume time for a sleep step. 115 | * @param duration - The duration string to sleep for 116 | * @param now - The current timestamp (defaults to Date.now()) 117 | * @returns A Result containing the resume Date or an Error 118 | */ 119 | export function calculateSleepResumeAt( 120 | duration: DurationString, 121 | now: number = Date.now(), 122 | ): Result { 123 | const result = parseDuration(duration); 124 | 125 | if (!result.ok) { 126 | return err(result.error); 127 | } 128 | 129 | return ok(new Date(now + result.value)); 130 | } 131 | 132 | /** 133 | * Create the context object for a sleep step attempt. 134 | * @param resumeAt - The time when the sleep should resume 135 | * @returns The context object for the sleep step 136 | */ 137 | export function createSleepContext(resumeAt: Readonly): { 138 | kind: "sleep"; 139 | resumeAt: string; 140 | } { 141 | return { 142 | kind: "sleep" as const, 143 | resumeAt: resumeAt.toISOString(), 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /examples/with-schema-validation/valibot.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { randomUUID } from "node:crypto"; 3 | import { OpenWorkflow } from "openworkflow"; 4 | import * as v from "valibot"; 5 | 6 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 7 | const backend = await BackendPostgres.connect(databaseUrl, { 8 | namespaceId: randomUUID(), 9 | }); 10 | const ow = new OpenWorkflow({ backend }); 11 | 12 | const schema = v.object({ 13 | docUrl: v.string(), 14 | num: v.string(), 15 | }); 16 | 17 | /** 18 | * An example workflow that extracts, cleans, summarizes, and saves a document 19 | * from a URL. It uses a Valibot schema to validate the input. 20 | */ 21 | const summarizeDoc = ow.defineWorkflow( 22 | { 23 | name: "summarize-doc-valibot", 24 | schema, 25 | }, 26 | async ({ input, step }) => { 27 | const extracted = await step.run({ name: "extract-text" }, () => { 28 | console.log(`[${input.num}] Extracting text from ${input.docUrl}`); 29 | return "extracted-text"; 30 | }); 31 | 32 | const cleaned = await step.run({ name: "clean-text" }, () => { 33 | console.log( 34 | `[${input.num}] Cleaning ${String(extracted.length)} characters`, 35 | ); 36 | return "cleaned-text"; 37 | }); 38 | 39 | const summarized = await step.run({ name: "summarize-text" }, async () => { 40 | console.log(`[${input.num}] Summarizing: ${cleaned.slice(0, 10)}...`); 41 | 42 | // sleep a bit to simulate async work 43 | await randomSleep(); 44 | 45 | // fail 50% of the time to demonstrate retries 46 | // eslint-disable-next-line sonarjs/pseudo-random 47 | if (Math.random() < 0.5) { 48 | console.log(`[${input.num}] ⚠️ Simulated failure during summarization`); 49 | throw new Error("Simulated summarization error"); 50 | } 51 | 52 | return "summary"; 53 | }); 54 | 55 | const summaryId = await step.run({ name: "save-summary" }, async () => { 56 | console.log( 57 | `[${input.num}] Saving summary (${summarized}) to the database`, 58 | ); 59 | 60 | // sleep a bit to simulate async work 61 | await randomSleep(); 62 | 63 | return randomUUID(); 64 | }); 65 | 66 | return { 67 | summaryId, 68 | summarized, 69 | }; 70 | }, 71 | ); 72 | 73 | /** 74 | * Start a worker with 4 concurrency slots. Then create and run four workflows 75 | * concurrently with injected logging. 76 | * 77 | * This `main` function is much more complex and messy than a typical example. 78 | * You can find a more typical example in the README. 79 | */ 80 | async function main() { 81 | const n = 4; 82 | 83 | console.log("Starting worker..."); 84 | const worker = ow.newWorker({ concurrency: n }); 85 | await worker.start(); 86 | 87 | console.log(`Running ${String(n)} workflows...`); 88 | const runCreatePromises = [] as Promise[]; 89 | for (let i = 0; i < n; i++) { 90 | runCreatePromises.push( 91 | summarizeDoc.run({ 92 | docUrl: "https://example.com/mydoc.pdf", 93 | num: String(i + 1), 94 | }), 95 | ); 96 | console.log(`Workflow run ${String(i + 1)} enqueued`); 97 | } 98 | 99 | // wait for all run handles to be created 100 | const runHandles = (await Promise.all(runCreatePromises)) as { 101 | result: () => ReturnType; 102 | }[]; 103 | 104 | // collect result promises, attach logging to each 105 | const resultPromises = runHandles.map((h, idx) => 106 | h 107 | .result() 108 | .then((output) => { 109 | console.log( 110 | `✅ Workflow run ${String(idx + 1)} succeeded: ${JSON.stringify(output)}`, 111 | ); 112 | return { status: "fulfilled" as const, value: output }; 113 | }) 114 | .catch((error: unknown) => { 115 | console.error(`❌ Workflow run ${String(idx + 1)} failed:`, error); 116 | return { status: "rejected" as const, reason: error } as unknown; 117 | }), 118 | ); 119 | 120 | // run all 121 | await Promise.all(resultPromises); 122 | 123 | console.log("Stopping worker..."); 124 | await worker.stop(); 125 | 126 | console.log("Closing backend..."); 127 | await backend.stop(); 128 | 129 | console.log("Done."); 130 | } 131 | 132 | await main().catch((error: unknown) => { 133 | console.error(error); 134 | process.exitCode = 1; 135 | }); 136 | 137 | function randomSleep() { 138 | // eslint-disable-next-line sonarjs/pseudo-random 139 | const sleepDurationMs = Math.floor(Math.random() * 1000) * 5; 140 | return new Promise((resolve) => setTimeout(resolve, sleepDurationMs)); 141 | } 142 | -------------------------------------------------------------------------------- /examples/with-schema-validation/yup.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { randomUUID } from "node:crypto"; 3 | import { OpenWorkflow } from "openworkflow"; 4 | import { object as yupObject, string as yupString } from "yup"; 5 | 6 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 7 | const backend = await BackendPostgres.connect(databaseUrl, { 8 | namespaceId: randomUUID(), 9 | }); 10 | const ow = new OpenWorkflow({ backend }); 11 | 12 | const schema = yupObject({ 13 | docUrl: yupString().url().required(), 14 | num: yupString().required(), 15 | }); 16 | 17 | /** 18 | * An example workflow that extracts, cleans, summarizes, and saves a document 19 | * from a URL. It uses a Yup schema to validate the input. 20 | */ 21 | const summarizeDoc = ow.defineWorkflow( 22 | { name: "summarize-doc-yup", schema }, 23 | async ({ input, step }) => { 24 | const extracted = await step.run({ name: "extract-text" }, () => { 25 | console.log(`[${input.num}] Extracting text from ${input.docUrl}`); 26 | return "extracted-text"; 27 | }); 28 | 29 | const cleaned = await step.run({ name: "clean-text" }, () => { 30 | console.log( 31 | `[${input.num}] Cleaning ${String(extracted.length)} characters`, 32 | ); 33 | return "cleaned-text"; 34 | }); 35 | 36 | const summarized = await step.run({ name: "summarize-text" }, async () => { 37 | console.log(`[${input.num}] Summarizing: ${cleaned.slice(0, 10)}...`); 38 | 39 | // sleep a bit to simulate async work 40 | await randomSleep(); 41 | 42 | // fail 50% of the time to demonstrate retries 43 | // eslint-disable-next-line sonarjs/pseudo-random 44 | if (Math.random() < 0.5) { 45 | console.log(`[${input.num}] ⚠️ Simulated failure during summarization`); 46 | throw new Error("Simulated summarization error"); 47 | } 48 | 49 | return "summary"; 50 | }); 51 | 52 | const summaryId = await step.run({ name: "save-summary" }, async () => { 53 | console.log( 54 | `[${input.num}] Saving summary (${summarized}) to the database`, 55 | ); 56 | 57 | // sleep a bit to simulate async work 58 | await randomSleep(); 59 | 60 | return randomUUID(); 61 | }); 62 | 63 | return { 64 | summaryId, 65 | summarized, 66 | }; 67 | }, 68 | ); 69 | 70 | /** 71 | * Start a worker with 4 concurrency slots. Then create and run four workflows 72 | * concurrently with injected logging. 73 | * 74 | * This `main` function is much more complex and messy than a typical example. 75 | * You can find a more typical example in the README. 76 | */ 77 | async function main() { 78 | const n = 4; 79 | 80 | console.log("Starting worker..."); 81 | const worker = ow.newWorker({ concurrency: n }); 82 | await worker.start(); 83 | 84 | console.log(`Running ${String(n)} workflows...`); 85 | const runCreatePromises = [] as Promise[]; 86 | for (let i = 0; i < n; i++) { 87 | runCreatePromises.push( 88 | summarizeDoc.run({ 89 | docUrl: "https://example.com/mydoc.pdf", 90 | num: String(i + 1), 91 | }), 92 | ); 93 | console.log(`Workflow run ${String(i + 1)} enqueued`); 94 | } 95 | 96 | // wait for all run handles to be created 97 | const runHandles = (await Promise.all(runCreatePromises)) as { 98 | result: () => ReturnType; 99 | }[]; 100 | 101 | // collect result promises, attach logging to each 102 | const resultPromises = runHandles.map((h, idx) => 103 | h 104 | .result() 105 | .then((output) => { 106 | console.log( 107 | `✅ Workflow run ${String(idx + 1)} succeeded: ${JSON.stringify(output)}`, 108 | ); 109 | return { status: "fulfilled" as const, value: output }; 110 | }) 111 | .catch((error: unknown) => { 112 | console.error(`❌ Workflow run ${String(idx + 1)} failed:`, error); 113 | return { status: "rejected" as const, reason: error } as unknown; 114 | }), 115 | ); 116 | 117 | // run all 118 | await Promise.all(resultPromises); 119 | 120 | console.log("Stopping worker..."); 121 | await worker.stop(); 122 | 123 | console.log("Closing backend..."); 124 | await backend.stop(); 125 | 126 | console.log("Done."); 127 | } 128 | 129 | await main().catch((error: unknown) => { 130 | console.error(error); 131 | process.exitCode = 1; 132 | }); 133 | 134 | function randomSleep() { 135 | // eslint-disable-next-line sonarjs/pseudo-random 136 | const sleepDurationMs = Math.floor(Math.random() * 1000) * 5; 137 | return new Promise((resolve) => setTimeout(resolve, sleepDurationMs)); 138 | } 139 | -------------------------------------------------------------------------------- /examples/basic/index.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { randomUUID } from "node:crypto"; 3 | import { OpenWorkflow } from "openworkflow"; 4 | 5 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 6 | const backend = await BackendPostgres.connect(databaseUrl, { 7 | namespaceId: randomUUID(), 8 | }); 9 | const ow = new OpenWorkflow({ backend }); 10 | 11 | interface SummarizeDocInput { 12 | docUrl: string; 13 | num: string; // just for logging 14 | } 15 | 16 | interface SummarizeDocOutput { 17 | summaryId: string; 18 | summarized: string; 19 | } 20 | 21 | /** 22 | * An example workflow that extracts, cleans, summarizes, and saves a document 23 | * from a URL. It explicitly specifies types for better type 24 | * safety, but that's optional. 25 | */ 26 | const summarizeDoc = ow.defineWorkflow( 27 | { name: "summarize-doc" }, 28 | async ({ input, step }) => { 29 | const extracted = await step.run({ name: "extract-text" }, () => { 30 | console.log(`[${input.num}] Extracting text from ${input.docUrl}`); 31 | return "extracted-text"; 32 | }); 33 | 34 | const cleaned = await step.run({ name: "clean-text" }, () => { 35 | console.log( 36 | `[${input.num}] Cleaning ${String(extracted.length)} characters`, 37 | ); 38 | return "cleaned-text"; 39 | }); 40 | 41 | const summarized = await step.run({ name: "summarize-text" }, async () => { 42 | console.log(`[${input.num}] Summarizing: ${cleaned.slice(0, 10)}...`); 43 | 44 | // sleep a bit to simulate async work 45 | await randomSleep(); 46 | 47 | // fail 50% of the time to demonstrate retries 48 | // eslint-disable-next-line sonarjs/pseudo-random 49 | if (Math.random() < 0.5) { 50 | console.log(`[${input.num}] ⚠️ Simulated failure during summarization`); 51 | throw new Error("Simulated summarization error"); 52 | } 53 | 54 | return "summary"; 55 | }); 56 | 57 | const summaryId = await step.run({ name: "save-summary" }, async () => { 58 | console.log( 59 | `[${input.num}] Saving summary (${summarized}) to the database`, 60 | ); 61 | 62 | // sleep a bit to simulate async work 63 | await randomSleep(); 64 | 65 | return randomUUID(); 66 | }); 67 | 68 | return { 69 | summaryId, 70 | summarized, 71 | }; 72 | }, 73 | ); 74 | 75 | /** 76 | * Start a worker with 4 concurrency slots. Then create and run four workflows 77 | * concurrently with injected logging. 78 | * 79 | * This `main` function is much more complex and messy than a typical example. 80 | * You can find a more typical example in the README. 81 | */ 82 | async function main() { 83 | const n = 4; 84 | 85 | console.log("Starting worker..."); 86 | const worker = ow.newWorker({ concurrency: n }); 87 | await worker.start(); 88 | 89 | console.log(`Running ${String(n)} workflows...`); 90 | const runCreatePromises = [] as Promise[]; 91 | for (let i = 0; i < n; i++) { 92 | runCreatePromises.push( 93 | summarizeDoc.run({ 94 | docUrl: "https://example.com/mydoc.pdf", 95 | num: String(i + 1), 96 | }), 97 | ); 98 | console.log(`Workflow run ${String(i + 1)} enqueued"`); 99 | } 100 | 101 | // wait for all run handles to be created 102 | const runHandles = (await Promise.all(runCreatePromises)) as { 103 | result: () => Promise; 104 | }[]; 105 | 106 | // collect result promises, attach logging to each 107 | const resultPromises = runHandles.map((h, idx) => 108 | h 109 | .result() 110 | .then((output) => { 111 | console.log( 112 | `✅ Workflow run ${String(idx + 1)} completed: ${JSON.stringify(output)}`, 113 | ); 114 | return { status: "fulfilled" as const, value: output }; 115 | }) 116 | .catch((error: unknown) => { 117 | console.error(`❌ Workflow run ${String(idx + 1)} failed:`, error); 118 | return { status: "rejected" as const, reason: error } as unknown; 119 | }), 120 | ); 121 | 122 | // run all 123 | await Promise.all(resultPromises); 124 | 125 | console.log("Stopping worker..."); 126 | await worker.stop(); 127 | 128 | console.log("Closing backend..."); 129 | await backend.stop(); 130 | 131 | console.log("Done."); 132 | } 133 | 134 | await main().catch((error: unknown) => { 135 | console.error(error); 136 | process.exitCode = 1; 137 | }); 138 | 139 | function randomSleep() { 140 | // eslint-disable-next-line sonarjs/pseudo-random 141 | const sleepDurationMs = Math.floor(Math.random() * 1000) * 5; 142 | return new Promise((resolve) => setTimeout(resolve, sleepDurationMs)); 143 | } 144 | -------------------------------------------------------------------------------- /examples/declare-workflow/index.ts: -------------------------------------------------------------------------------- 1 | import { BackendPostgres } from "@openworkflow/backend-postgres"; 2 | import { randomUUID } from "node:crypto"; 3 | import { OpenWorkflow, defineWorkflowSpec } from "openworkflow"; 4 | 5 | const databaseUrl = "postgresql://postgres:postgres@localhost:5432/postgres"; 6 | const backend = await BackendPostgres.connect(databaseUrl, { 7 | namespaceId: randomUUID(), 8 | }); 9 | const ow = new OpenWorkflow({ backend }); 10 | 11 | interface SummarizeDocInput { 12 | docUrl: string; 13 | num: string; // just for logging 14 | } 15 | 16 | interface SummarizeDocOutput { 17 | summaryId: string; 18 | summarized: string; 19 | } 20 | 21 | /** 22 | * Define the workflow spec separately from its implementation. 23 | * This spec can be shared with other services that only need to schedule runs. 24 | */ 25 | const summarizeDocSpec = defineWorkflowSpec< 26 | SummarizeDocInput, 27 | SummarizeDocOutput 28 | >({ name: "summarize-doc" }); 29 | 30 | /** 31 | * Implement the workflow. This registers the workflow handler with the 32 | * OpenWorkflow instance so workers can execute it. 33 | */ 34 | ow.implementWorkflow(summarizeDocSpec, async ({ input, step }) => { 35 | const extracted = await step.run({ name: "extract-text" }, () => { 36 | console.log(`[${input.num}] Extracting text from ${input.docUrl}`); 37 | return "extracted-text"; 38 | }); 39 | 40 | const cleaned = await step.run({ name: "clean-text" }, () => { 41 | console.log( 42 | `[${input.num}] Cleaning ${String(extracted.length)} characters`, 43 | ); 44 | return "cleaned-text"; 45 | }); 46 | 47 | const summarized = await step.run({ name: "summarize-text" }, async () => { 48 | console.log(`[${input.num}] Summarizing: ${cleaned.slice(0, 10)}...`); 49 | 50 | // sleep a bit to simulate async work 51 | await randomSleep(); 52 | 53 | // fail 50% of the time to demonstrate retries 54 | // eslint-disable-next-line sonarjs/pseudo-random 55 | if (Math.random() < 0.5) { 56 | console.log(`[${input.num}] ⚠️ Simulated failure during summarization`); 57 | throw new Error("Simulated summarization error"); 58 | } 59 | 60 | return "summary"; 61 | }); 62 | 63 | const summaryId = await step.run({ name: "save-summary" }, async () => { 64 | console.log( 65 | `[${input.num}] Saving summary (${summarized}) to the database`, 66 | ); 67 | 68 | // sleep a bit to simulate async work 69 | await randomSleep(); 70 | 71 | return randomUUID(); 72 | }); 73 | 74 | return { 75 | summaryId, 76 | summarized, 77 | }; 78 | }); 79 | 80 | /** 81 | * Start a worker with 4 concurrency slots. Then create and run four workflows 82 | * concurrently with injected logging. 83 | * 84 | * This `main` function is much more complex and messy than a typical example. 85 | * You can find a more typical example in the README. 86 | */ 87 | async function main() { 88 | const n = 4; 89 | 90 | console.log("Starting worker..."); 91 | const worker = ow.newWorker({ concurrency: n }); 92 | await worker.start(); 93 | 94 | console.log(`Running ${String(n)} workflows...`); 95 | const runCreatePromises = [] as Promise[]; 96 | for (let i = 0; i < n; i++) { 97 | runCreatePromises.push( 98 | ow.runWorkflow(summarizeDocSpec, { 99 | docUrl: "https://example.com/mydoc.pdf", 100 | num: String(i + 1), 101 | }), 102 | ); 103 | console.log(`Workflow run ${String(i + 1)} enqueued"`); 104 | } 105 | 106 | // wait for all run handles to be created 107 | const runHandles = (await Promise.all(runCreatePromises)) as { 108 | result: () => Promise; 109 | }[]; 110 | 111 | // collect result promises, attach logging to each 112 | const resultPromises = runHandles.map((h, idx) => 113 | h 114 | .result() 115 | .then((output) => { 116 | console.log( 117 | `✅ Workflow run ${String(idx + 1)} completed: ${JSON.stringify(output)}`, 118 | ); 119 | return { status: "fulfilled" as const, value: output }; 120 | }) 121 | .catch((error: unknown) => { 122 | console.error(`❌ Workflow run ${String(idx + 1)} failed:`, error); 123 | return { status: "rejected" as const, reason: error } as unknown; 124 | }), 125 | ); 126 | 127 | // run all 128 | await Promise.all(resultPromises); 129 | 130 | console.log("Stopping worker..."); 131 | await worker.stop(); 132 | 133 | console.log("Closing backend..."); 134 | await backend.stop(); 135 | 136 | console.log("Done."); 137 | } 138 | 139 | await main().catch((error: unknown) => { 140 | console.error(error); 141 | process.exitCode = 1; 142 | }); 143 | 144 | function randomSleep() { 145 | // eslint-disable-next-line sonarjs/pseudo-random 146 | const sleepDurationMs = Math.floor(Math.random() * 1000) * 5; 147 | return new Promise((resolve) => setTimeout(resolve, sleepDurationMs)); 148 | } 149 | -------------------------------------------------------------------------------- /packages/openworkflow/registry.test.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowRegistry } from "./registry.js"; 2 | import { defineWorkflow } from "./workflow.js"; 3 | import { describe, expect, test } from "vitest"; 4 | 5 | describe("WorkflowRegistry", () => { 6 | describe("register", () => { 7 | test("registers a workflow without version", () => { 8 | const registry = new WorkflowRegistry(); 9 | const workflow = createMockWorkflow("my-workflow"); 10 | 11 | registry.register(workflow); 12 | 13 | expect(registry.get("my-workflow", null)).toBe(workflow); 14 | }); 15 | 16 | test("registers a workflow with version", () => { 17 | const registry = new WorkflowRegistry(); 18 | const workflow = createMockWorkflow("my-workflow", "v1"); 19 | 20 | registry.register(workflow); 21 | 22 | expect(registry.get("my-workflow", "v1")).toBe(workflow); 23 | }); 24 | 25 | test("registers multiple versions of the same workflow", () => { 26 | const registry = new WorkflowRegistry(); 27 | const v1 = createMockWorkflow("my-workflow", "v1"); 28 | const v2 = createMockWorkflow("my-workflow", "v2"); 29 | 30 | registry.register(v1); 31 | registry.register(v2); 32 | 33 | expect(registry.get("my-workflow", "v1")).toBe(v1); 34 | expect(registry.get("my-workflow", "v2")).toBe(v2); 35 | }); 36 | 37 | test("registers different workflows with same version", () => { 38 | const registry = new WorkflowRegistry(); 39 | const workflow1 = createMockWorkflow("workflow-a", "v1"); 40 | const workflow2 = createMockWorkflow("workflow-b", "v1"); 41 | 42 | registry.register(workflow1); 43 | registry.register(workflow2); 44 | 45 | expect(registry.get("workflow-a", "v1")).toBe(workflow1); 46 | expect(registry.get("workflow-b", "v1")).toBe(workflow2); 47 | }); 48 | 49 | test("throws when registering duplicate unversioned workflow", () => { 50 | const registry = new WorkflowRegistry(); 51 | registry.register(createMockWorkflow("my-workflow")); 52 | 53 | expect(() => { 54 | registry.register(createMockWorkflow("my-workflow")); 55 | }).toThrow('Workflow "my-workflow" is already registered'); 56 | }); 57 | 58 | test("throws when registering duplicate versioned workflow", () => { 59 | const registry = new WorkflowRegistry(); 60 | registry.register(createMockWorkflow("my-workflow", "v1")); 61 | 62 | expect(() => { 63 | registry.register(createMockWorkflow("my-workflow", "v1")); 64 | }).toThrow('Workflow "my-workflow" (version: v1) is already registered'); 65 | }); 66 | 67 | test("allows same name with different versions", () => { 68 | const registry = new WorkflowRegistry(); 69 | const versioned = createMockWorkflow("my-workflow", "v1"); 70 | const unversioned = createMockWorkflow("my-workflow"); 71 | 72 | registry.register(versioned); 73 | registry.register(unversioned); 74 | 75 | expect(registry.get("my-workflow", "v1")).toBe(versioned); 76 | expect(registry.get("my-workflow", null)).toBe(unversioned); 77 | }); 78 | }); 79 | 80 | describe("get", () => { 81 | test("returns undefined for non-existent workflow", () => { 82 | const registry = new WorkflowRegistry(); 83 | 84 | expect(registry.get("non-existent", null)).toBeUndefined(); 85 | }); 86 | 87 | test("returns undefined for wrong version", () => { 88 | const registry = new WorkflowRegistry(); 89 | registry.register(createMockWorkflow("my-workflow", "v1")); 90 | 91 | expect(registry.get("my-workflow", "v2")).toBeUndefined(); 92 | expect(registry.get("my-workflow", null)).toBeUndefined(); 93 | }); 94 | 95 | test("returns undefined for versioned lookup on unversioned workflow", () => { 96 | const registry = new WorkflowRegistry(); 97 | registry.register(createMockWorkflow("my-workflow")); 98 | 99 | expect(registry.get("my-workflow", "v1")).toBeUndefined(); 100 | }); 101 | 102 | test("returns the registered workflow", () => { 103 | const registry = new WorkflowRegistry(); 104 | const workflow = createMockWorkflow("my-workflow"); 105 | registry.register(workflow); 106 | 107 | expect(registry.get("my-workflow", null)).toBe(workflow); 108 | }); 109 | }); 110 | 111 | describe("getAll", () => { 112 | test("returns all registered workflows", () => { 113 | const registry = new WorkflowRegistry(); 114 | const a = createMockWorkflow("workflow-a"); 115 | const b = createMockWorkflow("workflow-b", "v1"); 116 | const c = createMockWorkflow("workflow-a", "v2"); 117 | 118 | registry.register(a); 119 | registry.register(b); 120 | registry.register(c); 121 | 122 | const all = registry.getAll(); 123 | expect(all).toHaveLength(3); 124 | expect(all).toEqual(expect.arrayContaining([a, b, c])); 125 | }); 126 | 127 | test("returns empty array when none registered", () => { 128 | const registry = new WorkflowRegistry(); 129 | expect(registry.getAll()).toEqual([]); 130 | }); 131 | }); 132 | }); 133 | 134 | function createMockWorkflow(name: string, version?: string) { 135 | return defineWorkflow( 136 | { 137 | name, 138 | ...(version && { version }), 139 | }, 140 | async () => { 141 | // no-op 142 | }, 143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /packages/backend-sqlite/sqlite.test.ts: -------------------------------------------------------------------------------- 1 | import { Database, newDatabase, migrations, migrate } from "./sqlite.js"; 2 | import { randomUUID } from "node:crypto"; 3 | import { existsSync, unlinkSync } from "node:fs"; 4 | import { tmpdir } from "node:os"; 5 | import path from "node:path"; 6 | import { afterEach, beforeEach, describe, expect, test } from "vitest"; 7 | 8 | // Helper to get the current migration version (exported for testing) 9 | // Note: This function exists in sqlite.ts but isn't exported, so we'll 10 | // test it indirectly through migrate() and by checking the migrations table 11 | function getMigrationVersion(db: Database): number { 12 | const existsStmt = db.prepare(` 13 | SELECT COUNT(*) as count 14 | FROM sqlite_master 15 | WHERE type = 'table' AND name = 'openworkflow_migrations' 16 | `); 17 | const existsResult = existsStmt.get() as { count: number } | undefined; 18 | if (!existsResult || existsResult.count === 0) return -1; 19 | 20 | const versionStmt = db.prepare( 21 | `SELECT MAX("version") AS "version" FROM "openworkflow_migrations";`, 22 | ); 23 | const versionResult = versionStmt.get() as { version: number } | undefined; 24 | return versionResult?.version ?? -1; 25 | } 26 | 27 | describe("sqlite", () => { 28 | let db: Database; 29 | let dbPath: string; 30 | 31 | beforeEach(() => { 32 | // Use a unique file path for each test to ensure isolation 33 | dbPath = path.join(tmpdir(), `test_${randomUUID()}.db`); 34 | db = newDatabase(dbPath); 35 | }); 36 | 37 | afterEach(() => { 38 | db.close(); 39 | const walPath = `${dbPath}-wal`; 40 | const shmPath = `${dbPath}-shm`; 41 | // clean up the test database, WAL, and SHM files if they exist 42 | if (existsSync(dbPath)) { 43 | unlinkSync(dbPath); 44 | } 45 | if (existsSync(walPath)) { 46 | unlinkSync(walPath); 47 | } 48 | if (existsSync(shmPath)) { 49 | unlinkSync(shmPath); 50 | } 51 | }); 52 | 53 | describe("migrations()", () => { 54 | test("returns migration SQL statements with correct table names", () => { 55 | const migs = migrations(); 56 | expect(migs.length).toBeGreaterThan(0); 57 | 58 | // Check that migrations reference the openworkflow_migrations table 59 | for (const mig of migs) { 60 | expect(mig).toContain("openworkflow_migrations"); 61 | } 62 | 63 | // Verify first migration creates the migrations table 64 | expect(migs[0]).toContain( 65 | 'CREATE TABLE IF NOT EXISTS "openworkflow_migrations"', 66 | ); 67 | expect(migs[0]).toContain('"version"'); 68 | }); 69 | 70 | test("migrations create workflow_runs and step_attempts tables", () => { 71 | const migs = migrations(); 72 | 73 | // Migration 1 should create workflow_runs and step_attempts 74 | const migration1 = migs[1]; 75 | expect(migration1).toContain( 76 | 'CREATE TABLE IF NOT EXISTS "workflow_runs"', 77 | ); 78 | expect(migration1).toContain( 79 | 'CREATE TABLE IF NOT EXISTS "step_attempts"', 80 | ); 81 | }); 82 | }); 83 | 84 | describe("migrate()", () => { 85 | test("runs database migrations idempotently", () => { 86 | // First migration 87 | migrate(db); 88 | const version1 = getMigrationVersion(db); 89 | expect(version1).toBeGreaterThanOrEqual(0); 90 | 91 | // Second migration - should not cause errors 92 | migrate(db); 93 | const version2 = getMigrationVersion(db); 94 | expect(version2).toBe(version1); // Version should not change 95 | 96 | // Third migration - should still work 97 | migrate(db); 98 | const version3 = getMigrationVersion(db); 99 | expect(version3).toBe(version1); 100 | }); 101 | 102 | test("tracks migration versions correctly", () => { 103 | // Before migration, version should be -1 (table doesn't exist) 104 | let version = getMigrationVersion(db); 105 | expect(version).toBe(-1); 106 | 107 | // After migration, version should be the latest migration version 108 | migrate(db); 109 | version = getMigrationVersion(db); 110 | 111 | const allMigrations = migrations(); 112 | const expectedLatestVersion = allMigrations.length - 1; 113 | expect(version).toBe(expectedLatestVersion); 114 | }); 115 | 116 | test("applies migrations incrementally", () => { 117 | // Create the migrations table manually with version 0 118 | db.exec(` 119 | CREATE TABLE IF NOT EXISTS "openworkflow_migrations" ( 120 | "version" INTEGER NOT NULL PRIMARY KEY 121 | ); 122 | INSERT OR IGNORE INTO "openworkflow_migrations" ("version") 123 | VALUES (0); 124 | `); 125 | 126 | let version = getMigrationVersion(db); 127 | expect(version).toBe(0); 128 | 129 | // Run migrate - should apply remaining migrations 130 | migrate(db); 131 | version = getMigrationVersion(db); 132 | 133 | const allMigrations = migrations(); 134 | const expectedLatestVersion = allMigrations.length - 1; 135 | expect(version).toBe(expectedLatestVersion); 136 | }); 137 | 138 | test("creates all required tables after migration", () => { 139 | migrate(db); 140 | 141 | // Check that migrations table exists 142 | const migrationsCheck = db 143 | .prepare( 144 | ` 145 | SELECT COUNT(*) as count 146 | FROM sqlite_master 147 | WHERE type = 'table' AND name = 'openworkflow_migrations' 148 | `, 149 | ) 150 | .get() as { count: number }; 151 | expect(migrationsCheck.count).toBe(1); 152 | 153 | // Check that workflow_runs table exists 154 | const workflowRunsCheck = db 155 | .prepare( 156 | ` 157 | SELECT COUNT(*) as count 158 | FROM sqlite_master 159 | WHERE type = 'table' AND name = 'workflow_runs' 160 | `, 161 | ) 162 | .get() as { count: number }; 163 | expect(workflowRunsCheck.count).toBe(1); 164 | 165 | // Check that step_attempts table exists 166 | const stepAttemptsCheck = db 167 | .prepare( 168 | ` 169 | SELECT COUNT(*) as count 170 | FROM sqlite_master 171 | WHERE type = 'table' AND name = 'step_attempts' 172 | `, 173 | ) 174 | .get() as { count: number }; 175 | expect(stepAttemptsCheck.count).toBe(1); 176 | }); 177 | }); 178 | 179 | describe("migration version tracking", () => { 180 | test("migrations table stores version numbers correctly", () => { 181 | migrate(db); 182 | 183 | const versionStmt = db.prepare( 184 | `SELECT "version" FROM "openworkflow_migrations" ORDER BY "version";`, 185 | ); 186 | const versions = versionStmt.all() as { version: number }[]; 187 | 188 | // Should have all migration versions from 0 to latest 189 | const allMigrations = migrations(); 190 | const expectedLatestVersion = allMigrations.length - 1; 191 | 192 | expect(versions.length).toBe(expectedLatestVersion + 1); 193 | for (let i = 0; i <= expectedLatestVersion; i++) { 194 | const version = versions[i]; 195 | expect(version).toBeDefined(); 196 | expect(version?.version).toBe(i); 197 | } 198 | }); 199 | 200 | test("migrations can be run multiple times safely with INSERT OR IGNORE", () => { 201 | migrate(db); 202 | const versionAfterFirst = getMigrationVersion(db); 203 | 204 | // Run migrations again 205 | migrate(db); 206 | const versionAfterSecond = getMigrationVersion(db); 207 | 208 | expect(versionAfterSecond).toBe(versionAfterFirst); 209 | 210 | // Check that version entries aren't duplicated 211 | const versionStmt = db.prepare( 212 | `SELECT COUNT(*) as count FROM "openworkflow_migrations";`, 213 | ); 214 | const countResult = versionStmt.get() as { count: number }; 215 | const allMigrations = migrations(); 216 | const expectedCount = allMigrations.length; 217 | expect(countResult.count).toBe(expectedCount); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /packages/openworkflow/worker.ts: -------------------------------------------------------------------------------- 1 | import type { Backend } from "./backend.js"; 2 | import type { WorkflowRun } from "./core/workflow.js"; 3 | import { executeWorkflow } from "./execution.js"; 4 | import { WorkflowRegistry } from "./registry.js"; 5 | import type { Workflow } from "./workflow.js"; 6 | import { randomUUID } from "node:crypto"; 7 | 8 | const DEFAULT_LEASE_DURATION_MS = 30 * 1000; // 30s 9 | const DEFAULT_POLL_INTERVAL_MS = 100; // 100ms 10 | const DEFAULT_CONCURRENCY = 1; 11 | 12 | /** 13 | * Configures how a Worker polls the backend, leases workflow runs, and 14 | * registers workflows. 15 | */ 16 | export interface WorkerOptions { 17 | backend: Backend; 18 | workflows: Workflow[]; 19 | concurrency?: number | undefined; 20 | } 21 | 22 | /** 23 | * Runs workflows by polling the backend, dispatching runs across a concurrency 24 | * pool, and heartbeating/extending leases. 25 | */ 26 | export class Worker { 27 | private readonly backend: Backend; 28 | private readonly workerIds: string[]; 29 | private readonly registry = new WorkflowRegistry(); 30 | private readonly activeExecutions = new Set(); 31 | private running = false; 32 | private loopPromise: Promise | null = null; 33 | 34 | constructor(options: WorkerOptions) { 35 | this.backend = options.backend; 36 | 37 | for (const workflow of options.workflows) { 38 | this.registry.register(workflow); 39 | } 40 | 41 | const concurrency = Math.max( 42 | DEFAULT_CONCURRENCY, 43 | options.concurrency ?? DEFAULT_CONCURRENCY, 44 | ); 45 | 46 | // generate worker IDs for every concurrency slot 47 | this.workerIds = Array.from({ length: concurrency }, () => randomUUID()); 48 | } 49 | 50 | /** 51 | * Start the worker. It will begin polling for and executing workflows. 52 | * @returns Promise resolved when started 53 | */ 54 | async start(): Promise { 55 | if (this.running) return; 56 | this.running = true; 57 | this.loopPromise = this.runLoop(); 58 | await Promise.resolve(); 59 | } 60 | 61 | /** 62 | * Stop the worker gracefully. Waits for all active workflow runs to complete 63 | * before returning. 64 | * @returns Promise resolved when stopped 65 | */ 66 | async stop(): Promise { 67 | this.running = false; 68 | 69 | // wait for the poll loop to stop 70 | if (this.loopPromise) await this.loopPromise; 71 | 72 | // wait for all active executions to finish 73 | while (this.activeExecutions.size > 0) await sleep(100); 74 | } 75 | 76 | /** 77 | * Processes one round of work claims and execution. Exposed for testing. 78 | * Returns the number of workflow runs claimed. 79 | * @returns Number of workflow runs claimed 80 | */ 81 | async tick(): Promise { 82 | const availableSlots = this.concurrency - this.activeExecutions.size; 83 | if (availableSlots <= 0) return 0; 84 | 85 | // claim work for each available slot 86 | const claims = Array.from({ length: availableSlots }, (_, i) => { 87 | const availableWorkerId = this.workerIds[i % this.workerIds.length]; 88 | return availableWorkerId 89 | ? this.claimAndProcessWorkflowRunInBackground(availableWorkerId) 90 | : Promise.resolve(null); 91 | }); 92 | 93 | const claimed = await Promise.all(claims); 94 | return claimed.filter((run) => run !== null).length; 95 | } 96 | 97 | /** 98 | * Get the configured concurrency limit. 99 | * @returns Concurrency limit 100 | */ 101 | private get concurrency(): number { 102 | return this.workerIds.length; 103 | } 104 | 105 | /* 106 | * Main run loop that continuously ticks while the worker is running. 107 | * Only sleeps when no work was claimed to avoid busy-waiting. 108 | */ 109 | private async runLoop(): Promise { 110 | while (this.running) { 111 | try { 112 | const claimedCount = await this.tick(); 113 | // only sleep if we didn't claim any work 114 | if (claimedCount === 0) { 115 | await sleep(DEFAULT_POLL_INTERVAL_MS); 116 | } 117 | } catch (error) { 118 | console.error("Worker tick failed:", error); 119 | await sleep(DEFAULT_POLL_INTERVAL_MS); 120 | } 121 | } 122 | } 123 | 124 | /* 125 | * Claim and process a workflow run for the given worker ID. Do not await the 126 | * processing here to avoid blocking the caller. 127 | * Returns the claimed workflow run, or null if none was available. 128 | */ 129 | private async claimAndProcessWorkflowRunInBackground( 130 | workerId: string, 131 | ): Promise { 132 | // claim workflow run 133 | const workflowRun = await this.backend.claimWorkflowRun({ 134 | workerId, 135 | leaseDurationMs: DEFAULT_LEASE_DURATION_MS, 136 | }); 137 | if (!workflowRun) return null; 138 | 139 | const workflow = this.registry.get( 140 | workflowRun.workflowName, 141 | workflowRun.version, 142 | ); 143 | if (!workflow) { 144 | const versionStr = workflowRun.version 145 | ? ` (version: ${workflowRun.version})` 146 | : ""; 147 | await this.backend.failWorkflowRun({ 148 | workflowRunId: workflowRun.id, 149 | workerId, 150 | error: { 151 | message: `Workflow "${workflowRun.workflowName}"${versionStr} is not registered`, 152 | }, 153 | }); 154 | return null; 155 | } 156 | 157 | // create execution and start processing *async* w/o blocking 158 | const execution = new WorkflowExecution({ 159 | backend: this.backend, 160 | workflowRun, 161 | workerId, 162 | }); 163 | this.activeExecutions.add(execution); 164 | 165 | this.processExecutionInBackground(execution, workflow) 166 | .catch(() => { 167 | // errors are already handled in processExecution 168 | }) 169 | .finally(() => { 170 | execution.stopHeartbeat(); 171 | this.activeExecutions.delete(execution); 172 | }); 173 | 174 | return workflowRun; 175 | } 176 | 177 | /** 178 | * Process a workflow execution, handling heartbeats, step execution, and 179 | * marking success or failure. 180 | * @param execution - Workflow execution 181 | * @param workflow - Workflow to execute 182 | * @returns Promise resolved when processing completes 183 | */ 184 | private async processExecutionInBackground( 185 | execution: WorkflowExecution, 186 | workflow: Workflow, 187 | ): Promise { 188 | // start heartbeating 189 | execution.startHeartbeat(); 190 | 191 | try { 192 | await executeWorkflow({ 193 | backend: this.backend, 194 | workflowRun: execution.workflowRun, 195 | workflowFn: workflow.fn, 196 | workflowVersion: execution.workflowRun.version, 197 | workerId: execution.workerId, 198 | }); 199 | } catch (error) { 200 | // specifically for unexpected errors in the execution wrapper itself, not 201 | // for business logic errors (those are handled inside executeWorkflow) 202 | console.error( 203 | `Critical error during workflow execution for run ${execution.workflowRun.id}:`, 204 | error, 205 | ); 206 | } 207 | } 208 | } 209 | 210 | /** 211 | * Configures the options for a WorkflowExecution. 212 | */ 213 | interface WorkflowExecutionOptions { 214 | backend: Backend; 215 | workflowRun: WorkflowRun; 216 | workerId: string; 217 | } 218 | 219 | /** 220 | * Tracks a claimed workflow run and maintains its heartbeat lease for the 221 | * worker. 222 | */ 223 | class WorkflowExecution { 224 | private backend: Backend; 225 | workflowRun: WorkflowRun; 226 | workerId: string; 227 | private heartbeatTimer: NodeJS.Timeout | null = null; 228 | 229 | constructor(options: WorkflowExecutionOptions) { 230 | this.backend = options.backend; 231 | this.workflowRun = options.workflowRun; 232 | this.workerId = options.workerId; 233 | } 234 | 235 | /** 236 | * Start the heartbeat loop for this execution, heartbeating at half the lease 237 | * duration. 238 | */ 239 | startHeartbeat(): void { 240 | const leaseDurationMs = DEFAULT_LEASE_DURATION_MS; 241 | const heartbeatIntervalMs = leaseDurationMs / 2; 242 | 243 | this.heartbeatTimer = setInterval(() => { 244 | this.backend 245 | .extendWorkflowRunLease({ 246 | workflowRunId: this.workflowRun.id, 247 | workerId: this.workerId, 248 | leaseDurationMs, 249 | }) 250 | .catch((error: unknown) => { 251 | console.error("Heartbeat failed:", error); 252 | }); 253 | }, heartbeatIntervalMs); 254 | } 255 | 256 | /** 257 | * Stop the heartbeat loop. 258 | */ 259 | stopHeartbeat(): void { 260 | if (this.heartbeatTimer) { 261 | clearInterval(this.heartbeatTimer); 262 | this.heartbeatTimer = null; 263 | } 264 | } 265 | } 266 | 267 | /** 268 | * Sleep for a given duration. 269 | * @param ms - Milliseconds to sleep 270 | * @returns Promise resolved after sleeping 271 | */ 272 | function sleep(ms: number): Promise { 273 | return new Promise((resolve) => setTimeout(resolve, ms)); 274 | } 275 | -------------------------------------------------------------------------------- /packages/backend-sqlite/sqlite.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | import { DatabaseSync } from "node:sqlite"; 3 | 4 | export type Database = DatabaseSync; 5 | 6 | /** 7 | * newDatabase creates a new SQLite database connection. 8 | * @param path - Database file path (or ":memory:") for testing 9 | * @returns SQLite database connection 10 | */ 11 | export function newDatabase(path: string): Database { 12 | const db = new DatabaseSync(path); 13 | // Only enable WAL mode for file-based databases 14 | if (path !== ":memory:") { 15 | db.exec("PRAGMA journal_mode = WAL;"); 16 | } 17 | db.exec("PRAGMA foreign_keys = ON;"); 18 | return db; 19 | } 20 | 21 | /** 22 | * migrations returns the list of migration SQL statements. 23 | * @returns Migration SQL statements 24 | */ 25 | export function migrations(): string[] { 26 | return [ 27 | // 0 - init 28 | `BEGIN; 29 | 30 | CREATE TABLE IF NOT EXISTS "openworkflow_migrations" ( 31 | "version" INTEGER NOT NULL PRIMARY KEY 32 | ); 33 | 34 | INSERT OR IGNORE INTO "openworkflow_migrations" ("version") 35 | VALUES (0); 36 | 37 | COMMIT;`, 38 | 39 | // 1 - add workflow_runs and step_attempts tables 40 | `BEGIN; 41 | 42 | PRAGMA defer_foreign_keys = ON; 43 | 44 | CREATE TABLE IF NOT EXISTS "workflow_runs" ( 45 | "namespace_id" TEXT NOT NULL, 46 | "id" TEXT NOT NULL, 47 | -- 48 | "workflow_name" TEXT NOT NULL, 49 | "version" TEXT, 50 | "status" TEXT NOT NULL, 51 | "idempotency_key" TEXT, 52 | "config" TEXT NOT NULL, 53 | "context" TEXT, 54 | "input" TEXT, 55 | "output" TEXT, 56 | "error" TEXT, 57 | "attempts" INTEGER NOT NULL, 58 | "parent_step_attempt_namespace_id" TEXT, 59 | "parent_step_attempt_id" TEXT, 60 | "worker_id" TEXT, 61 | "available_at" TEXT, 62 | "deadline_at" TEXT, 63 | "started_at" TEXT, 64 | "finished_at" TEXT, 65 | "created_at" TEXT NOT NULL, 66 | "updated_at" TEXT NOT NULL, 67 | PRIMARY KEY ("namespace_id", "id"), 68 | FOREIGN KEY ("parent_step_attempt_namespace_id", "parent_step_attempt_id") 69 | REFERENCES "step_attempts" ("namespace_id", "id") 70 | ON DELETE SET NULL 71 | ); 72 | 73 | CREATE TABLE IF NOT EXISTS "step_attempts" ( 74 | "namespace_id" TEXT NOT NULL, 75 | "id" TEXT NOT NULL, 76 | -- 77 | "workflow_run_id" TEXT NOT NULL, 78 | "step_name" TEXT NOT NULL, 79 | "kind" TEXT NOT NULL, 80 | "status" TEXT NOT NULL, 81 | "config" TEXT NOT NULL, 82 | "context" TEXT, 83 | "output" TEXT, 84 | "error" TEXT, 85 | "child_workflow_run_namespace_id" TEXT, 86 | "child_workflow_run_id" TEXT, 87 | "started_at" TEXT, 88 | "finished_at" TEXT, 89 | "created_at" TEXT NOT NULL, 90 | "updated_at" TEXT NOT NULL, 91 | PRIMARY KEY ("namespace_id", "id"), 92 | FOREIGN KEY ("namespace_id", "workflow_run_id") 93 | REFERENCES "workflow_runs" ("namespace_id", "id") 94 | ON DELETE CASCADE, 95 | FOREIGN KEY ("child_workflow_run_namespace_id", "child_workflow_run_id") 96 | REFERENCES "workflow_runs" ("namespace_id", "id") 97 | ON DELETE SET NULL 98 | ); 99 | 100 | INSERT OR IGNORE INTO "openworkflow_migrations" ("version") 101 | VALUES (1); 102 | 103 | COMMIT;`, 104 | 105 | // 2 - foreign keys 106 | `BEGIN; 107 | 108 | -- Foreign keys are defined in migration 1 since SQLite requires them during table creation 109 | -- This migration exists for version parity with PostgreSQL backend 110 | 111 | INSERT OR IGNORE INTO "openworkflow_migrations" ("version") 112 | VALUES (2); 113 | 114 | COMMIT;`, 115 | 116 | // 3 - validate foreign keys 117 | `BEGIN; 118 | 119 | -- Foreign key validation happens automatically in SQLite when PRAGMA foreign_keys = ON 120 | -- This migration exists for version parity with PostgreSQL backend 121 | 122 | INSERT OR IGNORE INTO "openworkflow_migrations" ("version") 123 | VALUES (3); 124 | 125 | COMMIT;`, 126 | 127 | // 4 - indexes 128 | `BEGIN; 129 | 130 | CREATE INDEX IF NOT EXISTS "workflow_runs_status_available_at_created_at_idx" 131 | ON "workflow_runs" ("namespace_id", "status", "available_at", "created_at"); 132 | 133 | CREATE INDEX IF NOT EXISTS "workflow_runs_workflow_name_idempotency_key_created_at_idx" 134 | ON "workflow_runs" ("namespace_id", "workflow_name", "idempotency_key", "created_at"); 135 | 136 | CREATE INDEX IF NOT EXISTS "workflow_runs_parent_step_idx" 137 | ON "workflow_runs" ("parent_step_attempt_namespace_id", "parent_step_attempt_id") 138 | WHERE parent_step_attempt_namespace_id IS NOT NULL AND parent_step_attempt_id IS NOT NULL; 139 | 140 | CREATE INDEX IF NOT EXISTS "workflow_runs_created_at_desc_idx" 141 | ON "workflow_runs" ("namespace_id", "created_at" DESC); 142 | 143 | CREATE INDEX IF NOT EXISTS "workflow_runs_status_created_at_desc_idx" 144 | ON "workflow_runs" ("namespace_id", "status", "created_at" DESC); 145 | 146 | CREATE INDEX IF NOT EXISTS "workflow_runs_workflow_name_status_created_at_desc_idx" 147 | ON "workflow_runs" ("namespace_id", "workflow_name", "status", "created_at" DESC); 148 | 149 | CREATE INDEX IF NOT EXISTS "step_attempts_workflow_run_created_at_idx" 150 | ON "step_attempts" ("namespace_id", "workflow_run_id", "created_at"); 151 | 152 | CREATE INDEX IF NOT EXISTS "step_attempts_workflow_run_step_name_created_at_idx" 153 | ON "step_attempts" ("namespace_id", "workflow_run_id", "step_name", "created_at"); 154 | 155 | CREATE INDEX IF NOT EXISTS "step_attempts_child_workflow_run_idx" 156 | ON "step_attempts" ("child_workflow_run_namespace_id", "child_workflow_run_id") 157 | WHERE child_workflow_run_namespace_id IS NOT NULL AND child_workflow_run_id IS NOT NULL; 158 | 159 | INSERT OR IGNORE INTO "openworkflow_migrations" ("version") 160 | VALUES (4); 161 | 162 | COMMIT;`, 163 | ]; 164 | } 165 | 166 | /** 167 | * migrate applies pending migrations to the database. Does nothing if the 168 | * database is already up to date. 169 | * @param db - SQLite database 170 | */ 171 | export function migrate(db: Database): void { 172 | const currentMigrationVersion = getCurrentMigrationVersion(db); 173 | 174 | for (const [i, migrationSql] of migrations().entries()) { 175 | if (i <= currentMigrationVersion) continue; // already applied 176 | 177 | db.exec(migrationSql); 178 | } 179 | } 180 | 181 | /** 182 | * getCurrentMigrationVersion returns the current migration version of the database. 183 | * @param db - SQLite database 184 | * @returns Current migration version 185 | */ 186 | function getCurrentMigrationVersion(db: Database): number { 187 | // check if migrations table exists 188 | const existsStmt = db.prepare(` 189 | SELECT COUNT(*) as count 190 | FROM sqlite_master 191 | WHERE type = 'table' AND name = 'openworkflow_migrations' 192 | `); 193 | const existsResult = existsStmt.get() as { count: number } | undefined; 194 | if (!existsResult || existsResult.count === 0) return -1; 195 | 196 | // get current version 197 | const versionStmt = db.prepare( 198 | `SELECT MAX("version") AS "version" FROM "openworkflow_migrations";`, 199 | ); 200 | const versionResult = versionStmt.get() as { version: number } | undefined; 201 | return versionResult?.version ?? -1; 202 | } 203 | 204 | /** 205 | * Helper to generate UUIDs (SQLite doesn't have built-in UUID generation) 206 | * @returns A UUID string 207 | */ 208 | export function generateUUID(): string { 209 | return randomUUID(); 210 | } 211 | 212 | /** 213 | * Helper to get current timestamp in ISO8601 format 214 | * @returns ISO8601 timestamp string 215 | */ 216 | export function now(): string { 217 | return new Date().toISOString(); 218 | } 219 | 220 | /** 221 | * Helper to add milliseconds to a date and return ISO8601 string 222 | * @param date - ISO8601 date string 223 | * @param ms - Milliseconds to add 224 | * @returns Updated ISO8601 date string 225 | */ 226 | export function addMilliseconds(date: string, ms: number): string { 227 | const d = new Date(date); 228 | d.setMilliseconds(d.getMilliseconds() + ms); 229 | return d.toISOString(); 230 | } 231 | 232 | /** 233 | * Helper to serialize JSON for SQLite storage 234 | * @param value - Value to serialize 235 | * @returns JSON string or null 236 | */ 237 | export function toJSON(value: unknown): string | null { 238 | return value === null || value === undefined ? null : JSON.stringify(value); 239 | } 240 | 241 | /** 242 | * Helper to deserialize JSON from SQLite storage 243 | * @param value - JSON string or null 244 | * @returns Parsed value 245 | */ 246 | export function fromJSON(value: string | null): unknown { 247 | return value === null ? null : JSON.parse(value); 248 | } 249 | 250 | /** 251 | * Helper to convert Date to ISO8601 string for SQLite 252 | * @param date - Date or null 253 | * @returns ISO8601 date string or null 254 | */ 255 | export function toISO(date: Date | null): string | null { 256 | return date ? date.toISOString() : null; 257 | } 258 | 259 | /** 260 | * Helper to convert ISO8601 string from SQLite to Date 261 | * @param dateStr - ISO8601 date string or null 262 | * @returns Date or null 263 | */ 264 | export function fromISO(dateStr: string | null): Date | null { 265 | return dateStr ? new Date(dateStr) : null; 266 | } 267 | -------------------------------------------------------------------------------- /packages/backend-postgres/postgres.ts: -------------------------------------------------------------------------------- 1 | import postgres from "postgres"; 2 | 3 | export const DEFAULT_POSTGRES_URL = 4 | "postgresql://postgres:postgres@localhost:5432/postgres"; 5 | 6 | // The default schema to use for OpenWorkflow data. This type is more for 7 | // documentation than for practical use. The only time we allow schema 8 | // customization is during testing, specifically for testing migrations. 9 | // Everywhere else uses the "openworkflow" schema directly for prepared 10 | // statements. 11 | export const DEFAULT_SCHEMA = "openworkflow"; 12 | 13 | export type Postgres = ReturnType; 14 | export type PostgresOptions = Parameters[1]; 15 | 16 | /** 17 | * newPostgres creates a new Postgres client. 18 | * @param url - Database connection URL 19 | * @param options - Postgres client options 20 | * @returns A Postgres client 21 | */ 22 | export function newPostgres(url: string, options?: PostgresOptions) { 23 | return postgres(url, { ...options, transform: postgres.toCamel }); 24 | } 25 | 26 | /** 27 | * newPostgresMaxOne creates a new Postgres client with a maximum pool size of 28 | * one, which is useful for migrations. 29 | * @param url - Database connection URL 30 | * @param options - Postgres client options 31 | * @returns A Postgres client 32 | */ 33 | export function newPostgresMaxOne(url: string, options?: PostgresOptions) { 34 | return newPostgres(url, { ...options, max: 1 }); 35 | } 36 | 37 | /** 38 | * migrations returns the list of migration SQL statements. 39 | * @param schema - Schema name 40 | * @returns Migration SQL statements 41 | */ 42 | export function migrations(schema: string): string[] { 43 | return [ 44 | // 0 - init 45 | `BEGIN; 46 | 47 | CREATE SCHEMA IF NOT EXISTS ${schema}; 48 | 49 | CREATE TABLE IF NOT EXISTS "${schema}"."openworkflow_migrations" ( 50 | "version" BIGINT NOT NULL PRIMARY KEY 51 | ); 52 | 53 | INSERT INTO "${schema}"."openworkflow_migrations" ("version") 54 | VALUES (0) 55 | ON CONFLICT DO NOTHING; 56 | 57 | COMMIT;`, 58 | 59 | // 1 - add workflow_runs and step_attempts tables 60 | `BEGIN; 61 | 62 | CREATE TABLE IF NOT EXISTS "${schema}"."workflow_runs" ( 63 | "namespace_id" TEXT NOT NULL, 64 | "id" TEXT NOT NULL, 65 | -- 66 | "workflow_name" TEXT NOT NULL, 67 | "version" TEXT, 68 | "status" TEXT NOT NULL, 69 | "idempotency_key" TEXT, 70 | "config" JSONB NOT NULL, 71 | "context" JSONB, 72 | "input" JSONB, 73 | "output" JSONB, 74 | "error" JSONB, 75 | "attempts" INTEGER NOT NULL, 76 | "parent_step_attempt_namespace_id" TEXT, 77 | "parent_step_attempt_id" TEXT, 78 | "worker_id" TEXT, 79 | "available_at" TIMESTAMPTZ, 80 | "deadline_at" TIMESTAMPTZ, 81 | "started_at" TIMESTAMPTZ, 82 | "finished_at" TIMESTAMPTZ, 83 | "created_at" TIMESTAMPTZ NOT NULL, 84 | "updated_at" TIMESTAMPTZ NOT NULL, 85 | PRIMARY KEY ("namespace_id", "id") 86 | ); 87 | 88 | CREATE TABLE IF NOT EXISTS "${schema}"."step_attempts" ( 89 | "namespace_id" TEXT NOT NULL, 90 | "id" TEXT NOT NULL, 91 | -- 92 | "workflow_run_id" TEXT NOT NULL, 93 | "step_name" TEXT NOT NULL, 94 | "kind" TEXT NOT NULL, 95 | "status" TEXT NOT NULL, 96 | "config" JSONB NOT NULL, 97 | "context" JSONB, 98 | "output" JSONB, 99 | "error" JSONB, 100 | "child_workflow_run_namespace_id" TEXT, 101 | "child_workflow_run_id" TEXT, 102 | "started_at" TIMESTAMPTZ, 103 | "finished_at" TIMESTAMPTZ, 104 | "created_at" TIMESTAMPTZ NOT NULL, 105 | "updated_at" TIMESTAMPTZ NOT NULL, 106 | PRIMARY KEY ("namespace_id", "id") 107 | ); 108 | 109 | INSERT INTO "${schema}"."openworkflow_migrations" ("version") 110 | VALUES (1) 111 | ON CONFLICT DO NOTHING; 112 | 113 | COMMIT;`, 114 | 115 | // 2 - foreign keys 116 | `BEGIN; 117 | 118 | ALTER TABLE "${schema}"."step_attempts" 119 | ADD CONSTRAINT "step_attempts_workflow_run_fk" 120 | FOREIGN KEY ("namespace_id", "workflow_run_id") 121 | REFERENCES "${schema}"."workflow_runs" ("namespace_id", "id") 122 | ON DELETE CASCADE 123 | NOT VALID; 124 | 125 | ALTER TABLE "${schema}"."workflow_runs" 126 | ADD CONSTRAINT "workflow_runs_parent_step_attempt_fk" 127 | FOREIGN KEY ("parent_step_attempt_namespace_id", "parent_step_attempt_id") 128 | REFERENCES "${schema}"."step_attempts" ("namespace_id", "id") 129 | ON DELETE SET NULL 130 | NOT VALID; 131 | 132 | ALTER TABLE "${schema}"."step_attempts" 133 | ADD CONSTRAINT "step_attempts_child_workflow_run_fk" 134 | FOREIGN KEY ("child_workflow_run_namespace_id", "child_workflow_run_id") 135 | REFERENCES "${schema}"."workflow_runs" ("namespace_id", "id") 136 | ON DELETE SET NULL 137 | NOT VALID; 138 | 139 | INSERT INTO "${schema}"."openworkflow_migrations" ("version") 140 | VALUES (2) 141 | ON CONFLICT DO NOTHING; 142 | 143 | COMMIT;`, 144 | 145 | // 3 - validate foreign keys 146 | `BEGIN; 147 | 148 | ALTER TABLE "${schema}"."step_attempts" 149 | VALIDATE CONSTRAINT "step_attempts_workflow_run_fk"; 150 | 151 | ALTER TABLE "${schema}"."workflow_runs" VALIDATE CONSTRAINT 152 | "workflow_runs_parent_step_attempt_fk"; 153 | 154 | ALTER TABLE "${schema}"."step_attempts" 155 | VALIDATE CONSTRAINT "step_attempts_child_workflow_run_fk"; 156 | 157 | INSERT INTO "${schema}"."openworkflow_migrations" ("version") 158 | VALUES (3) 159 | ON CONFLICT DO NOTHING; 160 | 161 | COMMIT;`, 162 | 163 | // 4 - indexes 164 | `BEGIN; 165 | 166 | CREATE INDEX IF NOT EXISTS "workflow_runs_status_available_at_created_at_idx" 167 | ON "${schema}"."workflow_runs" ("namespace_id", "status", "available_at", "created_at"); 168 | 169 | CREATE INDEX IF NOT EXISTS "workflow_runs_workflow_name_idempotency_key_created_at_idx" 170 | ON "${schema}"."workflow_runs" ("namespace_id", "workflow_name", "idempotency_key", "created_at"); 171 | 172 | CREATE INDEX IF NOT EXISTS "workflow_runs_parent_step_idx" 173 | ON "${schema}"."workflow_runs" ("parent_step_attempt_namespace_id", "parent_step_attempt_id") 174 | WHERE parent_step_attempt_namespace_id IS NOT NULL AND parent_step_attempt_id IS NOT NULL; 175 | 176 | CREATE INDEX IF NOT EXISTS "workflow_runs_created_at_desc_idx" 177 | ON "${schema}"."workflow_runs" ("namespace_id", "created_at" DESC); 178 | 179 | CREATE INDEX IF NOT EXISTS "workflow_runs_status_created_at_desc_idx" 180 | ON "${schema}"."workflow_runs" ("namespace_id", "status", "created_at" DESC); 181 | 182 | CREATE INDEX IF NOT EXISTS "workflow_runs_workflow_name_status_created_at_desc_idx" 183 | ON "${schema}"."workflow_runs" ("namespace_id", "workflow_name", "status", "created_at" DESC); 184 | 185 | CREATE INDEX IF NOT EXISTS "step_attempts_workflow_run_created_at_idx" 186 | ON "${schema}"."step_attempts" ("namespace_id", "workflow_run_id", "created_at"); 187 | 188 | CREATE INDEX IF NOT EXISTS "step_attempts_workflow_run_step_name_created_at_idx" 189 | ON "${schema}"."step_attempts" ("namespace_id", "workflow_run_id", "step_name", "created_at"); 190 | 191 | CREATE INDEX IF NOT EXISTS "step_attempts_child_workflow_run_idx" 192 | ON "${schema}"."step_attempts" ("child_workflow_run_namespace_id", "child_workflow_run_id") 193 | WHERE child_workflow_run_namespace_id IS NOT NULL AND child_workflow_run_id IS NOT NULL; 194 | 195 | INSERT INTO "${schema}"."openworkflow_migrations"("version") 196 | VALUES (4) 197 | ON CONFLICT DO NOTHING; 198 | 199 | COMMIT;`, 200 | ]; 201 | } 202 | 203 | /** 204 | * migrate applies pending migrations to the database. Does nothing if the 205 | * database is already up to date. 206 | * @param pg - Postgres client 207 | * @param schema - Schema name 208 | * @returns Promise resolved when migrations complete 209 | */ 210 | export async function migrate(pg: Postgres, schema: string) { 211 | const currentMigrationVersion = await getCurrentMigrationVersion(pg, schema); 212 | 213 | for (const [i, migrationSql] of migrations(schema).entries()) { 214 | if (i <= currentMigrationVersion) continue; // already applied 215 | 216 | await pg.unsafe(migrationSql); 217 | } 218 | } 219 | 220 | /** 221 | * dropSchema drops the specified schema from the database. 222 | * @param pg - Postgres client 223 | * @param schema - Schema name 224 | * @returns Promise resolved when the schema is dropped 225 | */ 226 | export async function dropSchema(pg: Postgres, schema: string) { 227 | await pg.unsafe(`DROP SCHEMA IF EXISTS ${schema} CASCADE;`); 228 | } 229 | 230 | /** 231 | * getCurrentVersion returns the current migration version of the database. 232 | * @param pg - Postgres client 233 | * @param schema - Schema name 234 | * @returns Current migration version 235 | */ 236 | async function getCurrentMigrationVersion( 237 | pg: Postgres, 238 | schema: string, 239 | ): Promise { 240 | // check if migrations table exists 241 | const existsRes = await pg.unsafe<{ exists: boolean }[]>(` 242 | SELECT EXISTS ( 243 | SELECT 1 244 | FROM information_schema.tables 245 | WHERE table_schema = '${schema}' 246 | AND table_name = 'openworkflow_migrations' 247 | )`); 248 | if (!existsRes[0]?.exists) return -1; 249 | 250 | // get current version 251 | const currentVersionRes = await pg.unsafe<{ version: number }[]>( 252 | `SELECT MAX("version") AS "version" FROM "${schema}"."openworkflow_migrations";`, 253 | ); 254 | return currentVersionRes[0]?.version ?? -1; 255 | } 256 | -------------------------------------------------------------------------------- /packages/openworkflow/execution.ts: -------------------------------------------------------------------------------- 1 | import type { Backend } from "./backend.js"; 2 | import type { DurationString } from "./core/duration.js"; 3 | import { serializeError } from "./core/error.js"; 4 | import type { JsonValue } from "./core/json.js"; 5 | import type { StepAttempt, StepAttemptCache } from "./core/step.js"; 6 | import { 7 | createStepAttemptCacheFromAttempts, 8 | getCachedStepAttempt, 9 | addToStepAttemptCache, 10 | normalizeStepOutput, 11 | calculateSleepResumeAt, 12 | createSleepContext, 13 | } from "./core/step.js"; 14 | import type { WorkflowRun } from "./core/workflow.js"; 15 | 16 | /** 17 | * Config for an individual step defined with `step.run()`. 18 | */ 19 | export interface StepFunctionConfig { 20 | /** 21 | * The name of the step. 22 | */ 23 | name: string; 24 | } 25 | 26 | /** 27 | * Represents the API for defining steps within a workflow. Used within a 28 | * workflow handler to define steps by calling `step.run()`. 29 | */ 30 | export interface StepApi { 31 | run( 32 | config: Readonly, 33 | fn: StepFunction, 34 | ): Promise; 35 | sleep(name: string, duration: DurationString): Promise; 36 | } 37 | 38 | /** 39 | * The step definition (defined by the user) that executes user code. Can return 40 | * undefined (e.g., when using `return;`) which will be converted to null. 41 | */ 42 | export type StepFunction = () => 43 | | Promise 44 | | Output 45 | | undefined; 46 | 47 | /** 48 | * Params passed to a workflow function for the user to use when defining steps. 49 | */ 50 | export interface WorkflowFunctionParams { 51 | input: Input; 52 | step: StepApi; 53 | version: string | null; 54 | } 55 | 56 | /** 57 | * The workflow definition's function (defined by the user) that the user uses 58 | * to define the workflow's steps. 59 | */ 60 | export type WorkflowFunction = ( 61 | params: Readonly>, 62 | ) => Promise | Output; 63 | 64 | /** 65 | * Signal thrown when a workflow needs to sleep. Contains the time when the 66 | * workflow should resume. 67 | */ 68 | class SleepSignal extends Error { 69 | readonly resumeAt: Date; 70 | 71 | constructor(resumeAt: Readonly) { 72 | super("SleepSignal"); 73 | this.name = "SleepSignal"; 74 | this.resumeAt = resumeAt; 75 | } 76 | } 77 | 78 | /** 79 | * Configures the options for a StepExecutor. 80 | */ 81 | export interface StepExecutorOptions { 82 | backend: Backend; 83 | workflowRunId: string; 84 | workerId: string; 85 | attempts: StepAttempt[]; 86 | } 87 | 88 | /** 89 | * Replays prior step attempts and persists new ones while memoizing 90 | * deterministic step outputs. 91 | */ 92 | class StepExecutor implements StepApi { 93 | private readonly backend: Backend; 94 | private readonly workflowRunId: string; 95 | private readonly workerId: string; 96 | private cache: StepAttemptCache; 97 | 98 | constructor(options: Readonly) { 99 | this.backend = options.backend; 100 | this.workflowRunId = options.workflowRunId; 101 | this.workerId = options.workerId; 102 | 103 | this.cache = createStepAttemptCacheFromAttempts(options.attempts); 104 | } 105 | 106 | async run( 107 | config: Readonly, 108 | fn: StepFunction, 109 | ): Promise { 110 | const { name } = config; 111 | 112 | // return cached result if available 113 | const existingAttempt = getCachedStepAttempt(this.cache, name); 114 | if (existingAttempt) { 115 | return existingAttempt.output as Output; 116 | } 117 | 118 | // not in cache, create new step attempt 119 | const attempt = await this.backend.createStepAttempt({ 120 | workflowRunId: this.workflowRunId, 121 | workerId: this.workerId, 122 | stepName: name, 123 | kind: "function", 124 | config: {}, 125 | context: null, 126 | }); 127 | 128 | try { 129 | // execute step function 130 | const result = await fn(); 131 | const output = normalizeStepOutput(result); 132 | 133 | // mark success 134 | const savedAttempt = await this.backend.completeStepAttempt({ 135 | workflowRunId: this.workflowRunId, 136 | stepAttemptId: attempt.id, 137 | workerId: this.workerId, 138 | output, 139 | }); 140 | 141 | // cache result 142 | this.cache = addToStepAttemptCache(this.cache, savedAttempt); 143 | 144 | return savedAttempt.output as Output; 145 | } catch (error) { 146 | // mark failure 147 | await this.backend.failStepAttempt({ 148 | workflowRunId: this.workflowRunId, 149 | stepAttemptId: attempt.id, 150 | workerId: this.workerId, 151 | error: serializeError(error), 152 | }); 153 | throw error; 154 | } 155 | } 156 | 157 | async sleep(name: string, duration: DurationString): Promise { 158 | // return cached result if this sleep already completed 159 | const existingAttempt = getCachedStepAttempt(this.cache, name); 160 | if (existingAttempt) return; 161 | 162 | // create new step attempt for the sleep 163 | const result = calculateSleepResumeAt(duration); 164 | if (!result.ok) { 165 | throw result.error; 166 | } 167 | const resumeAt = result.value; 168 | const context = createSleepContext(resumeAt); 169 | 170 | await this.backend.createStepAttempt({ 171 | workflowRunId: this.workflowRunId, 172 | workerId: this.workerId, 173 | stepName: name, 174 | kind: "sleep", 175 | config: {}, 176 | context, 177 | }); 178 | 179 | // throw sleep signal to trigger postponement 180 | // we do not mark the step as completed here; it will be updated 181 | // when the workflow resumes 182 | throw new SleepSignal(resumeAt); 183 | } 184 | } 185 | 186 | /** 187 | * Parameters for the workflow execution use case. 188 | */ 189 | export interface ExecuteWorkflowParams { 190 | backend: Backend; 191 | workflowRun: WorkflowRun; 192 | workflowFn: WorkflowFunction; 193 | workflowVersion: string | null; 194 | workerId: string; 195 | } 196 | 197 | /** 198 | * Execute a workflow run. This is the core application use case that handles: 199 | * - Loading step history 200 | * - Handling sleeping steps 201 | * - Creating the step executor 202 | * - Executing the workflow function 203 | * - Completing, failing, or sleeping the workflow run based on the outcome 204 | * @param params - The execution parameters 205 | */ 206 | export async function executeWorkflow( 207 | params: Readonly, 208 | ): Promise { 209 | const { backend, workflowRun, workflowFn, workflowVersion, workerId } = 210 | params; 211 | 212 | try { 213 | // load all pages of step history 214 | const attempts: StepAttempt[] = []; 215 | let cursor: string | undefined; 216 | do { 217 | const response = await backend.listStepAttempts({ 218 | workflowRunId: workflowRun.id, 219 | ...(cursor ? { after: cursor } : {}), 220 | limit: 1000, 221 | }); 222 | attempts.push(...response.data); 223 | cursor = response.pagination.next ?? undefined; 224 | } while (cursor); 225 | 226 | // mark any sleep steps as completed if their sleep duration has elapsed, 227 | // or rethrow SleepSignal if still sleeping 228 | for (let i = 0; i < attempts.length; i++) { 229 | const attempt = attempts[i]; 230 | if (!attempt) continue; 231 | 232 | if ( 233 | attempt.status === "running" && 234 | attempt.kind === "sleep" && 235 | attempt.context?.kind === "sleep" 236 | ) { 237 | const now = Date.now(); 238 | const resumeAt = new Date(attempt.context.resumeAt); 239 | const resumeAtMs = resumeAt.getTime(); 240 | 241 | if (now < resumeAtMs) { 242 | // sleep duration HAS NOT elapsed yet, throw signal to put workflow 243 | // back to sleep 244 | throw new SleepSignal(resumeAt); 245 | } 246 | 247 | // sleep duration HAS elapsed, mark the step as completed and continue 248 | const completed = await backend.completeStepAttempt({ 249 | workflowRunId: workflowRun.id, 250 | stepAttemptId: attempt.id, 251 | workerId, 252 | output: null, 253 | }); 254 | 255 | // update cache w/ completed attempt 256 | attempts[i] = completed; 257 | } 258 | } 259 | 260 | // create step executor 261 | const executor = new StepExecutor({ 262 | backend, 263 | workflowRunId: workflowRun.id, 264 | workerId, 265 | attempts, 266 | }); 267 | 268 | // execute workflow 269 | const output = await workflowFn({ 270 | input: workflowRun.input as unknown, 271 | step: executor, 272 | version: workflowVersion, 273 | }); 274 | 275 | // mark success 276 | await backend.completeWorkflowRun({ 277 | workflowRunId: workflowRun.id, 278 | workerId, 279 | output: (output ?? null) as JsonValue, 280 | }); 281 | } catch (error) { 282 | // handle sleep signal by setting workflow to sleeping status 283 | if (error instanceof SleepSignal) { 284 | await backend.sleepWorkflowRun({ 285 | workflowRunId: workflowRun.id, 286 | workerId, 287 | availableAt: error.resumeAt, 288 | }); 289 | 290 | return; 291 | } 292 | 293 | // mark failure 294 | await backend.failWorkflowRun({ 295 | workflowRunId: workflowRun.id, 296 | workerId, 297 | error: serializeError(error), 298 | }); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /packages/openworkflow/client.ts: -------------------------------------------------------------------------------- 1 | import type { Backend } from "./backend.js"; 2 | import { loadConfig } from "./config.js"; 3 | import type { StandardSchemaV1 } from "./core/schema.js"; 4 | import type { 5 | SchemaInput, 6 | SchemaOutput, 7 | WorkflowRun, 8 | } from "./core/workflow.js"; 9 | import { validateInput } from "./core/workflow.js"; 10 | import type { WorkflowFunction } from "./execution.js"; 11 | import { WorkflowRegistry } from "./registry.js"; 12 | import { Worker } from "./worker.js"; 13 | import { 14 | defineWorkflow, 15 | type Workflow, 16 | type WorkflowSpec, 17 | } from "./workflow.js"; 18 | 19 | const DEFAULT_RESULT_POLL_INTERVAL_MS = 1000; // 1s 20 | const DEFAULT_RESULT_TIMEOUT_MS = 5 * 60 * 1000; // 5m 21 | 22 | /* The data the worker function receives (after transformation). */ 23 | type WorkflowHandlerInput = SchemaOutput; 24 | 25 | /* The data the client sends (before transformation) */ 26 | type WorkflowRunInput = SchemaInput; 27 | 28 | /** 29 | * Options for the OpenWorkflow client. 30 | */ 31 | export interface OpenWorkflowOptions { 32 | backend: Backend; 33 | } 34 | 35 | /** 36 | * Client used to register workflows and start runs. 37 | */ 38 | export class OpenWorkflow { 39 | private backend: Backend; 40 | private registry = new WorkflowRegistry(); 41 | 42 | constructor(options: OpenWorkflowOptions) { 43 | this.backend = options.backend; 44 | } 45 | 46 | /** 47 | * Create a new Worker with this client's backend and workflows. 48 | * @param options - Worker options 49 | * @param options.concurrency - Max concurrent workflow runs 50 | * @returns Worker instance 51 | */ 52 | newWorker(options?: { concurrency?: number | undefined }): Worker { 53 | return new Worker({ 54 | backend: this.backend, 55 | workflows: this.registry.getAll(), 56 | concurrency: options?.concurrency, 57 | }); 58 | } 59 | 60 | /** 61 | * Provide the implementation for a declared workflow. This links the workflow 62 | * specification to its execution logic and registers it with this 63 | * OpenWorkflow instance for worker execution. 64 | * @param spec - Workflow spec 65 | * @param fn - Workflow implementation 66 | */ 67 | implementWorkflow( 68 | spec: WorkflowSpec, 69 | fn: WorkflowFunction, 70 | ): void { 71 | const workflow: Workflow = { 72 | spec, 73 | fn, 74 | }; 75 | 76 | this.registry.register(workflow as Workflow); 77 | } 78 | 79 | /** 80 | * Run a workflow from its specification. This is the primary way to schedule 81 | * a workflow using only its WorkflowSpec. 82 | * @param spec - Workflow spec 83 | * @param input - Workflow input 84 | * @param options - Run options 85 | * @returns Handle for awaiting the result 86 | * @example 87 | * ```ts 88 | * const handle = await ow.runWorkflow(emailWorkflow, { to: 'user@example.com' }); 89 | * const result = await handle.result(); 90 | * ``` 91 | */ 92 | async runWorkflow( 93 | spec: WorkflowSpec, 94 | input?: RunInput, 95 | options?: WorkflowRunOptions, 96 | ): Promise> { 97 | const validationResult = await validateInput(spec.schema, input); 98 | if (!validationResult.success) { 99 | throw new Error(validationResult.error); 100 | } 101 | const parsedInput = validationResult.value; 102 | 103 | const workflowRun = await this.backend.createWorkflowRun({ 104 | workflowName: spec.name, 105 | version: spec.version ?? null, 106 | idempotencyKey: null, 107 | config: {}, 108 | context: null, 109 | input: parsedInput ?? null, 110 | availableAt: null, 111 | deadlineAt: options?.deadlineAt ?? null, 112 | }); 113 | 114 | return new WorkflowRunHandle({ 115 | backend: this.backend, 116 | workflowRun: workflowRun, 117 | resultPollIntervalMs: DEFAULT_RESULT_POLL_INTERVAL_MS, 118 | resultTimeoutMs: DEFAULT_RESULT_TIMEOUT_MS, 119 | }); 120 | } 121 | 122 | /** 123 | * Define and register a new workflow. 124 | * @param spec - Workflow spec 125 | * @param fn - Workflow implementation 126 | * @returns Runnable workflow 127 | * @example 128 | * ```ts 129 | * const workflow = ow.defineWorkflow( 130 | * { name: 'my-workflow' }, 131 | * async ({ input, step }) => { 132 | * // workflow implementation 133 | * }, 134 | * ); 135 | * ``` 136 | */ 137 | defineWorkflow< 138 | Input, 139 | Output, 140 | TSchema extends StandardSchemaV1 | undefined = undefined, 141 | >( 142 | spec: WorkflowSpec< 143 | WorkflowHandlerInput, 144 | Output, 145 | WorkflowRunInput 146 | >, 147 | fn: WorkflowFunction, Output>, 148 | ): RunnableWorkflow< 149 | WorkflowHandlerInput, 150 | Output, 151 | WorkflowRunInput 152 | > { 153 | const workflow = defineWorkflow(spec, fn); 154 | 155 | this.registry.register(workflow as Workflow); 156 | 157 | return new RunnableWorkflow(this, workflow); 158 | } 159 | } 160 | 161 | /** 162 | * Create an OpenWorkflow client from the project config file. 163 | * @returns OpenWorkflow instance 164 | */ 165 | export async function createClient(): Promise { 166 | const { config } = await loadConfig(); 167 | return new OpenWorkflow({ backend: config.backend }); 168 | } 169 | 170 | /** 171 | * A fully defined workflow with its implementation. This class is returned by 172 | * `client.defineWorkflow` and provides the `.run()` method for scheduling 173 | * workflow runs. 174 | */ 175 | export class RunnableWorkflow { 176 | private readonly ow: OpenWorkflow; 177 | readonly workflow: Workflow; 178 | 179 | constructor(ow: OpenWorkflow, workflow: Workflow) { 180 | this.ow = ow; 181 | this.workflow = workflow; 182 | } 183 | 184 | /** 185 | * Starts a new workflow run. 186 | * @param input - Workflow input 187 | * @param options - Run options 188 | * @returns Workflow run handle 189 | */ 190 | async run( 191 | input?: RunInput, 192 | options?: WorkflowRunOptions, 193 | ): Promise> { 194 | return this.ow.runWorkflow(this.workflow.spec, input, options); 195 | } 196 | } 197 | 198 | // 199 | // --- Workflow Run 200 | // 201 | 202 | /** 203 | * Options for creating a new workflow run from a runnable workflow when calling 204 | * `workflowDef.run()`. 205 | */ 206 | export interface WorkflowRunOptions { 207 | /** 208 | * Set a deadline for the workflow run. If the workflow exceeds this deadline, 209 | * it will be marked as failed. 210 | */ 211 | deadlineAt?: Date; 212 | } 213 | 214 | /** 215 | * Options for WorkflowHandle. 216 | */ 217 | export interface WorkflowHandleOptions { 218 | backend: Backend; 219 | workflowRun: WorkflowRun; 220 | resultPollIntervalMs: number; 221 | resultTimeoutMs: number; 222 | } 223 | 224 | /** 225 | * Options for result() on a WorkflowRunHandle. 226 | */ 227 | export interface WorkflowRunHandleResultOptions { 228 | /** 229 | * Time to wait for a workflow run to complete. Throws an error if the timeout 230 | * is exceeded. 231 | * @default 300000 (5 minutes) 232 | */ 233 | timeoutMs?: number; 234 | } 235 | 236 | /** 237 | * Represents a started workflow run and provides methods to await its result. 238 | * Returned from `workflowDef.run()`. 239 | */ 240 | export class WorkflowRunHandle { 241 | private backend: Backend; 242 | readonly workflowRun: WorkflowRun; 243 | private resultPollIntervalMs: number; 244 | private resultTimeoutMs: number; 245 | 246 | constructor(options: WorkflowHandleOptions) { 247 | this.backend = options.backend; 248 | this.workflowRun = options.workflowRun; 249 | this.resultPollIntervalMs = options.resultPollIntervalMs; 250 | this.resultTimeoutMs = options.resultTimeoutMs; 251 | } 252 | 253 | /** 254 | * Waits for the workflow run to complete and returns the result. 255 | * @param options - Options for waiting for the result 256 | * @returns Workflow output 257 | */ 258 | async result(options?: WorkflowRunHandleResultOptions): Promise { 259 | const start = Date.now(); 260 | const timeout = options?.timeoutMs ?? this.resultTimeoutMs; 261 | 262 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 263 | while (true) { 264 | const latest = await this.backend.getWorkflowRun({ 265 | workflowRunId: this.workflowRun.id, 266 | }); 267 | 268 | if (!latest) { 269 | throw new Error(`Workflow run ${this.workflowRun.id} no longer exists`); 270 | } 271 | 272 | // 'succeeded' status is deprecated 273 | if (latest.status === "succeeded" || latest.status === "completed") { 274 | return latest.output as Output; 275 | } 276 | 277 | if (latest.status === "failed") { 278 | throw new Error( 279 | `Workflow ${this.workflowRun.workflowName} failed: ${JSON.stringify(latest.error)}`, 280 | ); 281 | } 282 | 283 | if (latest.status === "canceled") { 284 | throw new Error( 285 | `Workflow ${this.workflowRun.workflowName} was canceled`, 286 | ); 287 | } 288 | 289 | if (Date.now() - start > timeout) { 290 | throw new Error( 291 | `Timed out waiting for workflow run ${this.workflowRun.id} to finish`, 292 | ); 293 | } 294 | 295 | await new Promise((resolve) => { 296 | setTimeout(resolve, this.resultPollIntervalMs); 297 | }); 298 | } 299 | } 300 | 301 | /** 302 | * Cancels the workflow run. Only workflows in pending, running, or sleeping 303 | * status can be canceled. 304 | */ 305 | async cancel(): Promise { 306 | await this.backend.cancelWorkflowRun({ 307 | workflowRunId: this.workflowRun.id, 308 | }); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /packages/openworkflow/core/step.test.ts: -------------------------------------------------------------------------------- 1 | import { ok } from "./result.js"; 2 | import { 3 | createStepAttemptCacheFromAttempts, 4 | getCachedStepAttempt, 5 | addToStepAttemptCache, 6 | normalizeStepOutput, 7 | calculateSleepResumeAt, 8 | createSleepContext, 9 | } from "./step.js"; 10 | import type { StepAttempt, StepAttemptCache } from "./step.js"; 11 | import { describe, expect, test } from "vitest"; 12 | 13 | describe("createStepAttemptCacheFromAttempts", () => { 14 | test("creates empty cache from empty array", () => { 15 | const cache = createStepAttemptCacheFromAttempts([]); 16 | 17 | expect(cache.size).toBe(0); 18 | }); 19 | 20 | test("includes completed attempts in cache", () => { 21 | const attempt = createMockStepAttempt({ 22 | stepName: "step-a", 23 | status: "completed", 24 | output: "result", 25 | }); 26 | const cache = createStepAttemptCacheFromAttempts([attempt]); 27 | 28 | expect(cache.size).toBe(1); 29 | expect(cache.get("step-a")).toBe(attempt); 30 | }); 31 | 32 | test("includes succeeded attempts in cache (deprecated status)", () => { 33 | const attempt = createMockStepAttempt({ 34 | stepName: "step-b", 35 | status: "succeeded", 36 | output: "result", 37 | }); 38 | const cache = createStepAttemptCacheFromAttempts([attempt]); 39 | 40 | expect(cache.size).toBe(1); 41 | expect(cache.get("step-b")).toBe(attempt); 42 | }); 43 | 44 | test("excludes running attempts from cache", () => { 45 | const attempt = createMockStepAttempt({ 46 | stepName: "step-c", 47 | status: "running", 48 | }); 49 | const cache = createStepAttemptCacheFromAttempts([attempt]); 50 | 51 | expect(cache.size).toBe(0); 52 | }); 53 | 54 | test("excludes failed attempts from cache", () => { 55 | const attempt = createMockStepAttempt({ 56 | stepName: "step-d", 57 | status: "failed", 58 | error: { message: "failed" }, 59 | }); 60 | const cache = createStepAttemptCacheFromAttempts([attempt]); 61 | 62 | expect(cache.size).toBe(0); 63 | }); 64 | 65 | test("filters mixed statuses correctly", () => { 66 | const attempts = [ 67 | createMockStepAttempt({ 68 | stepName: "completed-step", 69 | status: "completed", 70 | }), 71 | createMockStepAttempt({ stepName: "running-step", status: "running" }), 72 | createMockStepAttempt({ stepName: "failed-step", status: "failed" }), 73 | createMockStepAttempt({ 74 | stepName: "succeeded-step", 75 | status: "succeeded", 76 | }), 77 | ]; 78 | const cache = createStepAttemptCacheFromAttempts(attempts); 79 | 80 | expect(cache.size).toBe(2); 81 | expect(cache.has("completed-step")).toBe(true); 82 | expect(cache.has("succeeded-step")).toBe(true); 83 | expect(cache.has("running-step")).toBe(false); 84 | expect(cache.has("failed-step")).toBe(false); 85 | }); 86 | 87 | test("uses step name as cache key", () => { 88 | const attempt = createMockStepAttempt({ 89 | stepName: "my-unique-step-name", 90 | status: "completed", 91 | }); 92 | const cache = createStepAttemptCacheFromAttempts([attempt]); 93 | 94 | expect(cache.get("my-unique-step-name")).toBe(attempt); 95 | expect(cache.get("other-name")).toBeUndefined(); 96 | }); 97 | }); 98 | 99 | describe("getCachedStepAttempt", () => { 100 | test("returns cached attempt when present", () => { 101 | const attempt = createMockStepAttempt({ stepName: "cached-step" }); 102 | const cache: StepAttemptCache = new Map([["cached-step", attempt]]); 103 | 104 | const result = getCachedStepAttempt(cache, "cached-step"); 105 | 106 | expect(result).toBe(attempt); 107 | }); 108 | 109 | test("returns undefined when step not in cache", () => { 110 | const cache: StepAttemptCache = new Map(); 111 | 112 | const result = getCachedStepAttempt(cache, "missing-step"); 113 | 114 | expect(result).toBeUndefined(); 115 | }); 116 | 117 | test("returns undefined for similar but different step names", () => { 118 | const attempt = createMockStepAttempt({ stepName: "step-1" }); 119 | const cache: StepAttemptCache = new Map([["step-1", attempt]]); 120 | 121 | expect(getCachedStepAttempt(cache, "step-2")).toBeUndefined(); 122 | expect(getCachedStepAttempt(cache, "Step-1")).toBeUndefined(); 123 | expect(getCachedStepAttempt(cache, "step-1 ")).toBeUndefined(); 124 | }); 125 | }); 126 | 127 | describe("addToStepAttemptCache", () => { 128 | test("adds attempt to empty cache", () => { 129 | const cache: StepAttemptCache = new Map(); 130 | const attempt = createMockStepAttempt({ stepName: "new-step" }); 131 | 132 | const newCache = addToStepAttemptCache(cache, attempt); 133 | 134 | expect(newCache.size).toBe(1); 135 | expect(newCache.get("new-step")).toBe(attempt); 136 | }); 137 | 138 | test("adds attempt to existing cache", () => { 139 | const existing = createMockStepAttempt({ stepName: "existing-step" }); 140 | const cache: StepAttemptCache = new Map([["existing-step", existing]]); 141 | const newAttempt = createMockStepAttempt({ stepName: "new-step" }); 142 | 143 | const newCache = addToStepAttemptCache(cache, newAttempt); 144 | 145 | expect(newCache.size).toBe(2); 146 | expect(newCache.get("existing-step")).toBe(existing); 147 | expect(newCache.get("new-step")).toBe(newAttempt); 148 | }); 149 | 150 | test("does not mutate original cache (immutable)", () => { 151 | const existing = createMockStepAttempt({ stepName: "existing-step" }); 152 | const cache: StepAttemptCache = new Map([["existing-step", existing]]); 153 | const newAttempt = createMockStepAttempt({ stepName: "new-step" }); 154 | 155 | const newCache = addToStepAttemptCache(cache, newAttempt); 156 | 157 | expect(cache.size).toBe(1); 158 | expect(cache.has("new-step")).toBe(false); 159 | expect(newCache.size).toBe(2); 160 | }); 161 | 162 | test("overwrites existing entry with same step name", () => { 163 | const original = createMockStepAttempt({ 164 | stepName: "step", 165 | output: "original", 166 | }); 167 | const cache: StepAttemptCache = new Map([["step", original]]); 168 | const replacement = createMockStepAttempt({ 169 | stepName: "step", 170 | output: "replacement", 171 | }); 172 | 173 | const newCache = addToStepAttemptCache(cache, replacement); 174 | 175 | expect(newCache.size).toBe(1); 176 | expect(newCache.get("step")?.output).toBe("replacement"); 177 | }); 178 | }); 179 | 180 | describe("normalizeStepOutput", () => { 181 | test("passes through string values", () => { 182 | expect(normalizeStepOutput("hello")).toBe("hello"); 183 | }); 184 | 185 | test("passes through number values", () => { 186 | expect(normalizeStepOutput(42)).toBe(42); 187 | expect(normalizeStepOutput(3.14)).toBe(3.14); 188 | expect(normalizeStepOutput(0)).toBe(0); 189 | expect(normalizeStepOutput(-1)).toBe(-1); 190 | }); 191 | 192 | test("passes through boolean values", () => { 193 | expect(normalizeStepOutput(true)).toBe(true); 194 | expect(normalizeStepOutput(false)).toBe(false); 195 | }); 196 | 197 | test("passes through null", () => { 198 | expect(normalizeStepOutput(null)).toBeNull(); 199 | }); 200 | 201 | test("converts undefined to null", () => { 202 | // eslint-disable-next-line unicorn/no-useless-undefined 203 | expect(normalizeStepOutput(undefined)).toBeNull(); 204 | }); 205 | 206 | test("passes through object values", () => { 207 | const obj = { foo: "bar", nested: { baz: 123 } }; 208 | expect(normalizeStepOutput(obj)).toBe(obj); 209 | }); 210 | 211 | test("passes through array values", () => { 212 | const arr = [1, 2, 3]; 213 | expect(normalizeStepOutput(arr)).toBe(arr); 214 | }); 215 | 216 | test("passes through empty object", () => { 217 | const obj = {}; 218 | expect(normalizeStepOutput(obj)).toBe(obj); 219 | }); 220 | 221 | test("passes through empty array", () => { 222 | const arr: unknown[] = []; 223 | expect(normalizeStepOutput(arr)).toBe(arr); 224 | }); 225 | }); 226 | 227 | describe("calculateSleepResumeAt", () => { 228 | test("calculates resume time from duration string", () => { 229 | const now = 1_000_000; 230 | const result = calculateSleepResumeAt("5s", now); 231 | 232 | expect(result).toEqual(ok(new Date(now + 5000))); 233 | }); 234 | 235 | test("calculates resume time with milliseconds", () => { 236 | const now = 1_000_000; 237 | const result = calculateSleepResumeAt("500ms", now); 238 | 239 | expect(result).toEqual(ok(new Date(now + 500))); 240 | }); 241 | 242 | test("calculates resume time with minutes", () => { 243 | const now = 1_000_000; 244 | const result = calculateSleepResumeAt("2m", now); 245 | 246 | expect(result).toEqual(ok(new Date(now + 2 * 60 * 1000))); 247 | }); 248 | 249 | test("calculates resume time with hours", () => { 250 | const now = 1_000_000; 251 | const result = calculateSleepResumeAt("1h", now); 252 | 253 | expect(result).toEqual(ok(new Date(now + 60 * 60 * 1000))); 254 | }); 255 | 256 | test("uses Date.now() when now is not provided", () => { 257 | const before = Date.now(); 258 | const result = calculateSleepResumeAt("1s"); 259 | const after = Date.now(); 260 | 261 | expect(result.ok).toBe(true); 262 | if (result.ok) { 263 | const resumeTime = result.value.getTime(); 264 | expect(resumeTime).toBeGreaterThanOrEqual(before + 1000); 265 | expect(resumeTime).toBeLessThanOrEqual(after + 1000); 266 | } 267 | }); 268 | 269 | test("returns error for invalid duration", () => { 270 | // @ts-expect-error testing invalid input 271 | const result = calculateSleepResumeAt("invalid"); 272 | 273 | expect(result.ok).toBe(false); 274 | if (!result.ok) { 275 | expect(result.error).toBeInstanceOf(Error); 276 | } 277 | }); 278 | 279 | test("returns error for empty duration", () => { 280 | // @ts-expect-error testing invalid input 281 | const result = calculateSleepResumeAt(""); 282 | 283 | expect(result.ok).toBe(false); 284 | }); 285 | }); 286 | 287 | describe("createSleepContext", () => { 288 | test("creates sleep context with ISO string timestamp", () => { 289 | const resumeAt = new Date("2025-06-15T10:30:00.000Z"); 290 | const context = createSleepContext(resumeAt); 291 | 292 | expect(context).toEqual({ 293 | kind: "sleep", 294 | resumeAt: "2025-06-15T10:30:00.000Z", 295 | }); 296 | }); 297 | 298 | test("preserves millisecond precision", () => { 299 | const resumeAt = new Date("2025-01-01T00:00:00.123Z"); 300 | const context = createSleepContext(resumeAt); 301 | 302 | expect(context.resumeAt).toBe("2025-01-01T00:00:00.123Z"); 303 | }); 304 | 305 | test("always has kind set to sleep", () => { 306 | const resumeAt = new Date(); 307 | const context = createSleepContext(resumeAt); 308 | 309 | expect(context.kind).toBe("sleep"); 310 | }); 311 | 312 | test("creates context from current date", () => { 313 | const now = new Date(); 314 | const context = createSleepContext(now); 315 | 316 | expect(context.resumeAt).toBe(now.toISOString()); 317 | }); 318 | }); 319 | 320 | function createMockStepAttempt( 321 | overrides: Partial = {}, 322 | ): StepAttempt { 323 | return { 324 | namespaceId: "default", 325 | id: "step-1", 326 | workflowRunId: "workflow-1", 327 | stepName: "test-step", 328 | kind: "function", 329 | status: "completed", 330 | config: {}, 331 | context: null, 332 | output: null, 333 | error: null, 334 | childWorkflowRunNamespaceId: null, 335 | childWorkflowRunId: null, 336 | startedAt: new Date("2025-01-01T00:00:00Z"), 337 | finishedAt: new Date("2025-01-01T00:00:01Z"), 338 | createdAt: new Date("2025-01-01T00:00:00Z"), 339 | updatedAt: new Date("2025-01-01T00:00:01Z"), 340 | ...overrides, 341 | }; 342 | } 343 | --------------------------------------------------------------------------------